From ea74e4f7add23dbf946733812adff32e17eb371c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 31 Mar 2026 12:06:18 +0900 Subject: [PATCH 01/65] wip: failed attempt --- .../vitest/src/node/pools/applyBlobLabel.ts | 22 ++++++++++ packages/vitest/src/node/pools/rpc.ts | 9 ++++ packages/vitest/src/node/state.ts | 4 +- packages/vitest/src/node/types/config.ts | 10 +++++ packages/vitest/src/types/global.ts | 1 + packages/ws-client/src/state.ts | 4 +- test/config/test/blob-label.test.ts | 41 +++++++++++++++++++ 7 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 packages/vitest/src/node/pools/applyBlobLabel.ts create mode 100644 test/config/test/blob-label.test.ts diff --git a/packages/vitest/src/node/pools/applyBlobLabel.ts b/packages/vitest/src/node/pools/applyBlobLabel.ts new file mode 100644 index 000000000000..7b6873f1a595 --- /dev/null +++ b/packages/vitest/src/node/pools/applyBlobLabel.ts @@ -0,0 +1,22 @@ +import type { File } from '@vitest/runner' +import { calculateSuiteHash, generateFileHash } from '@vitest/runner/utils' + +/** + * Rewrite file.id and all child task IDs so that the same test file run under + * different blob labels produces a distinct entry in state.filesMap. + * + * Mirrors the `__typecheck__` pattern in typecheck/collect.ts: + * hash key = `${projectName}:__typecheck__`, file.meta.typecheck = true + * + * For blob labels: + * hash key = `${projectName} [${label}]`, file.meta.blobLabel = label + */ +export function applyBlobLabel(files: File[], label: string, projectName: string | undefined): void { + for (const file of files) { + const hashName = projectName ? `${projectName} [${label}]` : `[${label}]` + file.id = generateFileHash(file.name, hashName) + file.meta = { ...file.meta, blobLabel: label } + // Recompute all child task IDs derived positionally from file.id + calculateSuiteHash(file) + } +} diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 1d09a45b87c5..149c0132f910 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -7,6 +7,7 @@ import { cleanUrl } from '@vitest/utils/helpers' import { isBuiltin, toBuiltin } from '../../utils/modules' import { handleRollupError } from '../environments/fetchModule' import { normalizeResolvedIdToUrl } from '../environments/normalizeUrl' +import { applyBlobLabel } from './applyBlobLabel' interface MethodsOptions { cacheFs?: boolean @@ -103,6 +104,10 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp return { code: result?.code } }, async onQueued(file) { + const label = project.config.blobLabel + if (label) { + applyBlobLabel([file], label, project.name) + } if (methodsOptions.collect) { vitest.state.collectFiles(project, [file]) } @@ -111,6 +116,10 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp } }, async onCollected(files) { + const label = project.config.blobLabel + if (label) { + applyBlobLabel(files, label, project.name) + } if (methodsOptions.collect) { vitest.state.collectFiles(project, files) } diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts index 89159941c1ba..c34dffa1d6e8 100644 --- a/packages/vitest/src/node/state.ts +++ b/packages/vitest/src/node/state.ts @@ -146,10 +146,10 @@ export class StateManager { 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, + i => i.projectName !== file.projectName || i.meta.typecheck !== file.meta.typecheck || i.meta.blobLabel !== file.meta.blobLabel, ) const currentFile = existing.find( - i => i.projectName === file.projectName, + i => i.projectName === file.projectName && i.meta.typecheck === file.meta.typecheck && i.meta.blobLabel === file.meta.blobLabel, ) // 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 diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 3f6ec9ece2db..7d95d2501e2f 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1107,6 +1107,15 @@ export interface UserConfig extends InlineConfig { * Log all available tags instead of running tests. */ listTags?: boolean | 'json' + + // TODO: + // move to blob reporter level option? but this influences the entire test run. + /** + * Label to disambiguate the same test file when run under different conditions + * (e.g. different OSes in a merge-reports workflow). Encoded into `File.id` and + * `File.meta.blobLabel` so that state treats each label as a distinct entry. + */ + blobLabel?: string } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void @@ -1211,6 +1220,7 @@ export interface ResolvedConfig vmMemoryLimit?: UserConfig['vmMemoryLimit'] dumpDir?: string tagsFilter?: string[] + blobLabel?: string experimental: Omit['experimental'], 'importDurations'> & { importDurations: { diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 53297e36e85e..fe9c595440cf 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 + blobLabel?: string } interface File { diff --git a/packages/ws-client/src/state.ts b/packages/ws-client/src/state.ts index 4c93940c044c..eba136a043aa 100644 --- a/packages/ws-client/src/state.ts +++ b/packages/ws-client/src/state.ts @@ -48,10 +48,10 @@ export class StateManager { files.forEach((file) => { const existing = this.filesMap.get(file.filepath) || [] const otherProject = existing.filter( - i => i.projectName !== file.projectName || i.meta.typecheck !== file.meta.typecheck, + i => i.projectName !== file.projectName || i.meta.typecheck !== file.meta.typecheck || i.meta.blobLabel !== file.meta.blobLabel, ) const currentFile = existing.find( - i => i.projectName === file.projectName, + i => i.projectName === file.projectName && i.meta.typecheck === file.meta.typecheck && i.meta.blobLabel === file.meta.blobLabel, ) // 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 diff --git a/test/config/test/blob-label.test.ts b/test/config/test/blob-label.test.ts new file mode 100644 index 000000000000..da5270888e2c --- /dev/null +++ b/test/config/test/blob-label.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'vitest' +import { generateFileHash } from '@vitest/runner/utils' +import { runInlineTests } from '../../test-utils' + +const BASIC_TEST = ` +import { test } from 'vitest' +test('t', () => {}) +` + +test('no blobLabel: file has no meta.blobLabel and normal id', async () => { + const { ctx } = await runInlineTests({ + 'a.test.ts': BASIC_TEST, + }) + const files = ctx!.state.getFiles() + expect(files).toHaveLength(1) + expect(files[0].meta.blobLabel).toBeUndefined() + expect(files[0].id).toBe(generateFileHash('a.test.ts', undefined)) +}) + +test('blobLabel: file.meta.blobLabel is set and id uses label-salted hash', async () => { + const { ctx } = await runInlineTests( + { 'a.test.ts': BASIC_TEST }, + { blobLabel: 'linux' }, + ) + const files = ctx!.state.getFiles() + expect(files).toHaveLength(1) + expect(files[0].meta.blobLabel).toBe('linux') + expect(files[0].id).toBe(generateFileHash('a.test.ts', '[linux]')) +}) + +test('blobLabel: child task ids derived from new file.id', async () => { + const { ctx: noLabel } = await runInlineTests({ 'a.test.ts': BASIC_TEST }) + const { ctx: withLabel } = await runInlineTests( + { 'a.test.ts': BASIC_TEST }, + { blobLabel: 'linux' }, + ) + const taskNoLabel = noLabel!.state.getFiles()[0].tasks[0] + const taskWithLabel = withLabel!.state.getFiles()[0].tasks[0] + expect(taskWithLabel.id).not.toBe(taskNoLabel.id) + expect(taskWithLabel.id).toBe(`${withLabel!.state.getFiles()[0].id}_0`) +}) From 54ebad41f5c126be92863dcb98f733c7645f715f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 31 Mar 2026 12:10:21 +0900 Subject: [PATCH 02/65] wip: cleanup --- .../vitest/src/node/pools/applyBlobLabel.ts | 22 ------------------- packages/vitest/src/node/pools/rpc.ts | 9 -------- 2 files changed, 31 deletions(-) delete mode 100644 packages/vitest/src/node/pools/applyBlobLabel.ts diff --git a/packages/vitest/src/node/pools/applyBlobLabel.ts b/packages/vitest/src/node/pools/applyBlobLabel.ts deleted file mode 100644 index 7b6873f1a595..000000000000 --- a/packages/vitest/src/node/pools/applyBlobLabel.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { File } from '@vitest/runner' -import { calculateSuiteHash, generateFileHash } from '@vitest/runner/utils' - -/** - * Rewrite file.id and all child task IDs so that the same test file run under - * different blob labels produces a distinct entry in state.filesMap. - * - * Mirrors the `__typecheck__` pattern in typecheck/collect.ts: - * hash key = `${projectName}:__typecheck__`, file.meta.typecheck = true - * - * For blob labels: - * hash key = `${projectName} [${label}]`, file.meta.blobLabel = label - */ -export function applyBlobLabel(files: File[], label: string, projectName: string | undefined): void { - for (const file of files) { - const hashName = projectName ? `${projectName} [${label}]` : `[${label}]` - file.id = generateFileHash(file.name, hashName) - file.meta = { ...file.meta, blobLabel: label } - // Recompute all child task IDs derived positionally from file.id - calculateSuiteHash(file) - } -} diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 149c0132f910..1d09a45b87c5 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -7,7 +7,6 @@ import { cleanUrl } from '@vitest/utils/helpers' import { isBuiltin, toBuiltin } from '../../utils/modules' import { handleRollupError } from '../environments/fetchModule' import { normalizeResolvedIdToUrl } from '../environments/normalizeUrl' -import { applyBlobLabel } from './applyBlobLabel' interface MethodsOptions { cacheFs?: boolean @@ -104,10 +103,6 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp return { code: result?.code } }, async onQueued(file) { - const label = project.config.blobLabel - if (label) { - applyBlobLabel([file], label, project.name) - } if (methodsOptions.collect) { vitest.state.collectFiles(project, [file]) } @@ -116,10 +111,6 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp } }, async onCollected(files) { - const label = project.config.blobLabel - if (label) { - applyBlobLabel(files, label, project.name) - } if (methodsOptions.collect) { vitest.state.collectFiles(project, files) } From f4ca239ee1039b2eda5986d4038304618acfb386 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 31 Mar 2026 12:22:40 +0900 Subject: [PATCH 03/65] wip: idSeed on runner --- packages/runner/src/collect.ts | 2 +- packages/runner/src/types/runner.ts | 1 + packages/runner/src/utils/collect.ts | 6 ++++-- packages/vitest/src/node/cli/cli-config.ts | 4 ++++ packages/vitest/src/node/config/serializeConfig.ts | 2 ++ packages/vitest/src/node/types/config.ts | 1 + packages/vitest/src/runtime/config.ts | 1 + packages/vitest/src/runtime/runners/test.ts | 4 ++++ 8 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index b422a3079400..7240cbaed0a8 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -47,7 +47,7 @@ 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, config.idSeed) file.tags = fileTags file.shuffle = config.sequence.shuffle diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index d05ab8056004..3298c736c515 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -45,6 +45,7 @@ export interface VitestRunnerConfig { tags: TestTagDefinition[] tagsFilter: string[] | undefined strictTags: boolean + idSeed?: string } /** diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index 4c741ac2f478..624194657fde 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -199,10 +199,11 @@ export function createFileTask( projectName: string | undefined, pool?: string, viteEnvironment?: string, + idSeed?: string, ): File { const path = relative(root, filepath) const file: File = { - id: generateFileHash(path, projectName), + id: generateFileHash(path, projectName, idSeed), name: path, fullName: path, type: 'suite', @@ -228,8 +229,9 @@ export function createFileTask( export function generateFileHash( file: string, projectName: string | undefined, + idSeed?: string, ): string { - return /* @__PURE__ */ generateHash(`${file}${projectName || ''}`) + return /* @__PURE__ */ generateHash(`${file}${projectName || ''}${idSeed ? `\0${idSeed}` : ''}`) } export function findTestFileStackTrace(testFilePath: string, error: string): ParsedStack | undefined { diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 852372f76824..b5f8fc5c7a6f 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -830,6 +830,10 @@ export const cliOptionsConfig: VitestCLIOptions = { description: 'List all available tags instead of running tests. `--list-tags=json` will output tags in JSON format, unless there are no tags.', argument: '[type]', }, + blobLabel: { + description: 'Label to disambiguate the same test file when merging blob reports from different environments (e.g. `--blob-label=linux`).', + argument: '