[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix(ui): fix missing source code in html reporter metadata when merging blobs with different root directory test runs (#10338)

Co-authored-by: Codex <noreply@openai.com>

authored by

Hiroshi Ogawa
Codex
and committed by
GitHub
(May 18, 2026, 1:04 PM +0200) 4f7c2670 fed1125b

+264 -150
+92 -51
packages/ui/node/reporter.ts
··· 1 - import type { ModuleGraphData, RunnerTestFile, SerializedRootConfig } from 'vitest' 2 - import type { HTMLOptions, Reporter, Vitest } from 'vitest/node' 3 - import { existsSync, promises as fs } from 'node:fs' 1 + import type { SerializedError } from 'vitest' 2 + import type { HTMLOptions, Reporter, TestModule, Vitest } from 'vitest/node' 3 + import type { HTMLReportMetadata } from '../client/composables/client/static' 4 + import { existsSync, promises as fs, readFileSync } from 'node:fs' 4 5 import { fileURLToPath } from 'node:url' 5 6 import { promisify } from 'node:util' 6 7 import { gzip, constants as zlibConstants } from 'node:zlib' ··· 24 25 } 25 26 26 27 return config.outputFile.html 27 - } 28 - 29 - interface HTMLReportData { 30 - paths: string[] 31 - files: RunnerTestFile[] 32 - config: SerializedRootConfig 33 - moduleGraph: Record<string, Record<string, ModuleGraphData>> 34 - unhandledErrors: unknown[] 35 - // filename -> source 36 - sources: Record<string, string> 37 28 } 38 29 39 30 const distDir = resolve(fileURLToPath(import.meta.url), '../../dist') ··· 64 55 await fs.mkdir(resolve(this.reporterDir, 'assets'), { recursive: true }) 65 56 } 66 57 67 - async onTestRunEnd(): Promise<void> { 68 - const result: HTMLReportData = { 69 - paths: this.ctx.state.getPaths(), 70 - files: this.ctx.state.getFiles(), 71 - config: this.ctx.serializedRootConfig, 72 - unhandledErrors: this.ctx.state.getUnhandledErrors(), 73 - moduleGraph: {}, 74 - sources: {}, 75 - } 76 - const promises: Promise<void>[] = [] 58 + async onTestRunEnd( 59 + testModules: ReadonlyArray<TestModule>, 60 + unhandledErrors: ReadonlyArray<SerializedError>, 61 + ): Promise<void> { 62 + const result = await serializeReportMetadata( 63 + this.ctx, 64 + testModules, 65 + unhandledErrors, 66 + ) 67 + const report = stringify(result) 77 68 78 - promises.push(...result.files.map(async (file) => { 79 - const projectName = file.projectName || '' 80 - const resolvedConfig = this.ctx.getProjectByName(projectName).config 81 - const browser = resolvedConfig.browser.enabled 82 - result.moduleGraph[projectName] ??= {} 83 - result.moduleGraph[projectName][file.filepath] = await getModuleGraph( 84 - this.ctx, 85 - projectName, 86 - file.filepath, 87 - browser, 88 - ) 89 - if (!result.sources[file.filepath]) { 90 - try { 91 - result.sources[file.filepath] = await fs.readFile(file.filepath, { 92 - encoding: 'utf-8', 93 - }) 94 - } 95 - catch { 96 - // just ignore 97 - } 98 - } 99 - })) 100 - 101 - await Promise.all(promises) 102 - await this.writeReport(stringify(result)) 103 - } 104 - 105 - async writeReport(report: string): Promise<void> { 106 69 const metaFile = resolve(this.reporterDir, 'html.meta.json.gz') 107 70 108 71 const promiseGzip = promisify(gzip) ··· 168 131 await fs.cp(coverageHtmlDir, destCoverageDir, { recursive: true }) 169 132 } 170 133 } 134 + } 135 + 136 + async function serializeReportMetadata( 137 + ctx: Vitest, 138 + testModules: ReadonlyArray<TestModule>, 139 + unhandledErrors: ReadonlyArray<SerializedError>, 140 + ) { 141 + const result: HTMLReportMetadata = { 142 + files: [], 143 + config: ctx.serializedRootConfig, 144 + unhandledErrors: [...unhandledErrors], 145 + moduleGraph: {}, 146 + testModules: [], 147 + sourceCode: { 148 + codeTable: [], 149 + testModules: {}, 150 + }, 151 + } 152 + 153 + // dedupe based on project relative paths since 154 + // they can have different absolute paths for different test runs 155 + // when merging with platform blob labels and shards. 156 + // Source code is stored in a separate table so the same file included 157 + // in multiple projects can share the content while keeping distinct 158 + // project-relative test module entries. 159 + const testModuleCodes = result.sourceCode.testModules 160 + const codeIndexes = new Map<string, number>() 161 + function getCodeIndex(code: string) { 162 + const existing = codeIndexes.get(code) 163 + if (existing != null) { 164 + return existing 165 + } 166 + const index = result.sourceCode.codeTable.length 167 + codeIndexes.set(code, index) 168 + result.sourceCode.codeTable.push(code) 169 + return index 170 + } 171 + 172 + const promises: Promise<void>[] = [] 173 + 174 + for (const testModule of testModules) { 175 + result.files.push(testModule.task) 176 + 177 + const project = testModule.project 178 + const projectName = project.name 179 + result.testModules.push({ 180 + projectName, 181 + moduleId: testModule.moduleId, 182 + relativeModuleId: testModule.relativeModuleId, 183 + }) 184 + 185 + testModuleCodes[projectName] ??= {} 186 + if (testModuleCodes[projectName][testModule.relativeModuleId] == null) { 187 + try { 188 + const code = readFileSync( 189 + testModule.moduleId, 190 + 'utf-8', 191 + ) 192 + testModuleCodes[projectName][testModule.relativeModuleId] = getCodeIndex(code) 193 + } 194 + catch {} 195 + } 196 + 197 + // TODO: https://github.com/vitest-dev/vitest/issues/9763 198 + promises.push((async () => { 199 + result.moduleGraph[projectName] ??= {} 200 + result.moduleGraph[projectName][testModule.moduleId] = await getModuleGraph( 201 + ctx, 202 + projectName, 203 + testModule.moduleId, 204 + project.config.browser.enabled, 205 + ) 206 + })()) 207 + } 208 + 209 + await Promise.all(promises) 210 + 211 + return result 171 212 }
+14 -2
test/ui/test/helper.ts
··· 1 - import type { Page } from '@playwright/test' 1 + import type { Locator, Page } from '@playwright/test' 2 2 import type { InlineConfig, PreviewServer } from 'vite' 3 3 import type { CliOptions, Vitest } from 'vitest/node' 4 4 import assert from 'node:assert' ··· 7 7 import { expect } from '@playwright/test' 8 8 import { preview } from 'vite' 9 9 import { startVitest } from 'vitest/node' 10 + 11 + export async function startVitestSimple(cliOptions: CliOptions): Promise<Vitest> { 12 + const stdout = new Writable({ write: (_, __, callback) => callback() }) 13 + const stderr = new Writable({ write: (_, __, callback) => callback() }) 14 + const vitest = await startVitest('test', undefined, cliOptions, {}, { stdout, stderr }) 15 + await vitest.close() 16 + return vitest 17 + } 10 18 11 19 export async function startVitestUi( 12 20 cliOptions: CliOptions, ··· 66 74 await item.getByTestId('btn-open-details').click() 67 75 } 68 76 77 + export function getAnnotation(locator: Page | Locator, message: string) { 78 + return locator.getByRole('note').filter({ hasText: message }) 79 + } 80 + 69 81 export async function assertDownloadAttachment( 70 82 page: Page, 71 83 options: { ··· 74 86 content: string 75 87 }, 76 88 ) { 77 - const annotation = page.getByRole('note').filter({ hasText: options.name }) 89 + const annotation = getAnnotation(page, options.name) 78 90 const downloadPromise = page.waitForEvent('download') 79 91 await annotation.getByRole('link').click() 80 92 const download = await downloadPromise
+80
test/ui/test/merge-reports.spec.ts
··· 1 + import type { PreviewServer } from 'vite' 2 + import { readdirSync, renameSync, rmSync } from 'node:fs' 3 + import path from 'node:path' 4 + import { expect, test } from '@playwright/test' 5 + import { getAnnotation, getExplorerItem, startHtmlReportPreview, startVitestSimple } from './helper' 6 + 7 + test.describe('html reporter', () => { 8 + let previewServer: PreviewServer 9 + let baseURL: string 10 + 11 + test.beforeAll(async () => { 12 + // Simulate CI uploads blobs from platform-specific jobs and merges them on 13 + // a Linux job, so the merged report can reference source paths that do not 14 + // exist on the machine generating the HTML report. 15 + 16 + const baseDir = path.join(import.meta.dirname, '../fixtures/merge-reports') 17 + const linuxRoot = path.join(baseDir, 'linux') 18 + const macosRoot = path.join(baseDir, 'macos') 19 + const linuxBlobDir = path.join(linuxRoot, '.vitest/blob') 20 + const macosBlobDir = path.join(macosRoot, '.vitest/blob') 21 + 22 + rmSync(linuxBlobDir, { force: true, recursive: true }) 23 + rmSync(macosBlobDir, { force: true, recursive: true }) 24 + 25 + await startVitestSimple({ 26 + root: linuxRoot, 27 + reporters: [['blob', { label: 'linux' }]], 28 + env: { TEST_LABEL: 'linux' }, 29 + }) 30 + await startVitestSimple({ 31 + root: macosRoot, 32 + reporters: [['blob', { label: 'macos' }]], 33 + env: { TEST_LABEL: 'macos' }, 34 + }) 35 + 36 + for (const filename of readdirSync(macosBlobDir)) { 37 + renameSync(path.join(macosBlobDir, filename), path.join(linuxBlobDir, filename)) 38 + } 39 + 40 + const server = await startHtmlReportPreview( 41 + { 42 + root: linuxRoot, 43 + mergeReports: linuxBlobDir, 44 + reporters: 'html', 45 + }, 46 + { 47 + root: linuxRoot, 48 + build: { outDir: 'html' }, 49 + }, 50 + ) 51 + 52 + previewServer = server.previewServer 53 + baseURL = `${server.url}/` 54 + }) 55 + 56 + test.afterAll(async () => { 57 + await previewServer?.close() 58 + }) 59 + 60 + test('code from different root is available', async ({ page }) => { 61 + await page.goto(baseURL) 62 + 63 + const item1 = getExplorerItem(page, 'basic.test.ts').filter({ hasText: 'linux' }) 64 + const item2 = getExplorerItem(page, 'basic.test.ts').filter({ hasText: 'macos' }) 65 + const editorButton = page.getByTestId('btn-code') 66 + const editor = page.getByTestId('editor') 67 + 68 + await item1.hover() 69 + await item1.getByTestId('btn-open-details').click() 70 + await editorButton.click() 71 + await expect(editor).toContainText(`test('ok'`) 72 + await expect(getAnnotation(editor, 'test-linux')).toBeVisible() 73 + 74 + await item2.hover() 75 + await item2.getByTestId('btn-open-details').click() 76 + await editorButton.click() 77 + await expect(editor).toContainText(`test('ok'`) 78 + await expect(getAnnotation(editor, 'test-macos')).toBeVisible() 79 + }) 80 + })
-11
packages/ui/client/composables/client/state.ts
··· 25 25 26 26 export class StateManager { 27 27 filesMap: Map<string, RunnerTestFile[]> = new Map() 28 - pathsSet: Set<string> = new Set() 29 28 idMap: Map<string, RunnerTask> = new Map() 30 - 31 - getPaths(): string[] { 32 - return Array.from(this.pathsSet) 33 - } 34 29 35 30 /** 36 31 * Return files that were running or collected. ··· 53 48 return this.getFiles() 54 49 .filter(i => i.result?.state === 'fail') 55 50 .map(i => i.filepath) 56 - } 57 - 58 - collectPaths(paths: string[] = []): void { 59 - paths.forEach((path) => { 60 - this.pathsSet.add(path) 61 - }) 62 51 } 63 52 64 53 collectFiles(files: RunnerTestFile[] = []): void {
+53 -70
packages/ui/client/composables/client/static.ts
··· 1 - import type { BirpcReturn } from 'birpc' 2 1 import type { 3 2 ModuleGraphData, 4 3 RunnerTestFile, 5 4 SerializedRootConfig, 6 - WebSocketEvents, 7 - WebSocketHandlers, 8 5 } from 'vitest' 9 - import type { VitestClient } from './ws' 6 + import type { VitestClient, VitestClientRpc } from './ws' 10 7 import { decompressSync, strFromU8 } from 'fflate' 11 8 import { parse } from 'flatted' 12 9 import { reactive } from 'vue' 13 10 import { StateManager } from './state' 14 11 15 - interface HTMLReportMetadata { 16 - paths: string[] 12 + export interface HTMLReportMetadata { 17 13 files: RunnerTestFile[] 18 14 config: SerializedRootConfig 19 15 moduleGraph: Record<string, Record<string, ModuleGraphData>> 20 16 unhandledErrors: unknown[] 21 - // filename -> source 22 - sources: Record<string, string> 17 + testModules: { 18 + projectName: string 19 + moduleId: string 20 + relativeModuleId: string 21 + }[] 22 + sourceCode: { 23 + codeTable: string[] 24 + testModules: { [projectName: string]: { [relativeModuleId: string]: number } } 25 + } 23 26 } 24 27 25 - const noop: any = () => {} 26 - const asyncNoop: any = () => Promise.resolve() 28 + function deserializeReportMetadata(metadata: HTMLReportMetadata) { 29 + const sourceCodes: { [moduleId: string]: string } = {} 30 + for (const testModule of metadata.testModules) { 31 + const codeIndex = metadata.sourceCode.testModules[testModule.projectName]?.[testModule.relativeModuleId] 32 + if (codeIndex != null) { 33 + sourceCodes[testModule.moduleId] = metadata.sourceCode.codeTable[codeIndex] 34 + } 35 + } 27 36 28 - export function createStaticClient(): VitestClient { 29 - const ctx = reactive({ 30 - state: new StateManager(), 31 - waitForConnection, 32 - reconnect, 33 - ws: new EventTarget(), 34 - }) as VitestClient 35 - 36 - ctx.state.filesMap = reactive(ctx.state.filesMap) 37 - ctx.state.idMap = reactive(ctx.state.idMap) 38 - 39 - let metadata!: HTMLReportMetadata 40 - 41 - const rpc = { 42 - getFiles: () => { 37 + const rpc: VitestClientRpc = { 38 + getFiles: async () => { 43 39 return metadata.files 44 40 }, 45 - getPaths: () => { 46 - return metadata.paths 47 - }, 48 - getConfig: () => { 41 + getConfig: async () => { 49 42 return metadata.config 50 43 }, 51 44 getModuleGraph: async (projectName, id) => { 52 45 return metadata.moduleGraph[projectName]?.[id] 53 46 }, 54 - getUnhandledErrors: () => { 47 + getUnhandledErrors: async () => { 55 48 return metadata.unhandledErrors 56 49 }, 57 - getExternalResult: asyncNoop, 58 - getTransformResult: asyncNoop, 59 - onDone: noop, 60 - writeFile: asyncNoop, 61 - rerun: asyncNoop, 62 - rerunTask: asyncNoop, 63 - updateSnapshot: asyncNoop, 64 - resolveSnapshotPath: asyncNoop, 65 - snapshotSaved: asyncNoop, 66 - onAfterSuiteRun: asyncNoop, 67 - onCancel: asyncNoop, 68 - getCountOfFailedTests: () => 0, 69 - sendLog: asyncNoop, 70 - resolveSnapshotRawPath: asyncNoop, 71 - readSnapshotFile: asyncNoop, 72 - saveSnapshotFile: asyncNoop, 73 - readTestFile: async (id: string) => { 74 - return metadata.sources[id] 50 + readTestFile: async (id) => { 51 + return sourceCodes[id] 75 52 }, 76 - removeSnapshotFile: asyncNoop, 77 - onUnhandledError: noop, 78 - saveTestFile: asyncNoop, 79 - getProvidedContext: () => ({}), 80 - getTestFiles: asyncNoop, 81 - } as Omit<WebSocketHandlers, 'getResolvedProjectLabels'> 82 - 83 - ctx.rpc = rpc as any as BirpcReturn<WebSocketHandlers, WebSocketEvents> 84 - 85 - const openPromise = Promise.resolve() 86 - 87 - function reconnect() { 88 - registerMetadata() 53 + getPaths: async () => [], 54 + getResolvedProjectLabels: async () => [], 55 + getExternalResult: async () => undefined, 56 + getTransformResult: async () => undefined, 57 + rerun: async () => {}, 58 + rerunTask: async () => {}, 59 + updateSnapshot: async () => {}, 60 + saveTestFile: async () => {}, 61 + getTestFiles: async () => [], 89 62 } 63 + return rpc 64 + } 65 + 66 + export function createStaticClient(): VitestClient { 67 + const ctx = reactive<VitestClient>({ 68 + ws: new EventTarget() as WebSocket, 69 + state: new StateManager(), 70 + rpc: undefined!, 71 + reconnect: () => registerMetadata(), 72 + waitForConnection: async () => {}, 73 + }) 74 + 75 + ctx.state.filesMap = reactive(ctx.state.filesMap) 76 + ctx.state.idMap = reactive(ctx.state.idMap) 90 77 91 78 async function registerMetadata() { 92 79 const res = await fetch(window.METADATA_PATH!) 93 80 const content = new Uint8Array(await res.arrayBuffer()) 94 - 81 + let metadata: HTMLReportMetadata 95 82 // Check for gzip magic numbers (0x1f 0x8b) to determine if content is compressed. 96 83 // This handles cases where a static server incorrectly sets Content-Encoding: gzip 97 84 // for .gz files, causing the browser to auto-decompress before we process the raw gzip data. 98 85 if (content.length >= 2 && content[0] === 0x1F && content[1] === 0x8B) { 99 86 const decompressed = strFromU8(decompressSync(content)) 100 - metadata = parse(decompressed) as HTMLReportMetadata 87 + metadata = parse(decompressed) 101 88 } 102 89 else { 103 - metadata = parse(strFromU8(content)) as HTMLReportMetadata 90 + metadata = parse(strFromU8(content)) 104 91 } 105 - const event = new Event('open') 106 - ctx.ws.dispatchEvent(event) 92 + ctx.rpc = deserializeReportMetadata(metadata) 93 + ctx.ws.dispatchEvent(new Event('open')) 107 94 } 108 95 109 96 registerMetadata() 110 - 111 - function waitForConnection() { 112 - return openPromise 113 - } 114 97 115 98 return ctx 116 99 }
+13 -16
packages/ui/client/composables/client/ws.ts
··· 1 - import type { BirpcOptions, BirpcReturn } from 'birpc' 1 + import type { BirpcOptions, PromisifyFn } from 'birpc' 2 2 import type { WebSocketEvents, WebSocketHandlers } from 'vitest' 3 3 import { createBirpc } from 'birpc' 4 4 import { parse, stringify } from 'flatted' ··· 15 15 WebSocketConstructor?: typeof WebSocket 16 16 } 17 17 18 + export type VitestClientRpc = { 19 + [K in keyof WebSocketHandlers]: PromisifyFn<WebSocketHandlers[K]> 20 + } 21 + 18 22 export interface VitestClient { 19 23 ws: WebSocket 20 24 state: StateManager 21 - rpc: BirpcReturn<WebSocketHandlers, WebSocketEvents> 25 + rpc: VitestClientRpc 26 + // TODO: unused 22 27 waitForConnection: () => Promise<void> 23 28 reconnect: () => Promise<void> 24 29 } ··· 35 40 } = options 36 41 37 42 let tries = reconnectTries 38 - const ctx = reactive({ 43 + let openPromise: Promise<void> 44 + const ctx = reactive<VitestClient>({ 39 45 ws: new WebSocketConstructor(url), 40 46 state: new StateManager(), 41 - waitForConnection, 47 + rpc: undefined!, 48 + waitForConnection: () => openPromise, 42 49 reconnect, 43 - }, 'state') as VitestClient 50 + }, 'state') 44 51 45 52 ctx.state.filesMap = reactive(ctx.state.filesMap, 'filesMap') 46 53 ctx.state.idMap = reactive(ctx.state.idMap, 'idMap') ··· 58 65 ctx.state.clearFiles({ config }, [file]) 59 66 }) 60 67 handlers.onSpecsCollected?.(specs, startTime) 61 - }, 62 - onPathsCollected(paths) { 63 - ctx.state.collectPaths(paths) 64 - handlers.onPathsCollected?.(paths) 65 68 }, 66 69 onCollected(files) { 67 70 ctx.state.collectFiles(files) ··· 106 109 birpcHandlers, 107 110 ) 108 111 109 - let openPromise: Promise<void> 110 - 111 - function reconnect(reset = false) { 112 + async function reconnect(reset = false) { 112 113 if (reset) { 113 114 tries = reconnectTries 114 115 } ··· 147 148 } 148 149 149 150 registerWS() 150 - 151 - function waitForConnection() { 152 - return openPromise 153 - } 154 151 155 152 return ctx 156 153 }
+5
test/ui/fixtures/merge-reports/linux/basic.test.ts
··· 1 + import { test } from 'vitest' 2 + 3 + test('ok', async ({ annotate }) => { 4 + await annotate(`test-${process.env.TEST_LABEL ?? "unknown"}`) 5 + })
+1
test/ui/fixtures/merge-reports/linux/vitest.config.ts
··· 1 + export default {}
+5
test/ui/fixtures/merge-reports/macos/basic.test.ts
··· 1 + import { test } from 'vitest' 2 + 3 + test('ok', async ({ annotate }) => { 4 + await annotate(`test-${process.env.TEST_LABEL ?? "unknown"}`) 5 + })
+1
test/ui/fixtures/merge-reports/macos/vitest.config.ts
··· 1 + export default {}