From e60b2f49eeb172f23fe2329b3bb5ced79ca48aa5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 4 May 2026 14:27:36 +0900 Subject: [PATCH] feat: support merge reports for non-sharded multi-environment runs (take 2) (#10031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Codex Co-authored-by: Ari Perkkiö --- .github/workflows/ci.yml | 59 +++ docs/guide/cli.md | 2 +- docs/guide/improving-performance.md | 15 +- docs/guide/reporters.md | 23 +- packages/browser/src/client/orchestrator.ts | 29 +- packages/runner/src/collect.ts | 9 +- packages/runner/src/suite.ts | 12 + packages/runner/src/types/runner.ts | 1 + packages/runner/src/utils/collect.ts | 19 +- packages/ui/client/components/FileDetails.vue | 3 + .../client/components/explorer/Explorer.vue | 1 + .../components/explorer/ExplorerItem.vue | 3 + .../components/views/ViewTestReport.vue | 3 + .../ui/client/composables/explorer/types.ts | 1 + .../ui/client/composables/explorer/utils.ts | 2 + packages/utils/src/helpers.ts | 5 + packages/vitest/src/node/ast-collect.ts | 48 +- .../vitest/src/node/config/resolveConfig.ts | 10 + .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/node/core.ts | 2 +- packages/vitest/src/node/project.ts | 5 + packages/vitest/src/node/reporters/base.ts | 8 +- packages/vitest/src/node/reporters/blob.ts | 11 +- packages/vitest/src/node/reporters/summary.ts | 8 +- packages/vitest/src/node/state.ts | 8 +- packages/vitest/src/node/test-run.ts | 7 +- .../vitest/src/node/test-specification.ts | 12 +- packages/vitest/src/node/types/config.ts | 1 + packages/vitest/src/runtime/config.ts | 1 + packages/vitest/src/typecheck/collect.ts | 24 +- packages/vitest/src/types/global.ts | 1 + test/e2e/test/reported-tasks.test.ts | 24 +- .../reporters/__snapshots__/html.test.ts.snap | 2 +- test/e2e/test/reporters/merge-reports.test.ts | 477 +++++++++++++++++- test/e2e/test/static-collect.test.ts | 208 ++++---- test/e2e/test/test-specifications.test.ts | 20 +- test/e2e/vitest.config.ts | 7 +- test/test-utils/index.ts | 22 +- test/unit/vite.config.ts | 11 +- 39 files changed, 902 insertions(+), 203 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 010cff2d6..b293ac715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,8 @@ jobs: - name: Test run: pnpm run test:ci + env: + VITEST_CI_BLOB_LABEL: ${{ matrix.os }}-node-${{ matrix.node_version }} - name: Test Examples run: pnpm run test:examples @@ -130,6 +132,17 @@ jobs: path: test/ui/test-results/ retention-days: 30 + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: vitest-blob-${{ matrix.os }}-node-${{ matrix.node_version }} + path: | + README.md + test/unit/.vitest-reports + test/e2e/.vitest-reports + retention-days: 1 + include-hidden-files: true + test-cached: needs: changed name: 'Cache&Test: node-${{ matrix.node_version }}, ${{ matrix.os }}' @@ -251,3 +264,49 @@ jobs: name: playwright-report-rolldown path: rolldown/test/ui/test-results/ retention-days: 30 + + merge-reports: + needs: test + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + name: Merge Reports + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-and-cache + + - name: Install + run: pnpm i + + - name: Build + run: pnpm run build + + - uses: actions/download-artifact@v4 + with: + pattern: vitest-blob-* + merge-multiple: true + + - name: Merge reports + continue-on-error: true + run: pnpm --filter=./test/unit --filter=./test/e2e --no-bail --sequential test --merge-reports --reporter=html + + - name: Merge reports html + id: merge-html + run: | + mkdir -p html-all + cp -rf test/unit/html html-all/unit + cp -rf test/e2e/html html-all/e2e + echo "short_sha=${GITHUB_SHA:0:7}" >> $GITHUB_OUTPUT + + - uses: actions/upload-artifact@v7 + id: upload-report + with: + name: vitest-ci-report-${{ steps.merge-html.outputs.short_sha }} + path: html-all + retention-days: 7 + + - name: Link report viewer + run: | + echo "::notice title=Vitest HTML report::View HTML report: https://viewer.vitest.dev/?url=${{ steps.upload-report.outputs.artifact-url }}" + echo "[View HTML report](https://viewer.vitest.dev/?url=${{ steps.upload-report.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 014731736..6bcad4cf1 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -225,7 +225,7 @@ You cannot use this option with `--watch` enabled (enabled in dev by default). ::: ::: tip -If `--reporter=blob` is used without an output file, the default path will include the current shard config to avoid collisions with other Vitest processes. +If `--reporter=blob` is used without an output file, the default path will include the current shard config and blob label from `VITEST_BLOB_LABEL` or the blob reporter `label` option to avoid collisions with other Vitest processes. ::: ### merge-reports diff --git a/docs/guide/improving-performance.md b/docs/guide/improving-performance.md index baca0c6cd..2541eed93 100644 --- a/docs/guide/improving-performance.md +++ b/docs/guide/improving-performance.md @@ -132,6 +132,12 @@ Collect the results stored in `.vitest-reports` directory from each machine and vitest run --merge-reports ``` +When running the same shards across multiple environments, set the `VITEST_BLOB_LABEL` environment variable so merged reports can display them separately: + +```sh +VITEST_BLOB_LABEL=linux vitest run --reporter=blob --shard=1/3 +``` + ::: details GitHub Actions example This setup is also used at https://github.com/vitest-tests/test-sharding. @@ -144,9 +150,10 @@ on: - main jobs: tests: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, macos-latest] shardIndex: [1, 2, 3, 4] shardTotal: [4] steps: @@ -163,12 +170,14 @@ jobs: - name: Run tests run: pnpm run test --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + env: + VITEST_BLOB_LABEL: ${{ matrix.os }} - name: Upload blob report to GitHub Actions Artifacts if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: - name: blob-report-${{ matrix.shardIndex }} + name: blob-report-${{ matrix.os }}-${{ matrix.shardIndex }} path: .vitest-reports/* include-hidden-files: true retention-days: 1 @@ -177,7 +186,7 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: - name: blob-attachments-${{ matrix.shardIndex }} + name: blob-attachments-${{ matrix.os }}-${{ matrix.shardIndex }} path: .vitest/** include-hidden-files: true retention-days: 1 diff --git a/docs/guide/reporters.md b/docs/guide/reporters.md index 4b800ec4b..8aafe8dc7 100644 --- a/docs/guide/reporters.md +++ b/docs/guide/reporters.md @@ -748,13 +748,32 @@ By default, stores all results in `.vitest-reports` folder, but can be overridde npx vitest --reporter=blob --outputFile=reports/blob-1.json ``` -We recommend using this reporter if you are running Vitest on different machines with the [`--shard`](/guide/cli#shard) flag. -All blob reports can be merged into any report by using `--merge-reports` command at the end of your CI pipeline: +We recommend using this reporter if you are running Vitest on different machines with the [`--shard`](/guide/cli#shard) flag or across multiple environments (e.g., linux/macos/windows). All blob reports can be merged into any report by using `--merge-reports` command at the end of your CI pipeline: ```bash npx vitest --merge-reports=reports --reporter=json --reporter=default ``` +When running the same tests across multiple environments, use the `VITEST_BLOB_LABEL` environment variable to distinguish each environment's blob. Vitest reads labels at merge time and displays results separately: + +```bash +VITEST_BLOB_LABEL=linux vitest run --reporter=blob +``` + +You can also provide the label via the blob reporter option. This has higher priority than `VITEST_BLOB_LABEL`. + +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + reporters: [ + ['blob', { label: 'linux' }], + ], + }, +}) +``` + Blob reporter output doesn't include file-based [attachments](/api/advanced/artifacts.html#testattachment). Make sure to merge [`attachmentsDir`](/config/attachmentsdir) separately alongside blob reports on CI when using this feature. diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 5fb35d1e4..df9b8e68b 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -464,7 +464,34 @@ async function getContainer(config: SerializedConfig): Promise { function generateFileId(file: string) { const config = getConfig() const path = relative(config.root, file) - return generateHash(`${path}${config.name || ''}`) + return generateFileHash( + path, + config.name, + { + typecheck: config.pool === 'typescript', + __vitest_label__: config.mergeReportsLabel, + }, + ) +} + +// TODO: copied from packages/runner/src/utils/collect.ts +interface HashMeta { + typecheck?: boolean + __vitest_label__?: string +} + +function generateFileHash( + file: string, + projectName: string | undefined, + meta?: HashMeta, +): string { + const seed = [ + file, + projectName || '', + meta?.typecheck ? '__typecheck__' : '', + meta?.__vitest_label__ || '', + ].join('\0') + return generateHash(seed) } function generateHash(str: string): string { diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index b422a3079..7d89da6a2 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -47,7 +47,14 @@ export async function collectTests( const fileTags: string[] = typeof spec === 'string' ? [] : (spec.fileTags || []) - const file = createFileTask(filepath, config.root, config.name, runner.pool, runner.viteEnvironment) + const file = createFileTask( + filepath, + config.root, + config.name, + runner.pool, + runner.viteEnvironment, + { __vitest_label__: config.mergeReportsLabel }, + ) file.tags = fileTags file.shuffle = config.sequence.shuffle diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index ded1f6a60..404069b06 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -347,6 +347,18 @@ function createSuiteCollector( ...options, } const timeout = options.timeout ?? runner.config.testTimeout + // TODO: should this be `parentTask.meta`? + // currently we don't inherit + // file.meta -> task.meta + // file.meta -> suite.meta (see initSuite) + // but we do inherit + // suite.meta -> task.meta + // suite.meta -> suite.meta + // and also + // file.tags -> task.tags + // file.tags -> suite.tags + // suite.tags -> suite.tags + // suite.tags -> task.tags const parentMeta = currentSuite?.meta const tagMeta = tagsOptions.meta const testMeta = Object.create(null) diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 8eba4cb0c..bb82d60e2 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -46,6 +46,7 @@ export interface VitestRunnerConfig { tags: TestTagDefinition[] tagsFilter: string[] | undefined strictTags: boolean + mergeReportsLabel: string | undefined } /** diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index 4c741ac2f..0a7274887 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -193,23 +193,29 @@ export function calculateSuiteHash(parent: Suite): void { }) } +interface HashMeta { + typecheck?: boolean + __vitest_label__?: string +} + export function createFileTask( filepath: string, root: string, projectName: string | undefined, pool?: string, viteEnvironment?: string, + meta?: HashMeta, ): File { const path = relative(root, filepath) const file: File = { - id: generateFileHash(path, projectName), + id: generateFileHash(path, projectName, meta), name: path, fullName: path, type: 'suite', mode: 'queued', filepath, tasks: [], - meta: Object.create(null), + meta: Object.assign(Object.create(null), meta), projectName, file: undefined!, pool, @@ -228,8 +234,15 @@ export function createFileTask( export function generateFileHash( file: string, projectName: string | undefined, + meta?: HashMeta, ): string { - return /* @__PURE__ */ generateHash(`${file}${projectName || ''}`) + const seed = [ + file, + projectName || '', + meta?.typecheck ? '__typecheck__' : '', + meta?.__vitest_label__ || '', + ].join('\0') + return generateHash(seed) } export function findTestFileStackTrace(testFilePath: string, error: string): ParsedStack | undefined { diff --git a/packages/ui/client/components/FileDetails.vue b/packages/ui/client/components/FileDetails.vue index 0c9e65a06..7b016cd54 100644 --- a/packages/ui/client/components/FileDetails.vue +++ b/packages/ui/client/components/FileDetails.vue @@ -60,6 +60,8 @@ const isTypecheck = computed(() => { return !!current.value?.meta?.typecheck }) +const label = computed(() => current.value?.meta?.__vitest_label__) + function open() { const filePath = current.value?.filepath if (filePath) { @@ -206,6 +208,7 @@ const tags = computed(() => {
+ {{ label }} {
+ {{ label }} {{ projectName }} diff --git a/packages/ui/client/components/views/ViewTestReport.vue b/packages/ui/client/components/views/ViewTestReport.vue index 19e841bf6..d8bb17e1a 100644 --- a/packages/ui/client/components/views/ViewTestReport.vue +++ b/packages/ui/client/components/views/ViewTestReport.vue @@ -22,9 +22,12 @@ const failed = computed(() => { return mapLeveledTaskStacks(isDark.value, [props.test])[0] as RunnerTestCase | null }) +// filter out internal TaskMeta +// packages/vitest/src/types/global.ts const kWellKnownMeta = new Set([ 'benchmark', 'typecheck', + '__vitest_label__', ]) const meta = computed(() => { return Object.entries(props.test.meta).filter(([name]) => { diff --git a/packages/ui/client/composables/explorer/types.ts b/packages/ui/client/composables/explorer/types.ts index a51056f38..16cc9305d 100644 --- a/packages/ui/client/composables/explorer/types.ts +++ b/packages/ui/client/composables/explorer/types.ts @@ -63,6 +63,7 @@ export interface FileTreeNode extends ParentTreeNode { type: 'file' filepath: string typecheck: boolean | undefined + label?: string projectName?: string projectNameColor: string collectDuration?: number diff --git a/packages/ui/client/composables/explorer/utils.ts b/packages/ui/client/composables/explorer/utils.ts index 294ecb5a0..2ff576033 100644 --- a/packages/ui/client/composables/explorer/utils.ts +++ b/packages/ui/client/composables/explorer/utils.ts @@ -84,6 +84,7 @@ export function createOrUpdateFileNode( if (fileNode) { fileNode.typecheck = !!file.meta && 'typecheck' in file.meta + fileNode.label = file.meta?.__vitest_label__ fileNode.state = file.result?.state fileNode.mode = file.mode fileNode.duration = typeof file.result?.duration === 'number' ? Math.round(file.result.duration) : undefined @@ -106,6 +107,7 @@ export function createOrUpdateFileNode( children: new Set(), tasks: [], typecheck: !!file.meta && 'typecheck' in file.meta, + label: file.meta?.__vitest_label__, indent: 0, duration: typeof file.result?.duration === 'number' ? Math.round(file.result.duration) : undefined, slow: false, diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts index 7d2d74cca..22f1c0f7f 100644 --- a/packages/utils/src/helpers.ts +++ b/packages/utils/src/helpers.ts @@ -429,3 +429,8 @@ export function deepMerge( export function unique(array: T[]): T[] { return Array.from(new Set(array)) } + +export function sanitizeFilePath(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\x00-\x2C\x2E\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-') +} diff --git a/packages/vitest/src/node/ast-collect.ts b/packages/vitest/src/node/ast-collect.ts index 209955127..724d27fae 100644 --- a/packages/vitest/src/node/ast-collect.ts +++ b/packages/vitest/src/node/ast-collect.ts @@ -1,13 +1,12 @@ import type { File, Suite, Task, Test } from '@vitest/runner' -import type { SerializedConfig } from '../runtime/config' import type { TestError } from '../types/general' import type { TestProject } from './project' import { promises as fs } from 'node:fs' import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping' import { calculateSuiteHash, + createFileTask as createFileTaskOriginal, createTaskName, - generateHash, validateTags, } from '@vitest/runner/utils' import { unique } from '@vitest/utils/helpers' @@ -279,21 +278,20 @@ function astParseFile(filepath: string, code: string) { } export function createFailedFileTask(project: TestProject, filepath: string, error: Error): File { - const testFilepath = relative(project.config.root, filepath) - const file: ParsedFile = { + const config = project.serializedConfig + const baseFile = createFileTaskOriginal( filepath, - type: 'suite', - id: /* @__PURE__ */ generateHash(`${testFilepath}${project.config.name || ''}`), - name: testFilepath, - fullName: testFilepath, + config.root, + config.name, + config.pool, + undefined, + { typecheck: config.pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, + ) + const file: ParsedFile = { + ...baseFile, mode: 'run', - tasks: [], start: 0, end: 0, - projectName: project.name, - meta: {}, - pool: project.browser ? 'browser' : project.config.pool, - file: null!, result: { state: 'fail', errors: serializeError(project, error), @@ -326,28 +324,28 @@ function serializeError(ctx: TestProject, error: any): TestError[] { } function createFileTask( + project: TestProject, testFilepath: string, code: string, requestMap: any, - config: SerializedConfig, filepath: string, fileTags: string[] | undefined, ) { const { definitions, ast } = astParseFile(testFilepath, code) - const file: ParsedFile = { + const config = project.serializedConfig + const baseFile = createFileTaskOriginal( filepath, - type: 'suite', - id: /* @__PURE__ */ generateHash(`${testFilepath}${config.name || ''}`), - name: testFilepath, - fullName: testFilepath, + config.root, + config.name, + config.pool, + undefined, + { typecheck: config.pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, + ) + const file: ParsedFile = { + ...baseFile, mode: 'run', - tasks: [], start: ast.start, end: ast.end, - projectName: config.name, - meta: {}, - pool: 'browser', - file: null!, tags: fileTags || [], } file.file = file @@ -494,10 +492,10 @@ export async function astCollectTests( ) } return createFileTask( + project, testFilepath, request.code, request.map, - project.serializedConfig, filepath, request.fileTags, ) diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index f26dbfff9..60b507c11 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -760,6 +760,16 @@ export function resolveConfig( } } + resolved.mergeReportsLabel = process.env.VITEST_BLOB_LABEL + for (const reporter of resolved.reporters) { + if (Array.isArray(reporter) && reporter[0] === 'blob') { + const options = reporter[1] as any + if (options && typeof options.label === 'string') { + resolved.mergeReportsLabel = options.label + } + } + } + if (resolved.changed) { resolved.passWithNoTests ??= true } diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index b2b3e8f16..d81073d74 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -150,6 +150,7 @@ export function serializeConfig(project: TestProject): SerializedConfig { tags: config.tags || [], tagsFilter: config.tagsFilter, strictTags: config.strictTags ?? true, + mergeReportsLabel: config.mergeReportsLabel, slowTestThreshold: config.slowTestThreshold ?? globalConfig.slowTestThreshold diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 7a44919a1..521649d37 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -622,7 +622,7 @@ export class Vitest { const specifications: TestSpecification[] = [] for (const file of files) { const project = this.getProjectByName(file.projectName || '') - const specification = project.createSpecification(file.filepath, undefined, file.pool) + const specification = project.createSpecification(file.filepath, undefined, file.pool, file.meta) specifications.push(specification) } diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index b6f687db6..55d528532 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -1,3 +1,4 @@ +import type { TaskMeta } from '@vitest/runner/types' import type { GlobOptions } from 'tinyglobby' import type { DevEnvironment, ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite' import type { ModuleRunner } from 'vite/module-runner' @@ -152,12 +153,15 @@ export class TestProject { locationsOrOptions?: number[] | TestSpecificationOptions | undefined, /** @internal */ pool?: string, + /** @internal */ + metaOverride?: TaskMeta, ): TestSpecification { return new TestSpecification( this, moduleId, pool || getFilePoolName(this), locationsOrOptions, + metaOverride, ) } @@ -551,6 +555,7 @@ export class TestProject { server.config, ) this._config.api.token = this.vitest.config.api.token + this._config.mergeReportsLabel = this.vitest.config.mergeReportsLabel this._setHash() for (const _providedKey in this.config.provide) { const providedKey = _providedKey as keyof ProvidedContext diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index edd1fd63b..132a83a9d 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -335,6 +335,11 @@ export abstract class BaseReporter implements Reporter { title += ` ${c.bgBlue(c.bold(' TS '))}` } + const label = this.ctx.state.blobs && entity.task.file.meta.__vitest_label__ + if (label) { + title += ` ${c.bgCyan(c.bold(` ${label} `))}` + } + return title } @@ -936,8 +941,9 @@ export abstract class BaseReporter implements Reporter { name += c.dim(` [ ${this.relative(filepath)} ]`) } + const label = this.ctx.state.blobs && task.file?.meta?.__vitest_label__ this.ctx.logger.error( - `${c.bgRed(c.bold(' FAIL '))} ${formatProjectName(project)}${name}`, + `${c.bgRed(c.bold(' FAIL '))} ${formatProjectName(project)}${label ? `${c.bgCyan(c.bold(` ${label} `))} ` : ''}${name}`, ) } diff --git a/packages/vitest/src/node/reporters/blob.ts b/packages/vitest/src/node/reporters/blob.ts index 32dd65948..036fbdbf5 100644 --- a/packages/vitest/src/node/reporters/blob.ts +++ b/packages/vitest/src/node/reporters/blob.ts @@ -7,12 +7,14 @@ import type { Reporter } from '../types/reporter' import type { TestModule } from './reported-tasks' import { existsSync } from 'node:fs' import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { sanitizeFilePath } from '@vitest/utils/helpers' import { parse, stringify } from 'flatted' import { dirname, resolve } from 'pathe' import { getOutputFile } from '../../utils/config-helpers' export interface BlobOptions { outputFile?: string + label?: string } export class BlobReporter implements Reporter { @@ -50,9 +52,12 @@ export class BlobReporter implements Reporter { = this.options.outputFile ?? getOutputFile(this.ctx.config, 'blob') if (!outputFile) { const shard = this.ctx.config.shard - outputFile = shard - ? `.vitest-reports/blob-${shard.index}-${shard.count}.json` - : '.vitest-reports/blob.json' + const filename = [ + 'blob', + this.ctx.config.mergeReportsLabel, + shard && `${shard.index}-${shard.count}`, + ].filter(Boolean).join('-') + outputFile = `.vitest-reports/${sanitizeFilePath(filename)}.json` } const environmentModules: MergeReportEnvironmentModules = {} diff --git a/packages/vitest/src/node/reporters/summary.ts b/packages/vitest/src/node/reporters/summary.ts index 26a236cd1..11ac84ee6 100644 --- a/packages/vitest/src/node/reporters/summary.ts +++ b/packages/vitest/src/node/reporters/summary.ts @@ -38,7 +38,7 @@ interface RunningModule extends Pick { projectColor: TestModule['project']['color'] hook?: Omit tests: Map - typecheck: boolean + meta: TestModule['task']['meta'] } /** @@ -288,11 +288,13 @@ export class SummaryReporter implements Reporter { const summary = [''] for (const testFile of Array.from(this.runningModules.values()).sort(sortRunningModules)) { - const typecheck = testFile.typecheck ? `${c.bgBlue(c.bold(' TS '))} ` : '' + const typecheck = testFile.meta.typecheck ? `${c.bgBlue(c.bold(' TS '))} ` : '' + const label = this.ctx.state.blobs && testFile.meta.__vitest_label__ ? `${c.bgCyan(c.bold(` ${testFile.meta.__vitest_label__} `))} ` : '' summary.push( c.bold(c.yellow(` ${F_POINTER} `)) + formatProjectName({ name: testFile.projectName, color: testFile.projectColor }) + typecheck + + label + testFile.filename + c.dim(!testFile.completed && !testFile.total ? ' [queued]' @@ -398,6 +400,6 @@ function initializeStats(module: TestModule): RunningModule { projectName: module.project.name, projectColor: module.project.color, tests: new Map(), - typecheck: !!module.task.meta.typecheck, + meta: module.task.meta, } } diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts index 89159941c..76ea5b71c 100644 --- a/packages/vitest/src/node/state.ts +++ b/packages/vitest/src/node/state.ts @@ -145,17 +145,17 @@ export class StateManager { collectFiles(project: TestProject, files: File[] = []): void { files.forEach((file) => { const existing = this.filesMap.get(file.filepath) || [] - const otherFiles = existing.filter( - i => i.projectName !== file.projectName || i.meta.typecheck !== file.meta.typecheck, - ) const currentFile = existing.find( - i => i.projectName === file.projectName, + i => i.projectName === file.projectName + && i.meta.typecheck === file.meta.typecheck + && i.meta.__vitest_label__ === file.meta.__vitest_label__, ) // keep logs for the previous file because it should always be initiated before the collections phase // which means that all logs are collected during the collection and not inside tests if (currentFile) { file.logs = currentFile.logs } + const otherFiles = existing.filter(i => i !== currentFile) otherFiles.push(file) this.filesMap.set(file.filepath, otherFiles) this.updateId(file, project) diff --git a/packages/vitest/src/node/test-run.ts b/packages/vitest/src/node/test-run.ts index b130052dc..649362758 100644 --- a/packages/vitest/src/node/test-run.ts +++ b/packages/vitest/src/node/test-run.ts @@ -17,7 +17,7 @@ import assert from 'node:assert' import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' -import { isPrimitive } from '@vitest/utils/helpers' +import { isPrimitive, sanitizeFilePath } from '@vitest/utils/helpers' import { serializeValue } from '@vitest/utils/serialize' import { parseErrorStacktrace } from '@vitest/utils/source-map' import { extractSourcemapFromFile } from '@vitest/utils/source-map/node' @@ -311,8 +311,3 @@ export class TestRun { } } } - -function sanitizeFilePath(s: string): string { - // eslint-disable-next-line no-control-regex - return s.replace(/[\x00-\x2C\x2E\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-') -} diff --git a/packages/vitest/src/node/test-specification.ts b/packages/vitest/src/node/test-specification.ts index 74ef5d6a1..133251c34 100644 --- a/packages/vitest/src/node/test-specification.ts +++ b/packages/vitest/src/node/test-specification.ts @@ -1,3 +1,4 @@ +import type { TaskMeta } from '@vitest/runner/types' import type { SerializedTestSpecification } from '../runtime/types/utils' import type { TestProject } from './project' import type { TestModule } from './reporters/reported-tasks' @@ -55,17 +56,14 @@ export class TestSpecification { moduleId: string, pool: Pool, testLinesOrOptions?: number[] | TestSpecificationOptions | undefined, + // merge-reports uses the original `file.meta` from the test run + metaOverride?: TaskMeta, ) { const projectName = project.config.name - const hashName = pool !== 'typescript' - ? projectName - : projectName - // https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/typecheck/collect.ts#L58 - ? `${projectName}:__typecheck__` - : '__typecheck__' this.taskId = generateFileHash( relative(project.config.root, moduleId), - hashName, + projectName, + metaOverride ?? { typecheck: pool === 'typescript', __vitest_label__: project.config.mergeReportsLabel }, ) this.project = project this.moduleId = moduleId diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index d3b87c776..2d7d99458 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1226,6 +1226,7 @@ export interface ResolvedConfig vmMemoryLimit?: UserConfig['vmMemoryLimit'] dumpDir?: string tagsFilter?: string[] + mergeReportsLabel?: string experimental: Omit['experimental'], 'importDurations'> & { importDurations: { diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 5df14bd94..325ee6533 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -154,6 +154,7 @@ export interface SerializedConfig { tags: TestTagDefinition[] tagsFilter: string[] | undefined strictTags: boolean + mergeReportsLabel: string | undefined slowTestThreshold: number | undefined isAgent: boolean } diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index c6c5e571d..213512002 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -3,13 +3,12 @@ import type { Rollup } from 'vite' import type { TestProject } from '../node/project' import { calculateSuiteHash, + createFileTask, createTaskName, - generateHash, interpretTaskModes, someTasksAreOnly, } from '@vitest/runner/utils' import { ancestor as walkAst } from 'acorn-walk' -import { relative } from 'pathe' import { parseAstAsync } from 'vite' interface ParsedFile extends File { @@ -53,22 +52,19 @@ export async function collectTests( return null } const ast = await parseAstAsync(request.code) - const testFilepath = relative(ctx.config.root, filepath) const projectName = ctx.name - const typecheckSubprojectName = projectName ? `${projectName}:__typecheck__` : '__typecheck__' const file: ParsedFile = { - filepath, - type: 'suite', - id: generateHash(`${testFilepath}${typecheckSubprojectName}`), - name: testFilepath, - fullName: testFilepath, - mode: 'run', - tasks: [], + ...createFileTask( + filepath, + ctx.config.root, + projectName, + undefined, + undefined, + { typecheck: true }, + ), start: ast.start, end: ast.end, - projectName, - meta: { typecheck: true }, - file: null!, + mode: 'run', } file.file = file const definitions: LocalCallDefinition[] = [] diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 3885c9695..d22bfa5df 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -110,6 +110,7 @@ declare module '@vitest/runner' { interface TaskMeta { typecheck?: boolean benchmark?: boolean + __vitest_label__?: string } interface File { diff --git a/test/e2e/test/reported-tasks.test.ts b/test/e2e/test/reported-tasks.test.ts index d5248674c..4769fe082 100644 --- a/test/e2e/test/reported-tasks.test.ts +++ b/test/e2e/test/reported-tasks.test.ts @@ -320,20 +320,26 @@ it('can create new test specifications', ({ testModule }) => { const testSuite = [...testModule.children.suites()][0] const suiteSpec = testSuite.toTestSpecification() expect(suiteSpec.moduleId).toBe(testModule.moduleId) - expect(suiteSpec.testIds).toEqual([ - '-1008553841_11_0', - '-1008553841_11_1', - '-1008553841_11_2_0', - '-1008553841_11_2_1', - '-1008553841_11_2_2', - '-1008553841_11_2_3', - ]) + expect(suiteSpec.testIds).toMatchInlineSnapshot(` + [ + "1763725585_11_0", + "1763725585_11_1", + "1763725585_11_2_0", + "1763725585_11_2_1", + "1763725585_11_2_2", + "1763725585_11_2_3", + ] + `) expect(suiteSpec.project).toBe(testModule.project) const testCase = testSuite.children.at(0) as TestCase const caseSpec = testCase.toTestSpecification() expect(caseSpec.moduleId).toBe(testModule.moduleId) - expect(caseSpec.testIds).toEqual(['-1008553841_11_0']) + expect(caseSpec.testIds).toMatchInlineSnapshot(` + [ + "1763725585_11_0", + ] + `) expect(caseSpec.project).toBe(testModule.project) }) diff --git a/test/e2e/test/reporters/__snapshots__/html.test.ts.snap b/test/e2e/test/reporters/__snapshots__/html.test.ts.snap index c58b44d42..a1a51a543 100644 --- a/test/e2e/test/reporters/__snapshots__/html.test.ts.snap +++ b/test/e2e/test/reporters/__snapshots__/html.test.ts.snap @@ -182,7 +182,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing "file": [Circular], "fullName": "all-passing-or-skipped.test.ts > 3 + 3 = 6", "fullTestName": "3 + 3 = 6", - "id": "1111755131_1", + "id": "1804288165_1", "location": { "column": 6, "line": 7, diff --git a/test/e2e/test/reporters/merge-reports.test.ts b/test/e2e/test/reporters/merge-reports.test.ts index ce4765fd4..d7ec8b618 100644 --- a/test/e2e/test/reporters/merge-reports.test.ts +++ b/test/e2e/test/reporters/merge-reports.test.ts @@ -1,8 +1,9 @@ +import type { RunVitestConfig } from '#test-utils' import type { File, Test } from '@vitest/runner/types' import type { TestUserConfig, Vitest } from 'vitest/node' import { rmSync } from 'node:fs' import { resolve } from 'node:path' -import { runVitest } from '#test-utils' +import { buildTestTree, runVitest, useFS } from '#test-utils' import { playwright } from '@vitest/browser-playwright' import { createFileTask } from '@vitest/runner/utils' import { beforeEach, expect, test } from 'vitest' @@ -452,6 +453,7 @@ function trimReporterOutput(report: string) { const rows = report .replace(/\d+ms/g, '