From 4f7c2670cf596dd5176c009fc9ce3b817e7b2195 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 18 May 2026 20:04:02 +0900 Subject: [PATCH] fix(ui): fix missing source code in html reporter metadata when merging blobs with different root directory test runs (#10338) Co-authored-by: Codex --- .../ui/client/composables/client/state.ts | 11 -- .../ui/client/composables/client/static.ts | 121 +++++++-------- packages/ui/client/composables/client/ws.ts | 29 ++-- packages/ui/node/reporter.ts | 143 +++++++++++------- .../merge-reports/linux/basic.test.ts | 5 + .../merge-reports/linux/vitest.config.ts | 1 + .../merge-reports/macos/basic.test.ts | 5 + .../merge-reports/macos/vitest.config.ts | 1 + test/ui/test/helper.ts | 16 +- test/ui/test/merge-reports.spec.ts | 80 ++++++++++ 10 files changed, 263 insertions(+), 149 deletions(-) create mode 100644 test/ui/fixtures/merge-reports/linux/basic.test.ts create mode 100644 test/ui/fixtures/merge-reports/linux/vitest.config.ts create mode 100644 test/ui/fixtures/merge-reports/macos/basic.test.ts create mode 100644 test/ui/fixtures/merge-reports/macos/vitest.config.ts create mode 100644 test/ui/test/merge-reports.spec.ts diff --git a/packages/ui/client/composables/client/state.ts b/packages/ui/client/composables/client/state.ts index 971f9f17f..106b54f84 100644 --- a/packages/ui/client/composables/client/state.ts +++ b/packages/ui/client/composables/client/state.ts @@ -25,13 +25,8 @@ export const tagsDefinitions = computed(() => { export class StateManager { filesMap: Map = new Map() - pathsSet: Set = new Set() idMap: Map = new Map() - getPaths(): string[] { - return Array.from(this.pathsSet) - } - /** * Return files that were running or collected. */ @@ -55,12 +50,6 @@ export class StateManager { .map(i => i.filepath) } - collectPaths(paths: string[] = []): void { - paths.forEach((path) => { - this.pathsSet.add(path) - }) - } - collectFiles(files: RunnerTestFile[] = []): void { files.forEach((file) => { const existing = this.filesMap.get(file.filepath) || [] diff --git a/packages/ui/client/composables/client/static.ts b/packages/ui/client/composables/client/static.ts index 06000e9bb..67ab5f592 100644 --- a/packages/ui/client/composables/client/static.ts +++ b/packages/ui/client/composables/client/static.ts @@ -1,116 +1,99 @@ -import type { BirpcReturn } from 'birpc' import type { ModuleGraphData, RunnerTestFile, SerializedRootConfig, - WebSocketEvents, - WebSocketHandlers, } from 'vitest' -import type { VitestClient } from './ws' +import type { VitestClient, VitestClientRpc } from './ws' import { decompressSync, strFromU8 } from 'fflate' import { parse } from 'flatted' import { reactive } from 'vue' import { StateManager } from './state' -interface HTMLReportMetadata { - paths: string[] +export interface HTMLReportMetadata { files: RunnerTestFile[] config: SerializedRootConfig moduleGraph: Record> unhandledErrors: unknown[] - // filename -> source - sources: Record + testModules: { + projectName: string + moduleId: string + relativeModuleId: string + }[] + sourceCode: { + codeTable: string[] + testModules: { [projectName: string]: { [relativeModuleId: string]: number } } + } } -const noop: any = () => {} -const asyncNoop: any = () => Promise.resolve() - -export function createStaticClient(): VitestClient { - const ctx = reactive({ - state: new StateManager(), - waitForConnection, - reconnect, - ws: new EventTarget(), - }) as VitestClient - - ctx.state.filesMap = reactive(ctx.state.filesMap) - ctx.state.idMap = reactive(ctx.state.idMap) - - let metadata!: HTMLReportMetadata +function deserializeReportMetadata(metadata: HTMLReportMetadata) { + const sourceCodes: { [moduleId: string]: string } = {} + for (const testModule of metadata.testModules) { + const codeIndex = metadata.sourceCode.testModules[testModule.projectName]?.[testModule.relativeModuleId] + if (codeIndex != null) { + sourceCodes[testModule.moduleId] = metadata.sourceCode.codeTable[codeIndex] + } + } - const rpc = { - getFiles: () => { + const rpc: VitestClientRpc = { + getFiles: async () => { return metadata.files }, - getPaths: () => { - return metadata.paths - }, - getConfig: () => { + getConfig: async () => { return metadata.config }, getModuleGraph: async (projectName, id) => { return metadata.moduleGraph[projectName]?.[id] }, - getUnhandledErrors: () => { + getUnhandledErrors: async () => { return metadata.unhandledErrors }, - getExternalResult: asyncNoop, - getTransformResult: asyncNoop, - onDone: noop, - writeFile: asyncNoop, - rerun: asyncNoop, - rerunTask: asyncNoop, - updateSnapshot: asyncNoop, - resolveSnapshotPath: asyncNoop, - snapshotSaved: asyncNoop, - onAfterSuiteRun: asyncNoop, - onCancel: asyncNoop, - getCountOfFailedTests: () => 0, - sendLog: asyncNoop, - resolveSnapshotRawPath: asyncNoop, - readSnapshotFile: asyncNoop, - saveSnapshotFile: asyncNoop, - readTestFile: async (id: string) => { - return metadata.sources[id] + readTestFile: async (id) => { + return sourceCodes[id] }, - removeSnapshotFile: asyncNoop, - onUnhandledError: noop, - saveTestFile: asyncNoop, - getProvidedContext: () => ({}), - getTestFiles: asyncNoop, - } as Omit - - ctx.rpc = rpc as any as BirpcReturn + getPaths: async () => [], + getResolvedProjectLabels: async () => [], + getExternalResult: async () => undefined, + getTransformResult: async () => undefined, + rerun: async () => {}, + rerunTask: async () => {}, + updateSnapshot: async () => {}, + saveTestFile: async () => {}, + getTestFiles: async () => [], + } + return rpc +} - const openPromise = Promise.resolve() +export function createStaticClient(): VitestClient { + const ctx = reactive({ + ws: new EventTarget() as WebSocket, + state: new StateManager(), + rpc: undefined!, + reconnect: () => registerMetadata(), + waitForConnection: async () => {}, + }) - function reconnect() { - registerMetadata() - } + ctx.state.filesMap = reactive(ctx.state.filesMap) + ctx.state.idMap = reactive(ctx.state.idMap) async function registerMetadata() { const res = await fetch(window.METADATA_PATH!) const content = new Uint8Array(await res.arrayBuffer()) - + let metadata: HTMLReportMetadata // Check for gzip magic numbers (0x1f 0x8b) to determine if content is compressed. // This handles cases where a static server incorrectly sets Content-Encoding: gzip // for .gz files, causing the browser to auto-decompress before we process the raw gzip data. if (content.length >= 2 && content[0] === 0x1F && content[1] === 0x8B) { const decompressed = strFromU8(decompressSync(content)) - metadata = parse(decompressed) as HTMLReportMetadata + metadata = parse(decompressed) } else { - metadata = parse(strFromU8(content)) as HTMLReportMetadata + metadata = parse(strFromU8(content)) } - const event = new Event('open') - ctx.ws.dispatchEvent(event) + ctx.rpc = deserializeReportMetadata(metadata) + ctx.ws.dispatchEvent(new Event('open')) } registerMetadata() - function waitForConnection() { - return openPromise - } - return ctx } diff --git a/packages/ui/client/composables/client/ws.ts b/packages/ui/client/composables/client/ws.ts index d7c231a25..fa5266ba0 100644 --- a/packages/ui/client/composables/client/ws.ts +++ b/packages/ui/client/composables/client/ws.ts @@ -1,4 +1,4 @@ -import type { BirpcOptions, BirpcReturn } from 'birpc' +import type { BirpcOptions, PromisifyFn } from 'birpc' import type { WebSocketEvents, WebSocketHandlers } from 'vitest' import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' @@ -15,10 +15,15 @@ export interface VitestClientOptions { WebSocketConstructor?: typeof WebSocket } +export type VitestClientRpc = { + [K in keyof WebSocketHandlers]: PromisifyFn +} + export interface VitestClient { ws: WebSocket state: StateManager - rpc: BirpcReturn + rpc: VitestClientRpc + // TODO: unused waitForConnection: () => Promise reconnect: () => Promise } @@ -35,12 +40,14 @@ export function createWsClient(url: string, options: VitestClientOptions = {}): } = options let tries = reconnectTries - const ctx = reactive({ + let openPromise: Promise + const ctx = reactive({ ws: new WebSocketConstructor(url), state: new StateManager(), - waitForConnection, + rpc: undefined!, + waitForConnection: () => openPromise, reconnect, - }, 'state') as VitestClient + }, 'state') ctx.state.filesMap = reactive(ctx.state.filesMap, 'filesMap') ctx.state.idMap = reactive(ctx.state.idMap, 'idMap') @@ -59,10 +66,6 @@ export function createWsClient(url: string, options: VitestClientOptions = {}): }) handlers.onSpecsCollected?.(specs, startTime) }, - onPathsCollected(paths) { - ctx.state.collectPaths(paths) - handlers.onPathsCollected?.(paths) - }, onCollected(files) { ctx.state.collectFiles(files) handlers.onCollected?.(files) @@ -106,9 +109,7 @@ export function createWsClient(url: string, options: VitestClientOptions = {}): birpcHandlers, ) - let openPromise: Promise - - function reconnect(reset = false) { + async function reconnect(reset = false) { if (reset) { tries = reconnectTries } @@ -148,9 +149,5 @@ export function createWsClient(url: string, options: VitestClientOptions = {}): registerWS() - function waitForConnection() { - return openPromise - } - return ctx } diff --git a/packages/ui/node/reporter.ts b/packages/ui/node/reporter.ts index 04b3aa596..76bd5660c 100644 --- a/packages/ui/node/reporter.ts +++ b/packages/ui/node/reporter.ts @@ -1,6 +1,7 @@ -import type { ModuleGraphData, RunnerTestFile, SerializedRootConfig } from 'vitest' -import type { HTMLOptions, Reporter, Vitest } from 'vitest/node' -import { existsSync, promises as fs } from 'node:fs' +import type { SerializedError } from 'vitest' +import type { HTMLOptions, Reporter, TestModule, Vitest } from 'vitest/node' +import type { HTMLReportMetadata } from '../client/composables/client/static' +import { existsSync, promises as fs, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { gzip, constants as zlibConstants } from 'node:zlib' @@ -26,16 +27,6 @@ function getOutputFile(config: PotentialConfig | undefined) { return config.outputFile.html } -interface HTMLReportData { - paths: string[] - files: RunnerTestFile[] - config: SerializedRootConfig - moduleGraph: Record> - unhandledErrors: unknown[] - // filename -> source - sources: Record -} - const distDir = resolve(fileURLToPath(import.meta.url), '../../dist') export default class HTMLReporter implements Reporter { @@ -64,45 +55,17 @@ export default class HTMLReporter implements Reporter { await fs.mkdir(resolve(this.reporterDir, 'assets'), { recursive: true }) } - async onTestRunEnd(): Promise { - const result: HTMLReportData = { - paths: this.ctx.state.getPaths(), - files: this.ctx.state.getFiles(), - config: this.ctx.serializedRootConfig, - unhandledErrors: this.ctx.state.getUnhandledErrors(), - moduleGraph: {}, - sources: {}, - } - const promises: Promise[] = [] - - promises.push(...result.files.map(async (file) => { - const projectName = file.projectName || '' - const resolvedConfig = this.ctx.getProjectByName(projectName).config - const browser = resolvedConfig.browser.enabled - result.moduleGraph[projectName] ??= {} - result.moduleGraph[projectName][file.filepath] = await getModuleGraph( - this.ctx, - projectName, - file.filepath, - browser, - ) - if (!result.sources[file.filepath]) { - try { - result.sources[file.filepath] = await fs.readFile(file.filepath, { - encoding: 'utf-8', - }) - } - catch { - // just ignore - } - } - })) - - await Promise.all(promises) - await this.writeReport(stringify(result)) - } + async onTestRunEnd( + testModules: ReadonlyArray, + unhandledErrors: ReadonlyArray, + ): Promise { + const result = await serializeReportMetadata( + this.ctx, + testModules, + unhandledErrors, + ) + const report = stringify(result) - async writeReport(report: string): Promise { const metaFile = resolve(this.reporterDir, 'html.meta.json.gz') const promiseGzip = promisify(gzip) @@ -169,3 +132,81 @@ export default class HTMLReporter implements Reporter { } } } + +async function serializeReportMetadata( + ctx: Vitest, + testModules: ReadonlyArray, + unhandledErrors: ReadonlyArray, +) { + const result: HTMLReportMetadata = { + files: [], + config: ctx.serializedRootConfig, + unhandledErrors: [...unhandledErrors], + moduleGraph: {}, + testModules: [], + sourceCode: { + codeTable: [], + testModules: {}, + }, + } + + // dedupe based on project relative paths since + // they can have different absolute paths for different test runs + // when merging with platform blob labels and shards. + // Source code is stored in a separate table so the same file included + // in multiple projects can share the content while keeping distinct + // project-relative test module entries. + const testModuleCodes = result.sourceCode.testModules + const codeIndexes = new Map() + function getCodeIndex(code: string) { + const existing = codeIndexes.get(code) + if (existing != null) { + return existing + } + const index = result.sourceCode.codeTable.length + codeIndexes.set(code, index) + result.sourceCode.codeTable.push(code) + return index + } + + const promises: Promise[] = [] + + for (const testModule of testModules) { + result.files.push(testModule.task) + + const project = testModule.project + const projectName = project.name + result.testModules.push({ + projectName, + moduleId: testModule.moduleId, + relativeModuleId: testModule.relativeModuleId, + }) + + testModuleCodes[projectName] ??= {} + if (testModuleCodes[projectName][testModule.relativeModuleId] == null) { + try { + const code = readFileSync( + testModule.moduleId, + 'utf-8', + ) + testModuleCodes[projectName][testModule.relativeModuleId] = getCodeIndex(code) + } + catch {} + } + + // TODO: https://github.com/vitest-dev/vitest/issues/9763 + promises.push((async () => { + result.moduleGraph[projectName] ??= {} + result.moduleGraph[projectName][testModule.moduleId] = await getModuleGraph( + ctx, + projectName, + testModule.moduleId, + project.config.browser.enabled, + ) + })()) + } + + await Promise.all(promises) + + return result +} diff --git a/test/ui/fixtures/merge-reports/linux/basic.test.ts b/test/ui/fixtures/merge-reports/linux/basic.test.ts new file mode 100644 index 000000000..b3b349a2d --- /dev/null +++ b/test/ui/fixtures/merge-reports/linux/basic.test.ts @@ -0,0 +1,5 @@ +import { test } from 'vitest' + +test('ok', async ({ annotate }) => { + await annotate(`test-${process.env.TEST_LABEL ?? "unknown"}`) +}) diff --git a/test/ui/fixtures/merge-reports/linux/vitest.config.ts b/test/ui/fixtures/merge-reports/linux/vitest.config.ts new file mode 100644 index 000000000..b1c6ea436 --- /dev/null +++ b/test/ui/fixtures/merge-reports/linux/vitest.config.ts @@ -0,0 +1 @@ +export default {} diff --git a/test/ui/fixtures/merge-reports/macos/basic.test.ts b/test/ui/fixtures/merge-reports/macos/basic.test.ts new file mode 100644 index 000000000..b3b349a2d --- /dev/null +++ b/test/ui/fixtures/merge-reports/macos/basic.test.ts @@ -0,0 +1,5 @@ +import { test } from 'vitest' + +test('ok', async ({ annotate }) => { + await annotate(`test-${process.env.TEST_LABEL ?? "unknown"}`) +}) diff --git a/test/ui/fixtures/merge-reports/macos/vitest.config.ts b/test/ui/fixtures/merge-reports/macos/vitest.config.ts new file mode 100644 index 000000000..b1c6ea436 --- /dev/null +++ b/test/ui/fixtures/merge-reports/macos/vitest.config.ts @@ -0,0 +1 @@ +export default {} diff --git a/test/ui/test/helper.ts b/test/ui/test/helper.ts index 46efcab66..f41a3cf5f 100644 --- a/test/ui/test/helper.ts +++ b/test/ui/test/helper.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import type { InlineConfig, PreviewServer } from 'vite' import type { CliOptions, Vitest } from 'vitest/node' import assert from 'node:assert' @@ -8,6 +8,14 @@ import { expect } from '@playwright/test' import { preview } from 'vite' import { startVitest } from 'vitest/node' +export async function startVitestSimple(cliOptions: CliOptions): Promise { + const stdout = new Writable({ write: (_, __, callback) => callback() }) + const stderr = new Writable({ write: (_, __, callback) => callback() }) + const vitest = await startVitest('test', undefined, cliOptions, {}, { stdout, stderr }) + await vitest.close() + return vitest +} + export async function startVitestUi( cliOptions: CliOptions, viteOverrides: InlineConfig = {}, @@ -66,6 +74,10 @@ export async function openExplorerFileItem(page: Page, name: string) { await item.getByTestId('btn-open-details').click() } +export function getAnnotation(locator: Page | Locator, message: string) { + return locator.getByRole('note').filter({ hasText: message }) +} + export async function assertDownloadAttachment( page: Page, options: { @@ -74,7 +86,7 @@ export async function assertDownloadAttachment( content: string }, ) { - const annotation = page.getByRole('note').filter({ hasText: options.name }) + const annotation = getAnnotation(page, options.name) const downloadPromise = page.waitForEvent('download') await annotation.getByRole('link').click() const download = await downloadPromise diff --git a/test/ui/test/merge-reports.spec.ts b/test/ui/test/merge-reports.spec.ts new file mode 100644 index 000000000..b03976b12 --- /dev/null +++ b/test/ui/test/merge-reports.spec.ts @@ -0,0 +1,80 @@ +import type { PreviewServer } from 'vite' +import { readdirSync, renameSync, rmSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { getAnnotation, getExplorerItem, startHtmlReportPreview, startVitestSimple } from './helper' + +test.describe('html reporter', () => { + let previewServer: PreviewServer + let baseURL: string + + test.beforeAll(async () => { + // Simulate CI uploads blobs from platform-specific jobs and merges them on + // a Linux job, so the merged report can reference source paths that do not + // exist on the machine generating the HTML report. + + const baseDir = path.join(import.meta.dirname, '../fixtures/merge-reports') + const linuxRoot = path.join(baseDir, 'linux') + const macosRoot = path.join(baseDir, 'macos') + const linuxBlobDir = path.join(linuxRoot, '.vitest/blob') + const macosBlobDir = path.join(macosRoot, '.vitest/blob') + + rmSync(linuxBlobDir, { force: true, recursive: true }) + rmSync(macosBlobDir, { force: true, recursive: true }) + + await startVitestSimple({ + root: linuxRoot, + reporters: [['blob', { label: 'linux' }]], + env: { TEST_LABEL: 'linux' }, + }) + await startVitestSimple({ + root: macosRoot, + reporters: [['blob', { label: 'macos' }]], + env: { TEST_LABEL: 'macos' }, + }) + + for (const filename of readdirSync(macosBlobDir)) { + renameSync(path.join(macosBlobDir, filename), path.join(linuxBlobDir, filename)) + } + + const server = await startHtmlReportPreview( + { + root: linuxRoot, + mergeReports: linuxBlobDir, + reporters: 'html', + }, + { + root: linuxRoot, + build: { outDir: 'html' }, + }, + ) + + previewServer = server.previewServer + baseURL = `${server.url}/` + }) + + test.afterAll(async () => { + await previewServer?.close() + }) + + test('code from different root is available', async ({ page }) => { + await page.goto(baseURL) + + const item1 = getExplorerItem(page, 'basic.test.ts').filter({ hasText: 'linux' }) + const item2 = getExplorerItem(page, 'basic.test.ts').filter({ hasText: 'macos' }) + const editorButton = page.getByTestId('btn-code') + const editor = page.getByTestId('editor') + + await item1.hover() + await item1.getByTestId('btn-open-details').click() + await editorButton.click() + await expect(editor).toContainText(`test('ok'`) + await expect(getAnnotation(editor, 'test-linux')).toBeVisible() + + await item2.hover() + await item2.getByTestId('btn-open-details').click() + await editorButton.click() + await expect(editor).toContainText(`test('ok'`) + await expect(getAnnotation(editor, 'test-macos')).toBeVisible() + }) +}) -- 2.51.2