diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 0000000..bdf8e9c --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,50 @@ +name: Pre-Release NPM + +on: + push: + tags: + - '!v*' + - 'v*-rc*' + - 'v*-alpha*' + - 'v*-beta*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Node + uses: actions/setup-node@v2 + with: + node-version: '14.x' + registry-url: 'https://registry.npmjs.org' + + - name: Setup npm cache + uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Run build script + run: npm run build + + - name: Publish package to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public --tag beta + + - name: Create GitHub release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + draft: false + prerelease: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1295c8b..3398d16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: - 'v*' + - '!v*-rc*' + - '!v*-alpha*' + - '!v*-beta*' jobs: build: diff --git a/package-lock.json b/package-lock.json index 81489a0..2d803c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,15 @@ { "name": "@flybywiresim/igniter", - "version": "1.2.3", + "version": "2.0.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@flybywiresim/igniter", - "version": "1.2.3", + "version": "2.0.0-alpha.1", + "dependencies": { + "cli-progress": "^3.12.0" + }, "bin": { "igniter": "dist/binary.mjs" }, @@ -15,6 +18,7 @@ "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.2.0", "@rollup/plugin-typescript": "^8.2.0", + "@types/cli-progress": "^3.11.3", "@types/jest": "^26.0.20", "@types/mkdirp": "^1.0.1", "@typescript-eslint/eslint-plugin": "^4.15.0", @@ -1944,6 +1948,15 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/cli-progress": { + "version": "3.11.3", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.3.tgz", + "integrity": "sha512-/+C9xAdVtc+g5yHHkGBThgAA8rYpi5B+2ve3wLtybYj0JHEBs57ivR4x/zGfSsplRnV+psE91Nfin1soNKqz5Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", @@ -2462,10 +2475,9 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true, + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "engines": { "node": ">=8" } @@ -3160,6 +3172,17 @@ "node": ">=0.10.0" } }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -5256,7 +5279,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -8812,14 +8834,13 @@ } }, "node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" + "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" @@ -8828,8 +8849,7 @@ "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/string.prototype.matchall": { "version": "4.0.3", @@ -8877,12 +8897,11 @@ } }, "node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dependencies": { - "ansi-regex": "^5.0.0" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" @@ -11329,6 +11348,15 @@ "@babel/types": "^7.3.0" } }, + "@types/cli-progress": { + "version": "3.11.3", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.3.tgz", + "integrity": "sha512-/+C9xAdVtc+g5yHHkGBThgAA8rYpi5B+2ve3wLtybYj0JHEBs57ivR4x/zGfSsplRnV+psE91Nfin1soNKqz5Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", @@ -11703,10 +11731,9 @@ } }, "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "4.3.0", @@ -12243,6 +12270,14 @@ } } }, + "cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "requires": { + "string-width": "^4.2.3" + } + }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -13875,8 +13910,7 @@ "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, "is-generator-fn": { "version": "2.1.0", @@ -16658,21 +16692,19 @@ } }, "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" + "strip-ansi": "^6.0.1" }, "dependencies": { "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" } } }, @@ -16713,12 +16745,11 @@ } }, "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "requires": { - "ansi-regex": "^5.0.0" + "ansi-regex": "^5.0.1" } }, "strip-bom": { diff --git a/package.json b/package.json index c7cdc72..9ed383e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flybywiresim/igniter", - "version": "1.2.3", + "version": "2.0.0-alpha.1", "types": "dist/index.d.ts", "description": "An intelligent task runner written in Typescript.", "repository": { @@ -14,6 +14,9 @@ "lint": "eslint --ext .ts ./", "test": "jest" }, + "dependencies": { + "cli-progress": "^3.12.0" + }, "devDependencies": { "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-json": "^4.1.0", @@ -21,6 +24,7 @@ "@rollup/plugin-typescript": "^8.2.0", "@types/jest": "^26.0.20", "@types/mkdirp": "^1.0.1", + "@types/cli-progress": "^3.11.3", "@typescript-eslint/eslint-plugin": "^4.15.0", "@wessberg/rollup-plugin-ts": "^1.3.8", "chalk": "^4.1.0", diff --git a/rollup.config.mjs b/rollup.config.mjs index d33bbf8..760b8d7 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -11,7 +11,7 @@ export default [ file: './dist/binary.mjs', }, plugins: [ - typescript(), + typescript({ exclude: ["node_modules/**/*.json"] }), nodeResolve(), commonjs(), json(), @@ -19,7 +19,7 @@ export default [ ], }, { - input: 'src/Library/Index.ts', + input: 'src/Library/index.ts', output: { file: './dist/index.mjs', }, diff --git a/src/Binary.ts b/src/Binary.ts index d85e3fe..3b01b97 100644 --- a/src/Binary.ts +++ b/src/Binary.ts @@ -1,18 +1,20 @@ import { Command } from 'commander'; import { Pool } from 'task-pool'; +import os from 'os'; import { findConfigPath, loadConfigTask } from './Helpers'; import { Context } from './Library/Contracts/Context'; import { version } from '../package.json'; -import Renderer from './Renderer'; import Cache from './Cache'; +import { TaskOfTasks } from './Library'; +import Renderer from './Renderer'; const binary = (new Command()).version(version) .option('-c, --config ', 'set the configuration file name', 'igniter.config.mjs') - .option('-j, --num-workers ', 'set the maximum number of workers to use', `${Number.MAX_SAFE_INTEGER}`) + .option('-j, --num-workers ', 'set the maximum number of workers to use', `${os.cpus().length}`) .option('-r, --regex ', 'regular expression used to filter tasks') .option('-i, --invert', 'if true, regex will be used to reject tasks') - .option('--no-cache', 'do not skip tasks, even if hash matches cache') - .option('--no-tty', 'do not show updating output, just show a single render') + .option('-n, --no-cache', 'do not skip tasks, even if hash matches cache') + .option('-y, --no-tty', 'do not show updating output, just show a single render') .option('-d, --dry-run', 'skip all tasks to show configuration') .option('--debug', 'stop when an exception is thrown and show trace') .parse(process.argv); @@ -26,7 +28,10 @@ const context: Context = { dryRun: options.dryRun, filterRegex: options.regex ? RegExp(options.regex) : undefined, invertRegex: options.invert, - taskPool: new Pool({ limit: options.numWorkers, timeout: 120000 }), + taskPool: new Pool({ limit: options.numWorkers, timeout: 120_000 }), + numWorkers: options.numWorkers, + showNestedTaskKeys: true, + isTTY: process.stdout.isTTY, }; // Create and register a cache if needed. @@ -36,11 +41,15 @@ if (options.cache) context.cache = new Cache(context); const configRootTask = await loadConfigTask(context); // Set up root task (and thus children) to use our context. -configRootTask.useContext(context); +configRootTask.useContext(context, null); // Run and Render the config root task. -// If we have tty, render every 100ms, other perform single render. -await Renderer(configRootTask, options.tty ? 100 : 0); + +const rootTaskCompletionCallback = Renderer.render(context, configRootTask as TaskOfTasks); + +configRootTask.run().finally(() => rootTaskCompletionCallback()); // Export the new cache. -if (context.cache) context.cache.export(); +if (context.cache) { + context.cache.export(); +} diff --git a/src/Cache.ts b/src/Cache.ts index a181b31..50a77f3 100644 --- a/src/Cache.ts +++ b/src/Cache.ts @@ -31,7 +31,7 @@ export default class Cache implements CacheContract { /** * Get the value for a given key in the cache. */ - get(key: string): string { + get(key: string): string | undefined { return this.map.get(key); } diff --git a/src/Helpers.ts b/src/Helpers.ts index 987a4c4..3d3897e 100644 --- a/src/Helpers.ts +++ b/src/Helpers.ts @@ -38,7 +38,7 @@ export const storage = (context: Context, relativePath: string = ''): string => */ export const generateHashFromPath = (absolutePath: string): string => { // The hash is undefined if the path doesn't exist. - if (!fs.existsSync(absolutePath)) return undefined; + if (!fs.existsSync(absolutePath)) return 'undefined'; const stats = fs.statSync(absolutePath); if (stats.isFile()) return hasha(path.basename(absolutePath) + hasha.fromFileSync(absolutePath)); diff --git a/src/Library/Contracts/Cache.ts b/src/Library/Contracts/Cache.ts index aee70ea..4091ec7 100644 --- a/src/Library/Contracts/Cache.ts +++ b/src/Library/Contracts/Cache.ts @@ -8,7 +8,7 @@ export interface Cache { /** * Get the value for a given key in the cache. */ - get(key: string): string; + get(key: string): string | undefined; /** * Set a value for a given key in the cache. diff --git a/src/Library/Contracts/Context.ts b/src/Library/Contracts/Context.ts index c00d9b1..dd75911 100644 --- a/src/Library/Contracts/Context.ts +++ b/src/Library/Contracts/Context.ts @@ -5,8 +5,11 @@ export interface Context { debug: boolean, configPath: string, dryRun: boolean, - filterRegex: RegExp|undefined, + filterRegex: RegExp | undefined, invertRegex: boolean, cache?: Cache, taskPool: Pool, + numWorkers: number, + showNestedTaskKeys: boolean, + isTTY: boolean, } diff --git a/src/Library/Contracts/Task.ts b/src/Library/Contracts/Task.ts index ce97ed5..5d64eb9 100644 --- a/src/Library/Contracts/Task.ts +++ b/src/Library/Contracts/Task.ts @@ -45,18 +45,26 @@ export interface Task { */ run: TaskRunner; + willRun: () => boolean; + /** * The current status of this generic task. */ status: TaskStatus; + failedString: string | null; + + parent: Task | null; + /** * Register a context with the task (and sub-tasks). */ - useContext(context: Context): void; + useContext(context: Context, parentTask: Task | null): void; /** * Render the task. */ render(depth?: number): string; + + on(event: 'statusChange', cb: (task: Task) => void): void; } diff --git a/src/Library/Tasks/DummyTask.ts b/src/Library/Tasks/DummyTask.ts index 0531611..af76f18 100644 --- a/src/Library/Tasks/DummyTask.ts +++ b/src/Library/Tasks/DummyTask.ts @@ -13,9 +13,4 @@ export default class DummyTask extends GenericTask { if (this.exitStatus === TaskStatus.Failed) throw Error(); }); } - - protected shouldSkip(taskName: string) { - if (super.shouldSkip(taskName)) return true; - return this.exitStatus === TaskStatus.Skipped; - } } diff --git a/src/Library/Tasks/ExecTask.ts b/src/Library/Tasks/ExecTask.ts index 20a76e6..a1e76e6 100644 --- a/src/Library/Tasks/ExecTask.ts +++ b/src/Library/Tasks/ExecTask.ts @@ -15,14 +15,18 @@ export default class ExecTask extends GenericTask { const poolExec = this.context.taskPool.promise.wrap((execCmd) => new Promise((resolve, reject) => { const p = exec(execCmd); + if (!p.stderr || !p.stdout) { + throw new Error('Spawn child process had no stderr or stdout'); + } + let stderr = ''; p.stderr.on('data', (data) => { stderr += data; }); p.on('exit', (code) => { - p.stdout.destroy(); - p.stderr.destroy(); + p.stdout?.destroy(); + p.stderr?.destroy(); if (code === 0) { resolve(code); diff --git a/src/Library/Tasks/GenericTask.ts b/src/Library/Tasks/GenericTask.ts index 4155a2d..2626eac 100644 --- a/src/Library/Tasks/GenericTask.ts +++ b/src/Library/Tasks/GenericTask.ts @@ -6,65 +6,102 @@ import { Context } from '../Contracts/Context'; import ExecTaskError from './ExecTaskError'; export default class GenericTask implements Task { - protected context: Context; + protected givenContext: Context | undefined; - protected errorOutput: string; + private taskStatus: TaskStatus = TaskStatus.Queued; - public status: TaskStatus = TaskStatus.Queued; + public parent: Task | null = null; + + public failedString: string | null = null; + + public get status() { + return this.taskStatus; + } + + protected get context(): Context { + const context = this.givenContext; + + if (!context) { + throw new Error('.context called with no context set on task'); + } + + return context; + } + + public set status(newStatus: TaskStatus) { + if (this.taskStatus !== newStatus) { + this.taskStatus = newStatus; + + for (const cb of this.statusChangeCallbacks) { + cb(this); + } + } + } + + private statusChangeCallbacks: ((task: Task) => void)[] = []; /** - * @param key The key of this generic task. + * @param name The name of this generic task. * @param executor The TaskRunner used to run this task. * @param hashFolders Folders used to create caching hash. */ constructor( - public key: string, + private name: string, private executor: TaskRunner, private hashFolders: string[] = [], ) {} + get key(): string { + if (!this.context.showNestedTaskKeys) { + return this.name; + } + + const prefix = this.parent ? `${this.parent.key}:` : ''; + + return `${prefix}${this.name}`; + } + /** * Register a context with the task (and sub-tasks). */ - useContext(context: Context) { - this.context = context; + useContext(context: Context, parentTask: Task) { + this.givenContext = context; + this.parent = parentTask; + } + + willRun() { + return !(this.shouldSkipRegex(this.key) || this.shouldSkipCache(this.key)); } /** * Run the task executor. */ async run(prefix?: string) { - const taskKey = (prefix || '') + this.key; - if (this.shouldSkip(taskKey)) { + if (!this.willRun()) { this.status = TaskStatus.Skipped; return; } try { - this.status = TaskStatus.Running; await this.executor(prefix); this.status = TaskStatus.Success; // Set the cache value (will be saved when the overall cache is exported). if (this.context.cache) { const generateHash = generateHashFromPaths(this.hashFolders.map((path) => storage(this.context, path))); - this.context.cache.set(taskKey, generateHash); + this.context.cache.set(this.key, generateHash); } } catch (error) { if (this.context.debug) { throw error; } - this.status = TaskStatus.Failed; - if (error instanceof ExecTaskError) { - this.errorOutput = error.stderr; + this.failedString = error.stderr; } - } - } - protected shouldSkip(taskKey?: string) { - return this.shouldSkipRegex(taskKey) || this.shouldSkipCache(taskKey); + this.status = TaskStatus.Failed; + } } protected shouldSkipRegex(taskKey: string) { @@ -97,10 +134,15 @@ export default class GenericTask implements Task { if (s === TaskStatus.Skipped) return ['↪', chalk.gray]; return ['⊙', chalk.magenta]; // Replaced with spinner :) })(this.status); - if (this.status === TaskStatus.Failed && this.errorOutput !== undefined) { - const error = `${indent} ${this.errorOutput.split(/\r?\n/).join(`\n${indent} `)}`; + if (this.status === TaskStatus.Failed && this.failedString !== undefined) { + // eslint-disable-next-line max-len + const error = `${indent} ${this.failedString?.split(/\r?\n/).join(`\n${indent} `) ?? `${indent} `}`; return colour(`${indent + symbol} ${this.key}\n${error}`); } return colour(`${indent + symbol} ${this.key}`); } + + on(event: 'statusChange', cb: (task: Task) => void) { + this.statusChangeCallbacks.push(cb); + } } diff --git a/src/Library/Tasks/TaskOfTasks.ts b/src/Library/Tasks/TaskOfTasks.ts index 9d9d38b..24984f8 100644 --- a/src/Library/Tasks/TaskOfTasks.ts +++ b/src/Library/Tasks/TaskOfTasks.ts @@ -3,61 +3,121 @@ import { Context } from '../Contracts/Context'; import { Task, TaskStatus } from '../Contracts/Task'; export default class TaskOfTasks implements Task { - private context: Context; + private givenContext: Context | undefined; + + private taskStatus: TaskStatus = TaskStatus.Queued; + + public parent: Task | null = null; + + public failedString: string | null = null; + + public get status() { + return this.taskStatus; + } + + protected get context(): Context { + const context = this.givenContext; + + if (!context) { + throw new Error('.context called with no context set on task'); + } + + return context; + } + + public set status(newStatus: TaskStatus) { + if (this.taskStatus !== newStatus) { + this.taskStatus = newStatus; + + for (const cb of this.statusChangeCallbacks) { + cb(this); + } + } + } + + private statusChangeCallbacks: ((task: Task) => void)[] = []; /** - * @param key The key of this task of tasks. + * @param name The name of this task of tasks. * @param tasks The array of tasks for this TaskOfTasks. * @param concurrent Whether the tasks should run concurrently. */ constructor( - public key: string, + private name: string, public tasks: Task[], - private concurrent = false, + public concurrent = false, ) {} /** * Register a context with the task (and sub-tasks). */ - useContext(context: Context) { - this.context = context; - this.tasks.forEach((task) => task.useContext(context)); + useContext(context: Context, parentTask: Task | null) { + this.parent = parentTask; + this.givenContext = context; + + for (const task of this.tasks) { + task.useContext(context, this); + } } - /** - * Get the status - dynamically determined depending on sub-statuses. - */ - get status(): TaskStatus { - if (this.tasks.every((task) => task.status === TaskStatus.Queued)) return TaskStatus.Queued; - if (this.tasks.every((task) => task.status === TaskStatus.Success)) return TaskStatus.Success; - if (this.tasks.every((task) => task.status === TaskStatus.Skipped)) return TaskStatus.Skipped; - if (this.tasks.every((task) => task.status === TaskStatus.Failed)) return TaskStatus.Failed; - if (this.tasks.some((task) => task.status === TaskStatus.Running)) return TaskStatus.Running; - if (this.tasks.some((task) => task.status === TaskStatus.Failed)) return TaskStatus.Failed; - return TaskStatus.Success; + get key(): string { + if (!this.context.showNestedTaskKeys) { + return this.name; + } + + const prefix = this.parent ? `${this.parent.key}:` : ''; + + return `${prefix}${this.name}`; } /** * Run the task of tasks, sequentially or concurrently as required. */ async run(prefix?: string) { - const compoundPrefix = `${(prefix || '') + this.key}:`; - if (this.concurrent) await this.concurrently(compoundPrefix); - else await this.sequentially(compoundPrefix); + this.status = TaskStatus.Running; + + const compoundPrefix = `${prefix ? `${prefix}:` : ''}${this.name}`; + + if (this.concurrent) { + await this.concurrently(compoundPrefix); + } else { + await this.sequentially(compoundPrefix); + } + } + + willRun() { + return this.tasks.some((task) => task.willRun()); } /** * Run tasks concurrently. Resolves when all have ran. */ - async concurrently(prefix: string) { - await Promise.all(this.tasks.map((task) => task.run(prefix))); + private async concurrently(prefix: string) { + await Promise.all(this.tasks.map((task) => task.run(prefix))).then(() => { + this.status = this.tasks.every((it) => it.status === TaskStatus.Skipped) + ? TaskStatus.Skipped + : TaskStatus.Success; + }).catch(() => { + this.status = TaskStatus.Failed; + }); } /** * Run tasks sequentially. Resolves when all have ran. */ - async sequentially(prefix: string) { - for await (const task of this.tasks) await task.run(prefix); + private async sequentially(prefix: string) { + try { + for await (const task of this.tasks) { + await task.run(prefix); + } + } catch (e) { + this.status = TaskStatus.Failed; + throw e; + } + + this.status = this.tasks.every((it) => it.status === TaskStatus.Skipped) + ? TaskStatus.Skipped + : TaskStatus.Success; } render(depth: number = 0): string { @@ -75,4 +135,33 @@ export default class TaskOfTasks implements Task { .concat(this.tasks.map((task) => task.render(depth + 1))) .join('\n'); } + + on(event: 'statusChange', cb: (task: Task) => void) { + this.statusChangeCallbacks.push(cb); + + for (const task of this.tasks) { + task.on('statusChange', cb); + } + } + + /** + * Recursively counts and returns the number of tasks in this {@link TaskOfTasks} that will not be skipped + */ + recursivelyCountTasksToRun(): number { + let count = 0; + + for (const task of this.tasks) { + if (task instanceof TaskOfTasks) { + count += task.recursivelyCountTasksToRun(); + } else { + count += task.willRun() ? 1 : 0; + } + } + + return count; + } + + static isTaskOfTasks(subject: Task): subject is TaskOfTasks { + return 'recursivelyCountTasksToRun' in subject; + } } diff --git a/src/Library/Index.ts b/src/Library/index.ts similarity index 100% rename from src/Library/Index.ts rename to src/Library/index.ts diff --git a/src/Renderer.ts b/src/Renderer.ts index e093aa6..1a0418e 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -1,32 +1,238 @@ -import { Task, TaskStatus } from './Library/Contracts/Task'; - -export default async (task: Task, refreshRate = 100) => { - const spinner = ['◜', '◠', '◝', '◞', '◡', '◟']; - - const render = () => { - process.stdout.write('\x1Bc'); - const spinnerChar = spinner.shift(); - spinner.push(spinnerChar); - const view = task.render().replaceAll('⊙', spinnerChar); - console.log(view); // eslint-disable-line no-console - }; - - // If refreshRate is zero we just want to run the root task. - // Then we'll render ONCE to get the final output, and return. - if (refreshRate === 0) { - await task.run(); - render(); - if (task.status === TaskStatus.Failed) { - process.exitCode = 1; - } - return; +import cliProgress, { MultiBar, SingleBar } from 'cli-progress'; +import c from 'chalk'; +import { Task, TaskOfTasks, TaskStatus } from './Library'; +import { Context } from './Library/Contracts/Context'; + +import packageJson from '../package.json'; + +export default class Renderer { + static finishedTag(): string { + return c.bgGreen(c.blackBright(' Finished ')); + } + + static warningTag(): string { + return c.bgYellow(c.blackBright(' Warning ')); + } + + static failedTag(): string { + return c.bgRed(c.blackBright(' Failed ')); + } + + static debugTag(): string { + return c.bgMagenta(c.blackBright(' Debug ')); } - const interval = setInterval(render, refreshRate); - await task.run(); - clearInterval(interval); - render(); - if (task.status === TaskStatus.Failed) { - process.exitCode = 1; + static progressTag(): string { + return c.magenta(' Progress '); + } + + static infoTag(): string { + return c.bgBlue(c.blackBright(' Info ')); + } + + static emptyTag(): string { + return ' '.repeat(10); + } + + static runningTasks: Task[] = []; + + static render(context: Context, configRootTask: TaskOfTasks): () => void { + const startTime = Date.now(); + + let doneTasks = 0; + + const cursors = ['|', '/', '-', '\\']; + let cursorIndex = 0; + + Renderer.runningTasks.length = 0; + + let bars: MultiBar | undefined; + let progressBar: SingleBar | undefined; + if (context.isTTY) { + bars = new cliProgress.MultiBar({ clearOnComplete: true }, cliProgress.Presets.shades_classic); + progressBar = bars.create( + 20, + 0, + undefined, + { + clearOnComplete: true, + format: `${Renderer.progressTag()} {bar} {cursor} {percentage}% | {currentlyRunning} | {value}/{total}`, + noTTYOutput: !context.isTTY, + notTTYSchedule: 100, + barsize: 30, + }, + ); + } + + const tasksToRun = configRootTask.recursivelyCountTasksToRun(); + + progressBar?.start(tasksToRun, 0); + + let indent = ''; + + const timeouts = new Map(); + + function log(arg: string) { + if (context.isTTY) { + bars?.log(`${arg}\n`); + } else { + process.stdout.write(`${arg}\n`); + } + } + + function updateProgressBar() { + let tasks = ''; + let renderedTasks = 0; + + cursorIndex = (cursorIndex + 1) % cursors.length; + + if (Renderer.runningTasks.length === 0) { + tasks = ''; + } + + for (let i = 0; i < Renderer.runningTasks.length; i += 1) { + const runningTask = Renderer.runningTasks[i]; + + const maxLen = Math.max(0, process.stdout.getWindowSize()[0] - 100); + + if (tasks.length > maxLen) { + tasks += `... +${Renderer.runningTasks.length - renderedTasks}`; + break; + } + + if (tasks.length > 0) { + tasks += ', '; + } + + if (runningTask.key.length <= maxLen) { + tasks += runningTask.key; + } else { + tasks += `+${Renderer.runningTasks.length - renderedTasks}`; + break; + } + + renderedTasks += 1; + } + + const currentlyRunning = `${c.blue(`[${tasks}]`)}`; + + progressBar?.update(doneTasks, { cursor: cursors[cursorIndex], currentlyRunning }); + } + + function logTakingLongTime(task: Task, taskStartTime: number, interval: number) { + if (task.status !== TaskStatus.Running) { + return; + } + + const seconds = Math.floor((Date.now() - taskStartTime) / 1_000); + + log(`${Renderer.warningTag()} ${ + c.yellow(`> ${indent}Task ${c.white(task.key)} is taking a long time (${seconds}s)`) + }`); + + updateProgressBar(); + + timeouts.set(task, setTimeout(() => { + logTakingLongTime(task, taskStartTime, interval); + }, interval)); + } + + function clearTaskTimeout(task: Task) { + if (timeouts.has(task)) { + clearTimeout(timeouts.get(task) as any); + timeouts.delete(task); + } + } + + const versionString = c.white`v${packageJson.version}`; + const taskCountString = c.white`${tasksToRun} tasks`; + const workersString = c.white`${context.numWorkers} workers`; + + log(`${Renderer.infoTag()} ${c.blue`> Igniter ${versionString}, queueing ${taskCountString} with ${workersString}`}`); + + configRootTask.on('statusChange', (task) => { + if (task.status === TaskStatus.Running && !TaskOfTasks.isTaskOfTasks(task)) { + Renderer.runningTasks.push(task); + + const taskStartTime = Date.now(); + const warningInterval = context.taskPool.timeout * 0.5; + + timeouts.set(task, setTimeout(() => logTakingLongTime(task, taskStartTime, warningInterval), warningInterval)); + } + + const isRootSequentialTaskChild = TaskOfTasks.isTaskOfTasks(task) && task.parent + && TaskOfTasks.isTaskOfTasks(task.parent) && !task.parent.parent && !task.parent.concurrent; + const renderAsRootSequentialTaskChild = isRootSequentialTaskChild + && (task as TaskOfTasks).tasks.filter((it) => it.willRun()).length > 1; + + // Special styling for root sequential task children + if (renderAsRootSequentialTaskChild) { + if (task.status === TaskStatus.Running) { + log(`${Renderer.emptyTag()} ${c.green` ┌`} ${c.underline`Starting group '${task.key}'`}`); + indent = '│ '; + } else if (task.status === TaskStatus.Success) { + log(`${Renderer.emptyTag()} ${c.green` └`} ${c.underline`Done with group '${task.key}'`}`); + indent = ''; + } + } + + const isTaskOfTasksWithOnlyOneTaskRun = TaskOfTasks.isTaskOfTasks(task) + && task.tasks.filter((it) => it.willRun()).length === 1; + + // We do not print a line for a task of tasks which only had one child task run + if (!isRootSequentialTaskChild && !isTaskOfTasksWithOnlyOneTaskRun && task.status === TaskStatus.Success) { + clearTaskTimeout(task); + + let successText: string; + if (TaskOfTasks.isTaskOfTasks(task)) { + successText = c.green(`> ${indent}Group ${c.white(task.key)} finished`); + } else { + successText = c.green(`> ${indent}Task ${c.white(task.key)} finished`); + } + + doneTasks += 1; + + log(`${Renderer.finishedTag()} ${successText}`); + } + + if (task.status === TaskStatus.Failed) { + clearTaskTimeout(task); + + const errorIndent = ' '.repeat(13); + + const indentedFailedString = `${errorIndent}${indent}${(task.failedString ?? '') + .split(/\r?\n/).join(`\n${errorIndent}${indent}`)}`; + + doneTasks += 1; + + log(`${Renderer.failedTag()} ${c.red(`> ${indent}${c.underline(task.key)}\n${indentedFailedString}`)}`); + } + + if ((task.status === TaskStatus.Success || task.status === TaskStatus.Failed) + && !TaskOfTasks.isTaskOfTasks(task) && Renderer.runningTasks.includes(task) + ) { + Renderer.runningTasks.splice(Renderer.runningTasks.indexOf(task), 1); + } + + updateProgressBar(); + }); + + return () => { + for (const [, timeout] of timeouts.entries()) { + clearTimeout(timeout); + } + + setTimeout(() => { + bars?.stop(); + + process.stdout.clearLine(0); + + const seconds = (Date.now() - startTime) / 1_000; + + const timeStr = c.white(`${seconds.toFixed(1)}s`); + + console.log(`${Renderer.infoTag()} ${c.blue(`> Ran ${taskCountString} in ${timeStr}`)}`); + }, 100); + }; } -}; +} diff --git a/src/task-pool.d.ts b/src/task-pool.d.ts index 8fc9f46..a69c21b 100644 --- a/src/task-pool.d.ts +++ b/src/task-pool.d.ts @@ -28,11 +28,11 @@ declare module "task-pool" { export class Pool { constructor(init: Partial) - timeout: Pick + timeout: PoolInit['timeout'] style: TPoolStyle - limit: Pick + limit: PoolInit['limit'] running: number diff --git a/tests/igniter.config.mjs b/tests/igniter.config.mjs new file mode 100644 index 0000000..ac89b3e --- /dev/null +++ b/tests/igniter.config.mjs @@ -0,0 +1,14 @@ +import { ExecTask, TaskOfTasks } from "../dist/index.mjs"; + +export default new TaskOfTasks('test', [ + new ExecTask('A', 'ping 127.0.0.1 -n 6 > nul'), + new ExecTask('B', 'ping 127.0.0.1 -n 8 > nul'), + new ExecTask('C', 'ping 127.0.0.1 -n 4 > nul'), + new ExecTask('D', 'ping 127.0.0.1 -n 3 > nul'), + new TaskOfTasks('E', [ + new ExecTask('1', 'pinag 127.0.0.1 -n 6 > nul'), + new ExecTask('2', 'ping 127.0.0.1 -n 8 > nul'), + new ExecTask('3', 'ping 127.0.0.1 -n 4 > nul'), + new ExecTask('4', 'ping 127.0.0.1 -n 3 > nul'), + ], true), +], true); diff --git a/tsconfig.json b/tsconfig.json index afaddb9..1770a6b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,10 @@ "include": [ "src/**/*.ts", "src/**/*.d.ts", - "tests/**/*.ts", ], "compilerOptions": { + "strict": true, + "skipLibCheck": true, "allowSyntheticDefaultImports": true, "declaration": true, "emitDecoratorMetadata": true, @@ -13,6 +14,6 @@ "module": "ESNext", "moduleResolution": "node", "resolveJsonModule": true, - "target": "es2017", + "target": "es2017" } }