From a2b9513d3cae083fb264ff0dad459b53d90a42a4 Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sun, 8 Oct 2023 00:56:48 -0400 Subject: [PATCH 1/8] initial commit --- package-lock.json | 101 +++++++++++++------- package.json | 4 + rollup.config.mjs | 2 +- src/Binary.ts | 23 +++-- src/Cache.ts | 2 +- src/Helpers.ts | 2 +- src/Library/Contracts/Cache.ts | 2 +- src/Library/Contracts/Context.ts | 4 +- src/Library/Contracts/Task.ts | 8 +- src/Library/Tasks/ExecTask.ts | 8 +- src/Library/Tasks/GenericTask.ts | 70 +++++++++++--- src/Library/Tasks/TaskOfTasks.ts | 106 +++++++++++++++++---- src/Renderer.ts | 156 ++++++++++++++++++++++++++----- src/task-pool.d.ts | 4 +- tests/igniter.config.mjs | 14 +++ tsconfig.json | 5 +- 16 files changed, 398 insertions(+), 113 deletions(-) create mode 100644 tests/igniter.config.mjs diff --git a/package-lock.json b/package-lock.json index 81489a0..12e4f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "@flybywiresim/igniter", "version": "1.2.3", + "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..4588626 100644 --- a/package.json +++ b/package.json @@ -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..68de081 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(), diff --git a/src/Binary.ts b/src/Binary.ts index d85e3fe..0ddb379 100644 --- a/src/Binary.ts +++ b/src/Binary.ts @@ -3,16 +3,17 @@ import { Pool } from 'task-pool'; 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 render 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('-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 +27,9 @@ 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 }), + showNestedTaskKeys: true, + isTTY: process.stdout.isTTY, }; // Create and register a cache if needed. @@ -36,11 +39,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 = 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..b900be2 100644 --- a/src/Library/Contracts/Context.ts +++ b/src/Library/Contracts/Context.ts @@ -5,8 +5,10 @@ export interface Context { debug: boolean, configPath: string, dryRun: boolean, - filterRegex: RegExp|undefined, + filterRegex: RegExp | undefined, invertRegex: boolean, cache?: Cache, taskPool: Pool, + showNestedTaskKeys: boolean, + isTTY: boolean, } diff --git a/src/Library/Contracts/Task.ts b/src/Library/Contracts/Task.ts index ce97ed5..ad2c6b9 100644 --- a/src/Library/Contracts/Task.ts +++ b/src/Library/Contracts/Task.ts @@ -50,13 +50,19 @@ export interface 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/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..ce626d3 100644 --- a/src/Library/Tasks/GenericTask.ts +++ b/src/Library/Tasks/GenericTask.ts @@ -6,28 +6,67 @@ import { Context } from '../Contracts/Context'; import ExecTaskError from './ExecTaskError'; export default class GenericTask implements Task { - protected context: Context; + protected _context: 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._context; + + 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._context = context; + this.parent = parentTask; } /** @@ -41,7 +80,6 @@ export default class GenericTask implements Task { } try { - this.status = TaskStatus.Running; await this.executor(prefix); this.status = TaskStatus.Success; @@ -55,15 +93,15 @@ export default class GenericTask implements Task { throw error; } - this.status = TaskStatus.Failed; - if (error instanceof ExecTaskError) { - this.errorOutput = error.stderr; + this.failedString = error.stderr; } + + this.status = TaskStatus.Failed; } } - protected shouldSkip(taskKey?: string) { + protected shouldSkip(taskKey: string) { return this.shouldSkipRegex(taskKey) || this.shouldSkipCache(taskKey); } @@ -97,10 +135,14 @@ 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) { + 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..d5703ce 100644 --- a/src/Library/Tasks/TaskOfTasks.ts +++ b/src/Library/Tasks/TaskOfTasks.ts @@ -1,17 +1,49 @@ import chalk from 'chalk'; -import { Context } from '../Contracts/Context'; -import { Task, TaskStatus } from '../Contracts/Task'; +import {Context} from '../Contracts/Context'; +import {Task, TaskStatus} from '../Contracts/Task'; export default class TaskOfTasks implements Task { - private context: Context; + private _context: 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._context; + + 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, ) {} @@ -19,28 +51,41 @@ export default class TaskOfTasks implements Task { /** * 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._context = context; + this.tasks.forEach((task) => task.useContext(context, this)); + } + + get key(): string { + if (!this.context.showNestedTaskKeys) { + return this.name; + } + + const prefix = this.parent ? `${this.parent.key}.` : ''; + + return `${prefix}${this.name}`; } /** - * Get the status - dynamically determined depending on sub-statuses. + * Returns the number of tasks recursively contained by this task of tasks */ - 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; + recursiveCount(): number { + let count = 0; + + for (const task of this.tasks) { + count += task instanceof TaskOfTasks ? task.recursiveCount() : 1; + } + + return count; } /** * Run the task of tasks, sequentially or concurrently as required. */ async run(prefix?: string) { + this.status = TaskStatus.Running; + const compoundPrefix = `${(prefix || '') + this.key}:`; if (this.concurrent) await this.concurrently(compoundPrefix); else await this.sequentially(compoundPrefix); @@ -50,14 +95,25 @@ export default class TaskOfTasks implements Task { * Run tasks concurrently. Resolves when all have ran. */ async concurrently(prefix: string) { - await Promise.all(this.tasks.map((task) => task.run(prefix))); + await Promise.all(this.tasks.map((task) => task.run(prefix))).then(() => { + this.status = 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); + try { + for await (const task of this.tasks) await task.run(prefix); + } catch (e) { + this.status = TaskStatus.Failed; + throw e; + } + + this.status = TaskStatus.Success; } render(depth: number = 0): string { @@ -75,4 +131,16 @@ 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); + } + } + + static isTaskOfTasks(subject: Task): subject is TaskOfTasks { + return 'recursiveCount' in subject; + } } diff --git a/src/Renderer.ts b/src/Renderer.ts index e093aa6..f1fdea0 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -1,32 +1,138 @@ -import { Task, TaskStatus } from './Library/Contracts/Task'; +import cliProgress, { MultiBar, SingleBar } from 'cli-progress'; +import chalk from 'chalk'; +import { Task, TaskOfTasks, TaskStatus } from './Library'; +import { Context } from './Library/Contracts/Context'; -export default async (task: Task, refreshRate = 100) => { - const spinner = ['◜', '◠', '◝', '◞', '◡', '◟']; +export default function render(context: Context, configRootTask: TaskOfTasks): () => void { + const runningTasks: Task[] = []; - 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 - }; + let bars: MultiBar | undefined; + let progressBar: SingleBar | undefined; + if (context.isTTY) { + bars = new cliProgress.MultiBar({}); + progressBar = bars.create( + 20, + 0, + undefined, + { + format: 'progress [{bar}] {percentage}% | {currentlyRunning} | {value}/{total}', + noTTYOutput: !context.isTTY, + notTTYSchedule: 100 + }, + ); + } - // 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; + const taskCount = configRootTask.recursiveCount(); + + progressBar?.start(taskCount, 0); + + const timeouts = new Map(); + + function log(arg: string) { + if (context.isTTY) { + bars?.log(`${arg}\n`); + } else { + console.log(arg); } - return; } - const interval = setInterval(render, refreshRate); - await task.run(); - clearInterval(interval); - render(); - if (task.status === TaskStatus.Failed) { - process.exitCode = 1; + function logTakingLongTime(task: Task, startTime: number, interval: number) { + if (task.status !== TaskStatus.Running) { + return; + } + + const seconds = Math.floor((Date.now() - startTime) / 1_000); + + /* eslint-disable max-len */ + log( + `${chalk.bgYellow(chalk.black(chalk.blackBright(' Warning ')))} ${chalk.yellow(`> Task ${chalk.white(task.key)} is taking a long time (${seconds}s)`)}`, + ); + /* eslint-enable */ + + timeouts.set(task, setTimeout(() => { + logTakingLongTime(task, startTime, interval); + }, interval)); + } + + function clearTaskTimeout(task: Task) { + if (timeouts.has(task)) { + clearTimeout(timeouts.get(task) as any); + timeouts.delete(task); + } } -}; + + configRootTask.on('statusChange', (task) => { + if (task.status === TaskStatus.Running && !TaskOfTasks.isTaskOfTasks(task)) { + runningTasks.push(task); + + const startTime = Date.now(); + const warningInterval = context.taskPool.timeout * 0.5; + + timeouts.set(task, setTimeout(() => logTakingLongTime(task, startTime, warningInterval), warningInterval)); + } + + if (task.status === TaskStatus.Success) { + clearTaskTimeout(task); + + let successText: string; + if (TaskOfTasks.isTaskOfTasks(task)) { + successText = chalk.green(`> Group ${chalk.white(task.key)} finished`); + } else { + successText = task.key; + } + + log(`${chalk.bgGreen(chalk.black(chalk.blackBright(' Finished ')))} ${successText}`); + } + + if (task.status === TaskStatus.Failed) { + clearTaskTimeout(task); + + const indent = ' '.repeat(11); + + const indentedFailedString = `${indent}${(task.failedString ?? '').split(/\r?\n/).join(`\n${indent}`)}`; + + /* eslint-disable max-len */ + log( + `${chalk.bgRed(chalk.black(chalk.blackBright(' Failed ')))} ${chalk.red(`${chalk.underline(task.key)}\n${indentedFailedString}`)}`, + ); + /* eslint-enable */ + } + + if ((task.status === TaskStatus.Success || task.status === TaskStatus.Failed) + && !TaskOfTasks.isTaskOfTasks(task) && runningTasks.includes(task) + ) { + runningTasks.splice(runningTasks.indexOf(task), 1); + + let tasks = ''; + let renderedTasks = 0; + + for (const runningTask of runningTasks) { + if (tasks.length > 0) { + tasks += ', '; + } + + tasks += runningTask.key; + + if (tasks.length > 32) { + tasks += `... +${runningTasks.length - renderedTasks}`; + break; + } + + renderedTasks += 1; + } + + const currentlyRunning = `${chalk.blue(`[${tasks}]`)}`; + + progressBar?.increment(); + progressBar?.update({ currentlyRunning }); + } + }); + + return () => { + for (const [, timeout] of timeouts.entries()) { + clearTimeout(timeout); + } + + setTimeout(() => bars?.stop(), 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" } } From c107446d9062cee59339d9106450e8e33355c6cd Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sun, 8 Oct 2023 00:59:46 -0400 Subject: [PATCH 2/8] consistency --- src/Binary.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Binary.ts b/src/Binary.ts index 0ddb379..b70e947 100644 --- a/src/Binary.ts +++ b/src/Binary.ts @@ -12,8 +12,8 @@ const binary = (new Command()).version(version) .option('-j, --num-workers ', 'set the maximum number of workers to use', `${Number.MAX_SAFE_INTEGER}`) .option('-r, --regex ', 'regular expression used to filter tasks') .option('-i, --invert', 'if true, regex will be used to reject tasks') - .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('-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); From ad7807c514ff2d1d209d13b77fb116c6b366ef6d Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sat, 21 Oct 2023 14:59:42 -0400 Subject: [PATCH 3/8] more work --- src/Binary.ts | 8 +- src/Library/Contracts/Context.ts | 1 + src/Library/Contracts/Task.ts | 2 + src/Library/Tasks/DummyTask.ts | 5 - src/Library/Tasks/GenericTask.ts | 22 +-- src/Library/Tasks/TaskOfTasks.ts | 79 ++++++---- src/Renderer.ts | 256 ++++++++++++++++++++----------- 7 files changed, 233 insertions(+), 140 deletions(-) diff --git a/src/Binary.ts b/src/Binary.ts index b70e947..3b01b97 100644 --- a/src/Binary.ts +++ b/src/Binary.ts @@ -1,15 +1,16 @@ 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 Cache from './Cache'; import { TaskOfTasks } from './Library'; -import render from './Renderer'; +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('-n, --no-cache', 'do not skip tasks, even if hash matches cache') @@ -28,6 +29,7 @@ const context: Context = { filterRegex: options.regex ? RegExp(options.regex) : undefined, invertRegex: options.invert, taskPool: new Pool({ limit: options.numWorkers, timeout: 120_000 }), + numWorkers: options.numWorkers, showNestedTaskKeys: true, isTTY: process.stdout.isTTY, }; @@ -43,7 +45,7 @@ configRootTask.useContext(context, null); // Run and Render the config root task. -const rootTaskCompletionCallback = render(context, configRootTask as TaskOfTasks); +const rootTaskCompletionCallback = Renderer.render(context, configRootTask as TaskOfTasks); configRootTask.run().finally(() => rootTaskCompletionCallback()); diff --git a/src/Library/Contracts/Context.ts b/src/Library/Contracts/Context.ts index b900be2..dd75911 100644 --- a/src/Library/Contracts/Context.ts +++ b/src/Library/Contracts/Context.ts @@ -9,6 +9,7 @@ export interface Context { 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 ad2c6b9..5d64eb9 100644 --- a/src/Library/Contracts/Task.ts +++ b/src/Library/Contracts/Task.ts @@ -45,6 +45,8 @@ export interface Task { */ run: TaskRunner; + willRun: () => boolean; + /** * The current status of this generic task. */ 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/GenericTask.ts b/src/Library/Tasks/GenericTask.ts index ce626d3..2626eac 100644 --- a/src/Library/Tasks/GenericTask.ts +++ b/src/Library/Tasks/GenericTask.ts @@ -6,7 +6,7 @@ import { Context } from '../Contracts/Context'; import ExecTaskError from './ExecTaskError'; export default class GenericTask implements Task { - protected _context: Context | undefined; + protected givenContext: Context | undefined; private taskStatus: TaskStatus = TaskStatus.Queued; @@ -19,7 +19,7 @@ export default class GenericTask implements Task { } protected get context(): Context { - const context = this._context; + const context = this.givenContext; if (!context) { throw new Error('.context called with no context set on task'); @@ -56,7 +56,7 @@ export default class GenericTask implements Task { return this.name; } - const prefix = this.parent ? `${this.parent.key}.` : ''; + const prefix = this.parent ? `${this.parent.key}:` : ''; return `${prefix}${this.name}`; } @@ -65,16 +65,19 @@ export default class GenericTask implements Task { * Register a context with the task (and sub-tasks). */ useContext(context: Context, parentTask: Task) { - this._context = context; + 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; } @@ -86,7 +89,7 @@ export default class GenericTask implements Task { // 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) { @@ -101,10 +104,6 @@ export default class GenericTask implements Task { } } - protected shouldSkip(taskKey: string) { - return this.shouldSkipRegex(taskKey) || this.shouldSkipCache(taskKey); - } - protected shouldSkipRegex(taskKey: string) { if (this.context.dryRun) return true; if (this.context.filterRegex === undefined) return this.context.invertRegex; @@ -136,6 +135,7 @@ export default class GenericTask implements Task { return ['⊙', chalk.magenta]; // Replaced with spinner :) })(this.status); 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}`); } diff --git a/src/Library/Tasks/TaskOfTasks.ts b/src/Library/Tasks/TaskOfTasks.ts index d5703ce..375fdc2 100644 --- a/src/Library/Tasks/TaskOfTasks.ts +++ b/src/Library/Tasks/TaskOfTasks.ts @@ -1,9 +1,9 @@ import chalk from 'chalk'; -import {Context} from '../Contracts/Context'; -import {Task, TaskStatus} from '../Contracts/Task'; +import { Context } from '../Contracts/Context'; +import { Task, TaskStatus } from '../Contracts/Task'; export default class TaskOfTasks implements Task { - private _context: Context | undefined; + private givenContext: Context | undefined; private taskStatus: TaskStatus = TaskStatus.Queued; @@ -16,7 +16,7 @@ export default class TaskOfTasks implements Task { } protected get context(): Context { - const context = this._context; + const context = this.givenContext; if (!context) { throw new Error('.context called with no context set on task'); @@ -53,8 +53,11 @@ export default class TaskOfTasks implements Task { */ useContext(context: Context, parentTask: Task | null) { this.parent = parentTask; - this._context = context; - this.tasks.forEach((task) => task.useContext(context, this)); + this.givenContext = context; + + for (const task of this.tasks) { + task.useContext(context, this); + } } get key(): string { @@ -62,41 +65,38 @@ export default class TaskOfTasks implements Task { return this.name; } - const prefix = this.parent ? `${this.parent.key}.` : ''; + const prefix = this.parent ? `${this.parent.key}:` : ''; return `${prefix}${this.name}`; } - /** - * Returns the number of tasks recursively contained by this task of tasks - */ - recursiveCount(): number { - let count = 0; - - for (const task of this.tasks) { - count += task instanceof TaskOfTasks ? task.recursiveCount() : 1; - } - - return count; - } - /** * Run the task of tasks, sequentially or concurrently as required. */ async run(prefix?: string) { this.status = TaskStatus.Running; - const compoundPrefix = `${(prefix || '') + this.key}:`; - if (this.concurrent) await this.concurrently(compoundPrefix); - else await this.sequentially(compoundPrefix); + 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) { + private async concurrently(prefix: string) { await Promise.all(this.tasks.map((task) => task.run(prefix))).then(() => { - this.status = TaskStatus.Success; + this.status = this.tasks.every((it) => it.status === TaskStatus.Skipped) + ? TaskStatus.Skipped + : TaskStatus.Success; }).catch(() => { this.status = TaskStatus.Failed; }); @@ -105,15 +105,19 @@ export default class TaskOfTasks implements Task { /** * Run tasks sequentially. Resolves when all have ran. */ - async sequentially(prefix: string) { + private async sequentially(prefix: string) { try { - for await (const task of this.tasks) await task.run(prefix); + for await (const task of this.tasks) { + await task.run(prefix); + } } catch (e) { this.status = TaskStatus.Failed; throw e; } - this.status = TaskStatus.Success; + this.status = this.tasks.every((it) => it.status === TaskStatus.Skipped) + ? TaskStatus.Skipped + : TaskStatus.Success; } render(depth: number = 0): string { @@ -140,7 +144,24 @@ export default class TaskOfTasks implements Task { } } + /** + * 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 'recursiveCount' in subject; + return 'recursivelyCountTasksToRun' in subject; } } diff --git a/src/Renderer.ts b/src/Renderer.ts index f1fdea0..6da704e 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -1,138 +1,210 @@ import cliProgress, { MultiBar, SingleBar } from 'cli-progress'; -import chalk from 'chalk'; +import c from 'chalk'; import { Task, TaskOfTasks, TaskStatus } from './Library'; import { Context } from './Library/Contracts/Context'; -export default function render(context: Context, configRootTask: TaskOfTasks): () => void { - const runningTasks: Task[] = []; - - let bars: MultiBar | undefined; - let progressBar: SingleBar | undefined; - if (context.isTTY) { - bars = new cliProgress.MultiBar({}); - progressBar = bars.create( - 20, - 0, - undefined, - { - format: 'progress [{bar}] {percentage}% | {currentlyRunning} | {value}/{total}', - noTTYOutput: !context.isTTY, - notTTYSchedule: 100 - }, - ); - } +import packageJson from '../package.json'; - const taskCount = configRootTask.recursiveCount(); +export default class Renderer { + static finishedTag(): string { + return c.bgGreen(c.blackBright(' Finished ')); + } - progressBar?.start(taskCount, 0); + static warningTag(): string { + return c.bgYellow(c.blackBright(' Warning ')); + } - const timeouts = new Map(); + static failedTag(): string { + return c.bgRed(c.blackBright(' Failed ')); + } - function log(arg: string) { - if (context.isTTY) { - bars?.log(`${arg}\n`); - } else { - console.log(arg); - } + static debugTag(): string { + return c.bgMagenta(c.blackBright(' Debug ')); } - function logTakingLongTime(task: Task, startTime: number, interval: number) { - if (task.status !== TaskStatus.Running) { - return; - } + static progressTag(): string { + return c.magenta(' Progress '); + } - const seconds = Math.floor((Date.now() - startTime) / 1_000); + static infoTag(): string { + return c.bgBlue(c.blackBright(' Info ')); + } - /* eslint-disable max-len */ - log( - `${chalk.bgYellow(chalk.black(chalk.blackBright(' Warning ')))} ${chalk.yellow(`> Task ${chalk.white(task.key)} is taking a long time (${seconds}s)`)}`, - ); - /* eslint-enable */ + static runningTasks: Task[] = []; - timeouts.set(task, setTimeout(() => { - logTakingLongTime(task, startTime, interval); - }, interval)); - } + static render(context: Context, configRootTask: TaskOfTasks): () => void { + const startTime = Date.now(); - function clearTaskTimeout(task: Task) { - if (timeouts.has(task)) { - clearTimeout(timeouts.get(task) as any); - timeouts.delete(task); - } - } + let doneTasks = 0; - configRootTask.on('statusChange', (task) => { - if (task.status === TaskStatus.Running && !TaskOfTasks.isTaskOfTasks(task)) { - runningTasks.push(task); + const cursors = ['|', '/', '-', '\\']; + let cursorIndex = 0; - const startTime = Date.now(); - const warningInterval = context.taskPool.timeout * 0.5; + Renderer.runningTasks.length = 0; - timeouts.set(task, setTimeout(() => logTakingLongTime(task, startTime, warningInterval), warningInterval)); + 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, + }, + ); } - if (task.status === TaskStatus.Success) { - clearTaskTimeout(task); + const tasksToRun = configRootTask.recursivelyCountTasksToRun(); + + progressBar?.start(tasksToRun, 0); + + const timeouts = new Map(); - let successText: string; - if (TaskOfTasks.isTaskOfTasks(task)) { - successText = chalk.green(`> Group ${chalk.white(task.key)} finished`); + function log(arg: string) { + if (context.isTTY) { + bars?.log(`${arg}\n`); } else { - successText = task.key; + process.stdout.write(`${arg}\n`); } - - log(`${chalk.bgGreen(chalk.black(chalk.blackBright(' Finished ')))} ${successText}`); } - if (task.status === TaskStatus.Failed) { - clearTaskTimeout(task); + function updateProgressBar() { + let tasks = ''; + let renderedTasks = 0; - const indent = ' '.repeat(11); + cursorIndex = (cursorIndex + 1) % cursors.length; - const indentedFailedString = `${indent}${(task.failedString ?? '').split(/\r?\n/).join(`\n${indent}`)}`; + if (Renderer.runningTasks.length === 0) { + tasks = ''; + } - /* eslint-disable max-len */ - log( - `${chalk.bgRed(chalk.black(chalk.blackBright(' Failed ')))} ${chalk.red(`${chalk.underline(task.key)}\n${indentedFailedString}`)}`, - ); - /* eslint-enable */ - } + for (let i = 0; i < Renderer.runningTasks.length; i += 1) { + const runningTask = Renderer.runningTasks[i]; - if ((task.status === TaskStatus.Success || task.status === TaskStatus.Failed) - && !TaskOfTasks.isTaskOfTasks(task) && runningTasks.includes(task) - ) { - runningTasks.splice(runningTasks.indexOf(task), 1); + const maxLen = Math.max(0, process.stdout.getWindowSize()[0] - 78); - let tasks = ''; - let renderedTasks = 0; + if (tasks.length > maxLen) { + tasks += `... +${Renderer.runningTasks.length - renderedTasks}`; + break; + } - for (const runningTask of runningTasks) { if (tasks.length > 0) { tasks += ', '; } - tasks += runningTask.key; - - if (tasks.length > 32) { - tasks += `... +${runningTasks.length - renderedTasks}`; + if (runningTask.key.length <= maxLen) { + tasks += runningTask.key; + } else { + tasks += `+${Renderer.runningTasks.length - renderedTasks}`; break; } renderedTasks += 1; } - const currentlyRunning = `${chalk.blue(`[${tasks}]`)}`; + const currentlyRunning = `${c.blue(`[${tasks}]`)}`; - progressBar?.increment(); - progressBar?.update({ currentlyRunning }); + progressBar?.update(doneTasks, { cursor: cursors[cursorIndex], currentlyRunning }); } - }); - return () => { - for (const [, timeout] of timeouts.entries()) { - clearTimeout(timeout); + 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(`> Task ${c.white(task.key)} is taking a long time (${seconds}s)`)}`); + + updateProgressBar(); + + timeouts.set(task, setTimeout(() => { + logTakingLongTime(task, taskStartTime, interval); + }, interval)); } - setTimeout(() => bars?.stop(), 100); - }; + 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)); + } + + if (task.status === TaskStatus.Success) { + clearTaskTimeout(task); + + let successText: string; + if (TaskOfTasks.isTaskOfTasks(task)) { + successText = c.green(`> Group ${c.white(task.key)} finished`); + } else { + successText = c.green(`> Task ${c.white(task.key)} finished`); + } + + doneTasks += 1; + + log(`${Renderer.finishedTag()} ${successText}`); + } + + if (task.status === TaskStatus.Failed) { + clearTaskTimeout(task); + + const indent = ' '.repeat(11); + + const indentedFailedString = `${indent}${(task.failedString ?? '').split(/\r?\n/).join(`\n${indent}`)}`; + + doneTasks += 1; + + log(`${Renderer.failedTag()} ${c.red(`${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 taskCountStr = c.white(`${doneTasks.toString()} tasks`); + const timeStr = c.white(`${seconds.toFixed(1)}s`); + + console.log(`${Renderer.infoTag()} ${c.blue(`> Ran ${taskCountStr} in ${timeStr}`)}`); + }, 100); + }; + } } From bda5c5cfe0897f472886d0bbcf30970c18c7fb48 Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sat, 21 Oct 2023 15:04:06 -0400 Subject: [PATCH 4/8] add pre-release ci script --- .github/workflows/pre-release.yml | 50 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 3 ++ 2 files changed, 53 insertions(+) create mode 100644 .github/workflows/pre-release.yml 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: From d86eeff2071427331de7652678e086e0c3742024 Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sat, 21 Oct 2023 15:00:25 -0400 Subject: [PATCH 5/8] bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12e4f33..2d803c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "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" }, diff --git a/package.json b/package.json index 4588626..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": { From a88a67a757fb3a97e4b842dc2deaf00ba0011597 Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sat, 21 Oct 2023 15:14:07 -0400 Subject: [PATCH 6/8] fix lib index.ts casing --- src/Library/{Index.ts => index.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Library/{Index.ts => index.ts} (100%) 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 From 202b72c7d3a092c005ca13029d958d24eb1e4134 Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sat, 21 Oct 2023 15:17:51 -0400 Subject: [PATCH 7/8] fix rollup --- rollup.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 68de081..760b8d7 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -19,7 +19,7 @@ export default [ ], }, { - input: 'src/Library/Index.ts', + input: 'src/Library/index.ts', output: { file: './dist/index.mjs', }, From 980f1c51603c9ed5582de0c3f51244b1f3784900 Mon Sep 17 00:00:00 2001 From: Benjamin Dupont <4503241+Benjozork@users.noreply.github.com> Date: Sun, 7 Jan 2024 19:06:01 -0500 Subject: [PATCH 8/8] further rendering improvements --- src/Library/Tasks/TaskOfTasks.ts | 2 +- src/Renderer.ts | 48 +++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/Library/Tasks/TaskOfTasks.ts b/src/Library/Tasks/TaskOfTasks.ts index 375fdc2..24984f8 100644 --- a/src/Library/Tasks/TaskOfTasks.ts +++ b/src/Library/Tasks/TaskOfTasks.ts @@ -45,7 +45,7 @@ export default class TaskOfTasks implements Task { constructor( private name: string, public tasks: Task[], - private concurrent = false, + public concurrent = false, ) {} /** diff --git a/src/Renderer.ts b/src/Renderer.ts index 6da704e..1a0418e 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -30,6 +30,10 @@ export default class Renderer { return c.bgBlue(c.blackBright(' Info ')); } + static emptyTag(): string { + return ' '.repeat(10); + } + static runningTasks: Task[] = []; static render(context: Context, configRootTask: TaskOfTasks): () => void { @@ -64,6 +68,8 @@ export default class Renderer { progressBar?.start(tasksToRun, 0); + let indent = ''; + const timeouts = new Map(); function log(arg: string) { @@ -87,7 +93,7 @@ export default class Renderer { for (let i = 0; i < Renderer.runningTasks.length; i += 1) { const runningTask = Renderer.runningTasks[i]; - const maxLen = Math.max(0, process.stdout.getWindowSize()[0] - 78); + const maxLen = Math.max(0, process.stdout.getWindowSize()[0] - 100); if (tasks.length > maxLen) { tasks += `... +${Renderer.runningTasks.length - renderedTasks}`; @@ -120,7 +126,9 @@ export default class Renderer { const seconds = Math.floor((Date.now() - taskStartTime) / 1_000); - log(`${Renderer.warningTag()} ${c.yellow(`> Task ${c.white(task.key)} is taking a long time (${seconds}s)`)}`); + log(`${Renderer.warningTag()} ${ + c.yellow(`> ${indent}Task ${c.white(task.key)} is taking a long time (${seconds}s)`) + }`); updateProgressBar(); @@ -152,14 +160,34 @@ export default class Renderer { timeouts.set(task, setTimeout(() => logTakingLongTime(task, taskStartTime, warningInterval), warningInterval)); } - if (task.status === TaskStatus.Success) { + 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(`> Group ${c.white(task.key)} finished`); + successText = c.green(`> ${indent}Group ${c.white(task.key)} finished`); } else { - successText = c.green(`> Task ${c.white(task.key)} finished`); + successText = c.green(`> ${indent}Task ${c.white(task.key)} finished`); } doneTasks += 1; @@ -170,13 +198,14 @@ export default class Renderer { if (task.status === TaskStatus.Failed) { clearTaskTimeout(task); - const indent = ' '.repeat(11); + const errorIndent = ' '.repeat(13); - const indentedFailedString = `${indent}${(task.failedString ?? '').split(/\r?\n/).join(`\n${indent}`)}`; + const indentedFailedString = `${errorIndent}${indent}${(task.failedString ?? '') + .split(/\r?\n/).join(`\n${errorIndent}${indent}`)}`; doneTasks += 1; - log(`${Renderer.failedTag()} ${c.red(`${c.underline(task.key)}\n${indentedFailedString}`)}`); + log(`${Renderer.failedTag()} ${c.red(`> ${indent}${c.underline(task.key)}\n${indentedFailedString}`)}`); } if ((task.status === TaskStatus.Success || task.status === TaskStatus.Failed) @@ -200,10 +229,9 @@ export default class Renderer { const seconds = (Date.now() - startTime) / 1_000; - const taskCountStr = c.white(`${doneTasks.toString()} tasks`); const timeStr = c.white(`${seconds.toFixed(1)}s`); - console.log(`${Renderer.infoTag()} ${c.blue(`> Ran ${taskCountStr} in ${timeStr}`)}`); + console.log(`${Renderer.infoTag()} ${c.blue(`> Ran ${taskCountString} in ${timeStr}`)}`); }, 100); }; }