diff --git a/examples/error.ts b/examples/error.ts index c7942a0..71d2e21 100644 --- a/examples/error.ts +++ b/examples/error.ts @@ -20,4 +20,4 @@ await sleep(SLEEP_TIME) ms.goto('two') await sleep(SLEEP_TIME) -ms.stop(new Error('An error occurred')) +ms.stop('failed') diff --git a/examples/parallel.ts b/examples/parallel.ts new file mode 100644 index 0000000..60f31ad --- /dev/null +++ b/examples/parallel.ts @@ -0,0 +1,59 @@ +import {ParallelMultiStageOutput} from '../src/multi-stage-output.js' + +const SLEEP_TIME = Number.parseInt(process.env.SLEEP ?? '1000', 10) ?? 100 + +async function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +const ms = new ParallelMultiStageOutput<{message: string; staticValue: string; dynamicValue: string}>({ + jsonEnabled: false, + stageSpecificBlock: [ + { + get: (data) => data?.message, + stage: 'one', + type: 'message', + }, + { + get: (data) => data?.staticValue, + label: 'Static', + stage: 'two', + type: 'static-key-value', + }, + { + get: (data) => data?.dynamicValue, + label: 'Dynamic', + stage: 'one', + type: 'dynamic-key-value', + }, + ], + stages: ['one', 'two', 'three'], + title: 'Example', +}) + +ms.startStage('one', {message: 'This is a message', staticValue: 'This is a static key:value pair'}) +await sleep(SLEEP_TIME) + +ms.startStage('two', {dynamicValue: 'This is a dynamic key:value pair'}) +await sleep(SLEEP_TIME) + +ms.stopStage('one') +await sleep(SLEEP_TIME) + +ms.pauseStage('two') +await sleep(SLEEP_TIME) + +ms.resumeStage('two') +await sleep(SLEEP_TIME) + +ms.startStage('three') +await sleep(SLEEP_TIME) + +ms.stopStage('two') +await sleep(SLEEP_TIME) + +ms.stopStage('three') + +ms.stop() diff --git a/src/components/stages.tsx b/src/components/stages.tsx index a6af139..2a44f52 100644 --- a/src/components/stages.tsx +++ b/src/components/stages.tsx @@ -333,7 +333,7 @@ function StageEntries({ {status !== 'pending' && status !== 'skipped' && hasStageTime && ( - + )} @@ -496,8 +496,8 @@ export function determineCompactionLevel( let cLevel = 0 const levels = [ - // 1: only show one stage at a time, with stage specific info nested under the stage - (remainingHeight: number) => remainingHeight - stagesHeight + 1, + // 1: only current stages, with stage specific info nested under the stage + (remainingHeight: number) => remainingHeight - stagesHeight + Math.max(stageTracker.current.length, 1), // 2: hide the elapsed time (remainingHeight: number) => remainingHeight - 1, // 3: hide the title (subtract 1 for title and 1 for paddingBottom) @@ -522,7 +522,10 @@ export function determineCompactionLevel( // It's possible that the collapsed stage might extend beyond the terminal width. // If so, we need to bump the compaction level up to 7 so that the stage specific info is hidden - if (cLevel === 6 && stageTracker.current && calculateWidthOfCompactStage(stageTracker.current) >= columns) { + if ( + cLevel === 6 && + stageTracker.current.map((c) => calculateWidthOfCompactStage(c)).reduce((acc, width) => acc + width, 0) >= columns + ) { cLevel = 7 } @@ -678,7 +681,7 @@ export function Stages({ )} - stageTracker.current ?? 'unknown'}> + stageTracker.current[0] ?? 'unknown'}> > { } } -export class MultiStageOutput> implements Disposable { - private readonly ciInstance: CIMultiStageOutput | undefined - private data?: Partial - private readonly design: RequiredDesign - private readonly hasElapsedTime?: boolean - private readonly hasStageTime?: boolean - private readonly inkInstance: Instance | undefined - private readonly postStagesBlock?: InfoBlock - private readonly preStagesBlock?: InfoBlock - private readonly stages: readonly string[] | string[] - private readonly stageSpecificBlock?: StageInfoBlock - private readonly stageTracker: StageTracker - private stopped = false - private readonly timerUnit?: 'ms' | 's' - private readonly title?: string - - public constructor({ - data, - design, - jsonEnabled = false, - postStagesBlock, - preStagesBlock, - showElapsedTime, - showStageTime, - stageSpecificBlock, - stages, - timerUnit, - title, - }: MultiStageOutputOptions) { +class MultiStageOutputBase> implements Disposable { + protected readonly ciInstance: CIMultiStageOutput | undefined + protected data?: Partial + protected readonly design: RequiredDesign + protected readonly hasElapsedTime?: boolean + protected readonly hasStageTime?: boolean + protected readonly inkInstance: Instance | undefined + protected readonly postStagesBlock?: InfoBlock + protected readonly preStagesBlock?: InfoBlock + protected readonly stages: readonly string[] | string[] + protected readonly stageSpecificBlock?: StageInfoBlock + protected readonly stageTracker: StageTracker + protected stopped = false + protected readonly timerUnit?: 'ms' | 's' + protected readonly title?: string + + public constructor( + { + data, + design, + jsonEnabled = false, + postStagesBlock, + preStagesBlock, + showElapsedTime, + showStageTime, + stageSpecificBlock, + stages, + timerUnit, + title, + }: MultiStageOutputOptions, + allowParallelTasks?: boolean, + ) { this.data = data this.design = constructDesignParams(design) this.stages = stages @@ -279,7 +282,7 @@ export class MultiStageOutput> implements Disp this.hasElapsedTime = showElapsedTime ?? true this.hasStageTime = showStageTime ?? true this.timerUnit = timerUnit ?? 'ms' - this.stageTracker = new StageTracker(stages) + this.stageTracker = new StageTracker(stages, {allowParallelTasks}) this.stageSpecificBlock = stageSpecificBlock if (jsonEnabled) return @@ -310,6 +313,101 @@ export class MultiStageOutput> implements Disp this.stop('failed') } + protected formatKeyValuePairs(infoBlock: InfoBlock | StageInfoBlock | undefined): FormattedKeyValue[] { + return ( + infoBlock?.map((info) => { + const formattedData = info.get ? info.get(this.data as T) : undefined + return { + color: info.color, + isBold: info.bold, + neverCollapse: info.neverCollapse, + type: info.type, + value: formattedData, + ...(info.type === 'message' ? {} : {label: info.label}), + ...('stage' in info ? {stage: info.stage} : {}), + } + }) ?? [] + ) + } + + /** shared method to populate everything needed for Stages cmp */ + protected generateStagesInput(opts?: {compactionLevel?: number}): StagesProps { + const {compactionLevel} = opts ?? {} + return { + compactionLevel, + design: this.design, + hasElapsedTime: this.hasElapsedTime, + hasStageTime: this.hasStageTime, + postStagesBlock: this.formatKeyValuePairs(this.postStagesBlock), + preStagesBlock: this.formatKeyValuePairs(this.preStagesBlock), + stageSpecificBlock: this.formatKeyValuePairs(this.stageSpecificBlock), + stageTracker: this.stageTracker, + timerUnit: this.timerUnit, + title: this.title, + } + } + + protected rerender(): void { + if (isInCi) { + this.ciInstance?.update(this.stageTracker, this.data) + } else { + this.inkInstance?.rerender() + } + } + + /** + * Stop multi-stage output from running. + * + * The stage currently running will be changed to the provided `finalStatus`. + * + * @param finalStatus - The status to set the current stage to. + * @returns void + */ + public stop(finalStatus: StageStatus = 'completed'): void { + if (this.stopped) return + this.stopped = true + + this.stageTracker.stop(this.stageTracker.current[0] ?? this.stages[0], finalStatus) + + if (isInCi) { + this.ciInstance?.stop(this.stageTracker) + return + } + + // The underlying components expect an Error, although they don't currently use anything on the error - they check if it exists. + // Instead of refactoring the components to take a boolean, we pass in a placeholder Error, + // which, gives us the flexibility in the future to pass in an actual Error if we want + const error = finalStatus === 'failed' ? new Error('Error') : undefined + + const stagesInput = {...this.generateStagesInput({compactionLevel: 0}), ...(error ? {error} : {})} + + this.inkInstance?.rerender() + this.inkInstance?.unmount() + } + + public [Symbol.dispose](): void { + this.inkInstance?.unmount() + } + + /** + * Updates the data of the component. + * + * @param data - The partial data object to update the component's data with. + * @returns void + */ + public updateData(data: Partial): void { + if (this.stopped) return + this.data = {...this.data, ...data} as T + + this.rerender() + } +} + +export class MultiStageOutput> extends MultiStageOutputBase { + public constructor(options: MultiStageOutputOptions) { + super(options) + } + /** * Go to a stage, marking any stages in between the current stage and the provided stage as completed. * @@ -328,7 +426,7 @@ export class MultiStageOutput> implements Disp if (!this.stages.includes(stage)) return // prevent going to a previous stage - if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return + if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current[0] ?? this.stages[0])) return this.update(stage, 'completed', data) } @@ -342,7 +440,7 @@ export class MultiStageOutput> implements Disp public next(data?: Partial): void { if (this.stopped) return - const nextStageIndex = this.stages.indexOf(this.stageTracker.current ?? this.stages[0]) + 1 + const nextStageIndex = this.stages.indexOf(this.stageTracker.current[0] ?? this.stages[0]) + 1 if (nextStageIndex < this.stages.length) { this.update(this.stages[nextStageIndex], 'completed', data) } @@ -366,106 +464,49 @@ export class MultiStageOutput> implements Disp if (!this.stages.includes(stage)) return // prevent going to a previous stage - if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return + if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current[0] ?? this.stages[0])) return this.update(stage, 'skipped', data) } - /** - * Stop multi-stage output from running. - * - * The stage currently running will be changed to the provided `finalStatus`. - * - * @param finalStatus - The status to set the current stage to. - * @returns void - */ - public stop(finalStatus: StageStatus = 'completed'): void { - if (this.stopped) return - this.stopped = true - - this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], { - finalStatus, - }) - - if (isInCi) { - this.ciInstance?.stop(this.stageTracker) - return - } - - // The underlying components expect an Error, although they don't currently use anything on the error - they check if it exists. - // Instead of refactoring the components to take a boolean, we pass in a placeholder Error, - // which, gives us the flexibility in the future to pass in an actual Error if we want - const error = finalStatus === 'failed' ? new Error('Error') : undefined + private update(stage: string, bypassStatus: StageStatus, data?: Partial): void { + this.data = {...this.data, ...data} as Partial - const stagesInput = {...this.generateStagesInput({compactionLevel: 0}), ...(error ? {error} : {})} + this.stageTracker.refresh(stage, {bypassStatus}) - this.inkInstance?.rerender() - this.inkInstance?.unmount() + this.rerender() } +} - public [Symbol.dispose](): void { - this.inkInstance?.unmount() +export class ParallelMultiStageOutput> extends MultiStageOutputBase { + public constructor(options: MultiStageOutputOptions) { + super(options, true) } - /** - * Updates the data of the component. - * - * @param data - The partial data object to update the component's data with. - * @returns void - */ - public updateData(data: Partial): void { - if (this.stopped) return - this.data = {...this.data, ...data} as T - - this.rerender() + public pauseStage(stage: string, data?: Partial): void { + this.update(stage, 'paused', data) } - private formatKeyValuePairs(infoBlock: InfoBlock | StageInfoBlock | undefined): FormattedKeyValue[] { - return ( - infoBlock?.map((info) => { - const formattedData = info.get ? info.get(this.data as T) : undefined - return { - color: info.color, - isBold: info.bold, - neverCollapse: info.neverCollapse, - type: info.type, - value: formattedData, - ...(info.type === 'message' ? {} : {label: info.label}), - ...('stage' in info ? {stage: info.stage} : {}), - } - }) ?? [] - ) + public resumeStage(stage: string, data?: Partial): void { + this.update(stage, 'current', data) } - /** shared method to populate everything needed for Stages cmp */ - private generateStagesInput(opts?: {compactionLevel?: number}): StagesProps { - const {compactionLevel} = opts ?? {} - return { - compactionLevel, - design: this.design, - hasElapsedTime: this.hasElapsedTime, - hasStageTime: this.hasStageTime, - postStagesBlock: this.formatKeyValuePairs(this.postStagesBlock), - preStagesBlock: this.formatKeyValuePairs(this.preStagesBlock), - stageSpecificBlock: this.formatKeyValuePairs(this.stageSpecificBlock), - stageTracker: this.stageTracker, - timerUnit: this.timerUnit, - title: this.title, - } + public startStage(stage: string, data?: Partial): void { + this.update(stage, 'current', data) } - private rerender(): void { - if (isInCi) { - this.ciInstance?.update(this.stageTracker, this.data) - } else { - this.inkInstance?.rerender() - } + public stopStage(stage: string, data?: Partial): void { + this.update(stage, 'completed', data) } - private update(stage: string, bypassStatus: StageStatus, data?: Partial): void { - this.data = {...this.data, ...data} as Partial + private update(stage: string, status: StageStatus, data?: Partial): void { + if (this.stopped) return + if (!this.stages.includes(stage)) return + if (this.stageTracker.get(stage) === 'completed') return - this.stageTracker.refresh(stage, {bypassStatus}) + this.data = {...this.data, ...data} as T + + this.stageTracker.update(stage, status) this.rerender() } diff --git a/src/stage-tracker.ts b/src/stage-tracker.ts index 129574b..5d7f7f0 100644 --- a/src/stage-tracker.ts +++ b/src/stage-tracker.ts @@ -12,12 +12,17 @@ export type StageStatus = | 'warning' export class StageTracker { - public current: string | undefined + public current: string[] = [] + private allowParallelTasks: boolean private map = new Map() private markers = new Map>() - public constructor(private stages: readonly string[] | string[]) { + public constructor( + private stages: readonly string[] | string[], + opts?: {allowParallelTasks?: boolean}, + ) { this.map = new Map(stages.map((stage) => [stage, 'pending'])) + this.allowParallelTasks = opts?.allowParallelTasks ?? false } public get size(): number { @@ -32,15 +37,6 @@ export class StageTracker { return this.map.get(stage) } - public getCurrent(): {stage: string; status: StageStatus} | undefined { - if (this.current) { - return { - stage: this.current, - status: this.map.get(this.current) as StageStatus, - } - } - } - public indexOf(stage: string): number { return this.stages.indexOf(stage) } @@ -54,8 +50,7 @@ export class StageTracker { // .stop() was called with a finalStatus if (nextStage === stage && opts?.finalStatus) { - this.set(stage, opts.finalStatus) - this.stopMarker(stage) + this.stopStage(stage, opts.finalStatus) continue } @@ -78,8 +73,7 @@ export class StageTracker { // any stage before the current stage should be marked as completed (if it hasn't been marked as skipped or failed yet) if (stages.indexOf(nextStage) > stages.indexOf(stage)) { - this.set(stage, 'completed') - this.stopMarker(stage) + this.stopStage(stage, 'completed') continue } @@ -90,17 +84,43 @@ export class StageTracker { public set(stage: string, status: StageStatus): void { if (status === 'current') { - this.current = stage + if (!this.current.includes(stage)) { + this.current.push(stage) + } + } else { + this.current = this.current.filter((s) => s !== stage) } this.map.set(stage, status) } + public stop(currentStage: string, finalStatus: StageStatus): void { + if (this.allowParallelTasks) { + for (const [stage, status] of this.entries()) { + if (status === 'current') { + this.stopStage(stage, finalStatus) + } + } + } else { + this.refresh(currentStage, {finalStatus}) + } + } + + public update(stage: string, status: StageStatus): void { + if (status === 'completed' || status === 'failed' || status === 'aborted') { + this.stopStage(stage, status) + } else { + this.set(stage, status) + } + } + public values(): IterableIterator { return this.map.values() } - private stopMarker(stage: string): void { + private stopStage(stage: string, status: StageStatus): void { + this.set(stage, status) + const marker = this.markers.get(stage) if (marker && !marker.stopped) { marker.stop() diff --git a/test/stage-tracker.test.ts b/test/stage-tracker.test.ts index 5f49954..1a3ff3f 100644 --- a/test/stage-tracker.test.ts +++ b/test/stage-tracker.test.ts @@ -13,7 +13,7 @@ describe('StageTracker', () => { it('should keep track of the current stage', () => { const tracker = new StageTracker(['one', 'two', 'three']) tracker.refresh('two') - expect(tracker.current).to.equal('two') + expect(tracker.current).to.deep.equal(['two']) }) it("should set the current stage to error when there's an error", () => {