diff --git a/.changeset/calm-clocks-hang.md b/.changeset/calm-clocks-hang.md new file mode 100644 index 00000000..3eb8485f --- /dev/null +++ b/.changeset/calm-clocks-hang.md @@ -0,0 +1,5 @@ +--- +'@ice/pkg': minor +--- + +feat: support plain text reporter and use seconds diff --git a/packages/pkg/src/commands/build.ts b/packages/pkg/src/commands/build.ts index f10660a1..0cce7c95 100644 --- a/packages/pkg/src/commands/build.ts +++ b/packages/pkg/src/commands/build.ts @@ -1,6 +1,6 @@ import fse from 'fs-extra'; import type { BuildTask, Context, OutputResult } from '../types.js'; -import { RunnerLinerTerminalReporter } from '../helpers/runnerReporter.js'; +import { createRunnerReporter } from '../helpers/runnerReporter.js'; import { getTaskRunners } from '../helpers/getTaskRunners.js'; import { RunnerScheduler } from '../helpers/runnerScheduler.js'; @@ -31,7 +31,7 @@ export default async function build(context: Context) { const tasks = getTaskRunners(buildTasks, context); try { - const terminal = new RunnerLinerTerminalReporter(); + const terminal = createRunnerReporter(); const taskGroup = new RunnerScheduler(tasks, terminal); const results = taskGroup.run(); diff --git a/packages/pkg/src/commands/start.ts b/packages/pkg/src/commands/start.ts index 4bd23824..e24024de 100644 --- a/packages/pkg/src/commands/start.ts +++ b/packages/pkg/src/commands/start.ts @@ -1,7 +1,7 @@ import { consola } from 'consola'; import { createBatchChangeHandler, createWatcher } from '../helpers/watcher.js'; import type { OutputResult, Context, WatchChangedFile, BuildTask } from '../types.js'; -import { RunnerLinerTerminalReporter } from '../helpers/runnerReporter.js'; +import { createRunnerReporter } from '../helpers/runnerReporter.js'; import { getTaskRunners } from '../helpers/getTaskRunners.js'; import { RunnerScheduler } from '../helpers/runnerScheduler.js'; import { createServer } from '../server/createServer.js'; @@ -45,7 +45,7 @@ export default async function start(context: Context) { const tasks = getTaskRunners(buildTasks, context, watcher); - const terminal = new RunnerLinerTerminalReporter(); + const terminal = createRunnerReporter(); const taskGroup = new RunnerScheduler(tasks, terminal); const outputResults: OutputResult[] = await taskGroup.run(); diff --git a/packages/pkg/src/helpers/runnerReporter.ts b/packages/pkg/src/helpers/runnerReporter.ts index ee602e63..4828ab7a 100644 --- a/packages/pkg/src/helpers/runnerReporter.ts +++ b/packages/pkg/src/helpers/runnerReporter.ts @@ -20,6 +20,10 @@ export interface RunnerReporter { onStop?: (options: RunnerReporterStopOptions) => void; } +export interface RunnerReporterOptions { + stream?: NodeJS.WriteStream; +} + export class RunnerLinerTerminalReporter implements RunnerReporter { private stream: NodeJS.WriteStream; private timer: any = null; @@ -28,11 +32,7 @@ export class RunnerLinerTerminalReporter implements RunnerReporter { private isRendering = false; private runningRunners: Runner[] = []; - constructor( - options: { - stream?: NodeJS.WriteStream; - } = {}, - ) { + constructor(options: RunnerReporterOptions = {}) { this.stream = options.stream ?? process.stderr; } @@ -43,25 +43,25 @@ export class RunnerLinerTerminalReporter implements RunnerReporter { onRunnerEnd(runner: Runner) { this.runningRunners.splice(this.runningRunners.indexOf(runner), 1); const { status, context } = runner; - if (status === RunnerStatus.Finished) { - // TODO: for error - const items: string[] = [ - runner.isError ? chalk.red(figures.cross) : chalk.green(figures.tick), - chalk.cyan(runner.name), - formatTimeCost(runner.getMetric(TASK_MARK).cost), - ]; - - if (context.mode === 'development') { - items.push(chalk.red('dev')); - } - - // remove loading - this.clear(); - // eslint-disable-next-line no-console - console.log(` ${items.join(' ')}`); - // resume loading - this.render(); + if (status !== RunnerStatus.Finished && status !== RunnerStatus.Error) { + return; } + + const items: string[] = [ + runner.isError ? chalk.red(figures.cross) : chalk.green(figures.tick), + chalk.cyan(runner.name), + formatTimeCost(runner.getMetric(TASK_MARK).cost), + ]; + + if (context.mode === 'development') { + items.push(chalk.red('dev')); + } + + // remove loading + this.clear(); + this.stream.write(` ${items.join(' ')}\n`); + // resume loading + this.render(); } onStart() { @@ -77,9 +77,8 @@ export class RunnerLinerTerminalReporter implements RunnerReporter { // 停下来之后进行最后一次更新 this.clear(); this.isRendering = false; - // eslint-disable-next-line no-console - console.log( - ` ${chalk.blue(figures.info)} Done in ${formatTimeCost(options.cost)} for ${options.runners.length} tasks`, + this.stream.write( + ` ${chalk.blue(figures.info)} Done in ${formatTimeCost(options.cost)} for ${options.runners.length} tasks\n`, ); } @@ -120,3 +119,48 @@ export class RunnerLinerTerminalReporter implements RunnerReporter { return ` ${chalk.dim(this.spinner.frames[this.frame])} ${chalk.dim('Running...')}`; } } + +export class RunnerPlainTextReporter implements RunnerReporter { + private stream: NodeJS.WriteStream; + + constructor(options: RunnerReporterOptions = {}) { + this.stream = options.stream ?? process.stderr; + } + + onRunnerEnd(runner: Runner) { + const { status, context } = runner; + if (status !== RunnerStatus.Finished && status !== RunnerStatus.Error) { + return; + } + + const items: string[] = [ + runner.isError ? figures.cross : figures.tick, + runner.name, + formatTimeCost(runner.getMetric(TASK_MARK).cost, false), + ]; + + if (context.mode === 'development') { + items.push('dev'); + } + + this.stream.write(` ${items.join(' ')}\n`); + } + + onStop(options: RunnerReporterStopOptions) { + this.stream.write( + ` ${figures.info} Done in ${formatTimeCost(options.cost, false)} for ${options.runners.length} tasks\n`, + ); + } +} + +export function isTerminalEnvironment(stream: NodeJS.WriteStream = process.stderr) { + return Boolean(stream.isTTY) && !process.env.CI; +} + +export function createRunnerReporter(options: RunnerReporterOptions = {}): RunnerReporter { + const stream = options.stream ?? process.stderr; + if (isTerminalEnvironment(stream)) { + return new RunnerLinerTerminalReporter({ stream }); + } + return new RunnerPlainTextReporter({ stream }); +} diff --git a/packages/pkg/src/helpers/runnerScheduler.ts b/packages/pkg/src/helpers/runnerScheduler.ts index 68eec314..ab1af027 100644 --- a/packages/pkg/src/helpers/runnerScheduler.ts +++ b/packages/pkg/src/helpers/runnerScheduler.ts @@ -37,20 +37,48 @@ export class RunnerScheduler { async run(changedFiles?: WatchChangedFile[]): Promise { const startTime = Date.now(); this.reporter.onStart?.(); - const parallelPromise = Promise.all(this.parallelRunners.map((runner) => runner.run(changedFiles))); - const concurrentPromise = concurrentPromiseAll( - this.concurrentRunners.map((runner) => () => runner.run(changedFiles)), - 1, - ); - - const [parallelResults, concurrentResults] = await Promise.all([parallelPromise, concurrentPromise]); - const stopTime = Date.now(); - this.reporter.onStop?.({ - startTime, - stopTime, - cost: stopTime - startTime, - runners: this.runners, - }); - return [...parallelResults, ...concurrentResults]; + try { + const parallelPromise = Promise.allSettled(this.parallelRunners.map((runner) => runner.run(changedFiles))); + const concurrentPromise = concurrentPromiseAll( + this.concurrentRunners.map((runner) => () => runner.run(changedFiles)), + 1, + ).then( + (value) => ({ status: 'fulfilled' as const, value }), + (reason) => ({ status: 'rejected' as const, reason }), + ); + + const [parallelSettled, concurrentSettled] = await Promise.all([parallelPromise, concurrentPromise]); + + let firstError: unknown; + const parallelResults: T[] = []; + for (const item of parallelSettled) { + if (item.status === 'fulfilled') { + parallelResults.push(item.value); + } else if (firstError === undefined) { + firstError = item.reason; + } + } + + let concurrentResults: T[] = []; + if (concurrentSettled.status === 'fulfilled') { + concurrentResults = concurrentSettled.value; + } else if (firstError === undefined) { + firstError = concurrentSettled.reason; + } + + if (firstError !== undefined) { + throw firstError; + } + + return [...parallelResults, ...concurrentResults]; + } finally { + const stopTime = Date.now(); + this.reporter.onStop?.({ + startTime, + stopTime, + cost: stopTime - startTime, + runners: this.runners, + }); + } } } diff --git a/packages/pkg/src/utils.ts b/packages/pkg/src/utils.ts index 41aad459..e8d2c923 100644 --- a/packages/pkg/src/utils.ts +++ b/packages/pkg/src/utils.ts @@ -235,30 +235,31 @@ export function debouncePromise( }; } -// Build time 0-500ms Green -// 500-3000 Yellow -// 3000- Red -export const timeFrom = (start: number, subtract = 0): string => { - const time: number | string = performance.now() - start - subtract; - const timeString = `${time.toFixed(2)} ms`.padEnd(5, ' '); - if (time < 500) { +// Build time <1s Green +// <5s Yellow +// >=5s Red +function colorizeTimeCost(time: number, timeString: string) { + if (time < 1000) { return picocolors.green(timeString); - } else if (time < 3000) { + } else if (time < 5000) { return picocolors.yellow(timeString); } else { return picocolors.red(timeString); } +} + +export const timeFrom = (start: number, subtract = 0): string => { + const time = performance.now() - start - subtract; + const timeString = `${(time / 1000).toFixed(2)}s`; + return colorizeTimeCost(time, timeString); }; -export function formatTimeCost(time: number) { - const timeString = `${time.toFixed(2)} ms`.padEnd(5, ' '); - if (time < 500) { - return picocolors.green(timeString); - } else if (time < 3000) { - return picocolors.yellow(timeString); - } else { - return picocolors.red(timeString); +export function formatTimeCost(time: number, colored = true) { + const timeString = `${(time / 1000).toFixed(2)}s`; + if (!colored) { + return timeString; } + return colorizeTimeCost(time, timeString); } export const unique = (arr: T[]): T[] => { diff --git a/packages/pkg/tests/helpers/runnerScheduler.test.ts b/packages/pkg/tests/helpers/runnerScheduler.test.ts index 4bdf6e8e..c64cde88 100644 --- a/packages/pkg/tests/helpers/runnerScheduler.test.ts +++ b/packages/pkg/tests/helpers/runnerScheduler.test.ts @@ -18,6 +18,12 @@ class MockRunner extends Runner { } doRun(files?: WatchChangedFile[]): Promise { + if (typeof this._value === 'function') { + return this._value(files); + } + if (this._value instanceof Error) { + return Promise.reject(this._value); + } return Promise.resolve(this._value); } } @@ -47,3 +53,36 @@ it('should initialize with correct distribution of runners', async () => { expect(reporter.onStop).toHaveBeenCalledTimes(1); expect(reporter.onStart).toHaveBeenCalledTimes(1); }); + +it('should call reporter cleanup when runner fails', async () => { + const failedRunner = new MockRunner(mockContext, false, new Error('boom')); + const reporter = new MockReporter(); + + const scheduler = new RunnerScheduler([failedRunner], reporter); + + await expect(scheduler.run()).rejects.toThrow('boom'); + expect(reporter.onRunnerStart).toHaveBeenCalledTimes(1); + expect(reporter.onRunnerEnd).toHaveBeenCalledTimes(1); + expect(reporter.onStop).toHaveBeenCalledTimes(1); + expect(reporter.onStart).toHaveBeenCalledTimes(1); +}); + +it('should call onStop after all runners settled when one fails early', async () => { + const failedRunner = new MockRunner(mockContext, true, new Error('boom')); + const slowRunner = new MockRunner( + mockContext, + true, + () => new Promise((resolve) => setTimeout(() => resolve(1), 20)), + ); + const reporter = new MockReporter(); + + const scheduler = new RunnerScheduler([failedRunner, slowRunner], reporter); + + await expect(scheduler.run()).rejects.toThrow('boom'); + expect(reporter.onRunnerEnd).toHaveBeenCalledTimes(2); + expect(reporter.onStop).toHaveBeenCalledTimes(1); + + const stopOrder = reporter.onStop.mock.invocationCallOrder[0]; + const endOrders = reporter.onRunnerEnd.mock.invocationCallOrder; + expect(stopOrder).toBeGreaterThan(Math.max(...endOrders)); +}); diff --git a/packages/pkg/tests/utils.test.ts b/packages/pkg/tests/utils.test.ts index d67b562d..dbb3de98 100644 --- a/packages/pkg/tests/utils.test.ts +++ b/packages/pkg/tests/utils.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, vi, Mock } from 'vitest'; -import { concurrentPromiseAll, delay } from '../src/utils'; +import { performance } from 'node:perf_hooks'; +import picocolors from 'picocolors'; +import { afterEach, describe, it, expect, vi, Mock } from 'vitest'; +import { concurrentPromiseAll, delay, formatTimeCost, timeFrom } from '../src/utils'; const MOCK_TASK_TIME = 20; @@ -24,6 +26,16 @@ function taskStatus(tasks: Mock[]) { return tasks.map((task) => task.mock.calls.length); } +function mockColor(name: 'green' | 'yellow' | 'red') { + return vi + .spyOn(picocolors, name) + .mockImplementation((input: string | number | null | undefined) => `${name}(${String(input)})`); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('concurrentPromiseAll', () => { it('should execute all tasks and return the expected result', async () => { const values = [1, 2, 3]; @@ -51,7 +63,8 @@ describe('concurrentPromiseAll', () => { await concurrentPromiseAll(tasks); expect(true).toBeFalsy(); // 应该永远不会到达这里 } catch (error) { - expect(error.message).toBe('Task failed'); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Task failed'); } }); @@ -60,3 +73,36 @@ describe('concurrentPromiseAll', () => { expect(result).toEqual([]); }); }); + +describe('time formatting', () => { + it('should format plain text time cost in seconds without colors', () => { + expect(formatTimeCost(120, false)).toBe('0.12s'); + expect(formatTimeCost(4567, false)).toBe('4.57s'); + }); + + it('should colorize formatTimeCost based on second thresholds', () => { + mockColor('green'); + mockColor('yellow'); + mockColor('red'); + + expect(formatTimeCost(120)).toBe('green(0.12s)'); + expect(formatTimeCost(4200)).toBe('yellow(4.20s)'); + expect(formatTimeCost(5000)).toBe('red(5.00s)'); + }); + + it('should format timeFrom in seconds using the same thresholds', () => { + mockColor('green'); + mockColor('yellow'); + mockColor('red'); + + const now = vi.spyOn(performance, 'now'); + now.mockReturnValue(1120); + expect(timeFrom(1000)).toBe('green(0.12s)'); + + now.mockReturnValue(5200); + expect(timeFrom(1000)).toBe('yellow(4.20s)'); + + now.mockReturnValue(6200); + expect(timeFrom(1000)).toBe('red(5.20s)'); + }); +});