From f79e7db90649e60d59e86bc61376db494c06ae97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Fri, 15 May 2026 03:09:00 +0300 Subject: [PATCH] fix(reporters): `summary` to intercept logger's streams even when they are not `process.std*` streams (#10340) --- packages/vitest/src/node/reporters/default.ts | 5 +- .../reporters/renderers/windowedRenderer.ts | 36 ++- packages/vitest/src/node/reporters/summary.ts | 9 + pnpm-lock.yaml | 18 ++ pnpm-workspace.yaml | 1 + .../fixtures/reporters/summary/first.test.ts | 21 ++ .../fixtures/reporters/summary/second.test.ts | 21 ++ test/e2e/package.json | 1 + test/e2e/test/reporters/summary.test.ts | 205 ++++++++++++++++++ 9 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 test/e2e/fixtures/reporters/summary/first.test.ts create mode 100644 test/e2e/fixtures/reporters/summary/second.test.ts create mode 100644 test/e2e/test/reporters/summary.test.ts diff --git a/packages/vitest/src/node/reporters/default.ts b/packages/vitest/src/node/reporters/default.ts index f5a64f59d879..659f016793fb 100644 --- a/packages/vitest/src/node/reporters/default.ts +++ b/packages/vitest/src/node/reporters/default.ts @@ -9,6 +9,9 @@ import { SummaryReporter } from './summary' export interface DefaultReporterOptions extends BaseOptions { summary?: boolean + + /** @internal */ + summaryOptions?: SummaryReporter['options'] } export class DefaultReporter extends BaseReporter { @@ -87,6 +90,6 @@ export class DefaultReporter extends BaseReporter { onInit(ctx: Vitest): void { super.onInit(ctx) - this.summary?.onInit(ctx, { verbose: this.verbose }) + this.summary?.onInit(ctx, { verbose: this.verbose, ...this.options.summaryOptions }) } } diff --git a/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts b/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts index ba039fa37214..59fb111f69cd 100644 --- a/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts +++ b/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts @@ -2,6 +2,10 @@ import type { Writable } from 'node:stream' import type { Vitest } from '../../core' import { stripVTControlCharacters } from 'node:util' +/** Minimum time between two renders, no matter how many scheduled renderes were called */ +const DEFAULT_RENDER_THRESHOLD_MS = 100 + +/** Interval between automatic renders. If no test state changes happened, this will increase just duration field */ const DEFAULT_RENDER_INTERVAL_MS = 1_000 const ESC = '\x1B[' @@ -10,9 +14,10 @@ const MOVE_CURSOR_ONE_ROW_UP = `${ESC}1A` const SYNC_START = `${ESC}?2026h` const SYNC_END = `${ESC}?2026l` -interface Options { +export interface Options { logger: Vitest['logger'] interval?: number + threshold?: number getWindow: () => string[] } @@ -36,10 +41,12 @@ export class WindowRenderer { constructor(options: Options) { this.options = { - interval: DEFAULT_RENDER_INTERVAL_MS, ...options, + threshold: options.threshold ?? DEFAULT_RENDER_THRESHOLD_MS, + interval: options.interval ?? DEFAULT_RENDER_INTERVAL_MS, } + // Capture the original write methods early, before intercepting these this.streams = { output: options.logger.outputStream.write.bind(options.logger.outputStream), error: options.logger.errorStream.write.bind(options.logger.errorStream), @@ -50,6 +57,14 @@ export class WindowRenderer { this.interceptStream(process.stderr, 'error'), ) + // Intercept calls to custom VitestOptions.stdout and stderr streams + if (options.logger.outputStream !== process.stdout) { + this.cleanups.push(this.interceptStream(options.logger.outputStream, 'output')) + } + if (options.logger.errorStream !== process.stderr) { + this.cleanups.push(this.interceptStream(options.logger.errorStream, 'error')) + } + // Write buffered content on unexpected exits, e.g. direct `process.exit()` calls this.options.logger.onTerminalCleanup(() => { this.flushBuffer() @@ -86,9 +101,14 @@ export class WindowRenderer { this.renderScheduled = true this.flushBuffer() - setTimeout(() => { + if (this.options.threshold) { + setTimeout(() => { + this.renderScheduled = false + }, this.options.threshold).unref() + } + else { this.renderScheduled = false - }, 100).unref() + } } } @@ -121,9 +141,12 @@ export class WindowRenderer { } private render(message?: string, type: StreamType = 'output') { + this.write(SYNC_START) + if (this.finished) { this.clearWindow() - return this.write(message || '', type) + this.write(message || '', type) + return this.write(SYNC_END) } const windowContent = this.options.getWindow() @@ -134,7 +157,6 @@ export class WindowRenderer { padding -= getRenderedRowCount([message], this.options.logger.getColumns()) } - this.write(SYNC_START) this.clearWindow() if (message) { @@ -165,7 +187,7 @@ export class WindowRenderer { this.windowHeight = 0 } - private interceptStream(stream: NodeJS.WriteStream, type: StreamType) { + private interceptStream(stream: NodeJS.WriteStream | Writable, type: StreamType) { const original = stream.write // @ts-expect-error -- not sure how 2 overloads should be typed diff --git a/packages/vitest/src/node/reporters/summary.ts b/packages/vitest/src/node/reporters/summary.ts index 9e519d087a02..1c9dcadc45f0 100644 --- a/packages/vitest/src/node/reporters/summary.ts +++ b/packages/vitest/src/node/reporters/summary.ts @@ -1,6 +1,7 @@ import type { Vitest } from '../core' import type { TestSpecification } from '../test-specification' import type { Reporter } from '../types/reporter' +import type { Options as WindowRendererOptions } from './renderers/windowedRenderer' import type { ReportedHookContext, TestCase, TestModule } from './reported-tasks' import c from 'tinyrainbow' import { F_POINTER, F_TREE_NODE_END, F_TREE_NODE_MIDDLE } from './renderers/figures' @@ -12,6 +13,12 @@ const FINISHED_TEST_CLEANUP_TIME_MS = 1_000 interface Options { verbose?: boolean + + /** @internal */ + interval?: WindowRendererOptions['interval'] + + /** @internal */ + threshold?: WindowRendererOptions['threshold'] } interface Counter { @@ -76,6 +83,8 @@ export class SummaryReporter implements Reporter { this.renderer = new WindowRenderer({ logger: ctx.logger, getWindow: () => this.createSummary(), + interval: this.options.interval, + threshold: this.options.threshold, }) this.ctx.onClose(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c22cf16860a..928275647122 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ catalogs: acorn-walk: specifier: ^8.3.5 version: 8.3.5 + ansivision: + specifier: ^0.2.7 + version: 0.2.7 birpc: specifier: ^4.0.0 version: 4.0.0 @@ -1430,6 +1433,9 @@ importers: '@vitest/utils': specifier: workspace:* version: link:../../packages/utils + ansivision: + specifier: 'catalog:' + version: 0.2.7 flatted: specifier: 'catalog:' version: 3.4.2 @@ -1713,6 +1719,9 @@ packages: '@adobe/css-tools@4.4.0': resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + '@ansi-tools/parser@1.0.15': + resolution: {integrity: sha512-Y+PIdkMVu8I27z344G65nni8c7TlPtVv/7Kw/ncS6lbpJKBRT00bTtyAlPL/aVjkCrLoYlUwsgvoGJ/iWhH52w==} + '@antfu/eslint-config@7.6.1': resolution: {integrity: sha512-MRiskHFHYPF0R3eWDUkPPiHUM3fWXwAviVv9O8iMH5hVJkgp60oJYBMzbImKdqSGMuuyOMY3GXxWbH60t9rK0g==} hasBin: true @@ -5800,6 +5809,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + ansivision@0.2.7: + resolution: {integrity: sha512-D2y8fZ2G+S0Ni/Uw2ki9bGpsBu0WkNRSAARJK/on+xb4AzEkNSM2oUNfiJXqjN2wkjHGklWzol8qYDWy0EMyYg==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -10560,6 +10572,8 @@ snapshots: '@adobe/css-tools@4.4.0': {} + '@ansi-tools/parser@1.0.15': {} + '@antfu/eslint-config@7.6.1(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.29)(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)(vitest@packages+vitest)': dependencies: '@antfu/install-pkg': 1.1.0 @@ -14329,6 +14343,10 @@ snapshots: ansis@4.2.0: {} + ansivision@0.2.7: + dependencies: + '@ansi-tools/parser': 1.0.15 + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e5b7925a2fab..557f3f7a69de 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -68,6 +68,7 @@ catalog: '@vitejs/plugin-vue': ^6.0.4 '@vueuse/core': ^14.2.1 acorn-walk: ^8.3.5 + ansivision: ^0.2.7 birpc: ^4.0.0 cac: ^6.7.14 chai: ^6.2.2 diff --git a/test/e2e/fixtures/reporters/summary/first.test.ts b/test/e2e/fixtures/reporters/summary/first.test.ts new file mode 100644 index 000000000000..3f3cf39e9038 --- /dev/null +++ b/test/e2e/fixtures/reporters/summary/first.test.ts @@ -0,0 +1,21 @@ +import { setTimeout } from 'node:timers/promises' +import { describe, test } from 'vitest' + +const TIMEOUT = 100; + +// Test queue time +await setTimeout(TIMEOUT); + +describe("suite", () => { + test("one",async () => { + await setTimeout(TIMEOUT); + }) + + test("two", async () => { + await setTimeout(TIMEOUT); + }) + + test("three", async () => { + await setTimeout(TIMEOUT); + }) +}) diff --git a/test/e2e/fixtures/reporters/summary/second.test.ts b/test/e2e/fixtures/reporters/summary/second.test.ts new file mode 100644 index 000000000000..3f3cf39e9038 --- /dev/null +++ b/test/e2e/fixtures/reporters/summary/second.test.ts @@ -0,0 +1,21 @@ +import { setTimeout } from 'node:timers/promises' +import { describe, test } from 'vitest' + +const TIMEOUT = 100; + +// Test queue time +await setTimeout(TIMEOUT); + +describe("suite", () => { + test("one",async () => { + await setTimeout(TIMEOUT); + }) + + test("two", async () => { + await setTimeout(TIMEOUT); + }) + + test("three", async () => { + await setTimeout(TIMEOUT); + }) +}) diff --git a/test/e2e/package.json b/test/e2e/package.json index c90211a4a1cb..e80c5d7db150 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -26,6 +26,7 @@ "@vitest/mocker": "workspace:*", "@vitest/runner": "workspace:*", "@vitest/utils": "workspace:*", + "ansivision": "catalog:", "flatted": "catalog:", "jest-image-snapshot": "^6.5.1", "obug": "^2.1.1", diff --git a/test/e2e/test/reporters/summary.test.ts b/test/e2e/test/reporters/summary.test.ts new file mode 100644 index 000000000000..d7f33bca4a36 --- /dev/null +++ b/test/e2e/test/reporters/summary.test.ts @@ -0,0 +1,205 @@ +import type { Renderer } from 'ansivision' +import { runVitest, StableTestFileOrderSorter } from '#test-utils' +import { renderString } from 'ansivision' +import { normalize } from 'pathe' +import { expect, test } from 'vitest' + +test('states of running tests are reported', async () => { + const { stdout } = await runVitest({ + root: 'fixtures/reporters/summary', + reporters: [['default', { summary: true, summaryOptions: { threshold: 0 }, isTTY: true }]], + config: false, + fileParallelism: false, + sequence: { sequencer: StableTestFileOrderSorter }, + }, undefined, { preserveAnsi: true, tty: true }) + + const frames = await renderString(stdout).then(trimFrames) + + expect(frames).toMatchInlineSnapshot(` + " + RUN v[...] /fixtures/reporters/summary + + + ❯ first.test.ts [queued] + + Test Files 0 passed (2) + Tests 0 passed (0) + Start at