openclaw/scripts/lib/vitest-report-capture.mts
Vincent Koc 8e5a3f3638
chore(test): migrate to stable Vitest 5 (#138264)
* build(test): migrate to stable Vitest 5

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* docs(test): document Vitest 5 contracts

* test(test): prove Vitest cache invalidation

* test(test): stabilize report config load proof

* test(test): restore Vitest 5 compatibility

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* test(test): isolate pnpm cache fixture registry

* fix(test): keep pnpm 12 env lock portable

* fix(test): preserve explicit Vitest project roots

* fix(test): preserve nested Vitest project identity

* test(test): expect captured Vitest name prefix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-09-06 04:17:59 +09:00

170 lines
6.1 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import type { Reporter, TestModule, TestProject, TestSpecification, Vitest } from "vitest/node";
function projectIdentity(project: TestProject) {
return {
name: project.name,
namePrefix: project.namePrefix,
root: project.config.root,
config: project.vite.config.configFile,
pool: project.config.pool,
};
}
function moduleIdentity(spec: TestSpecification) {
return {
...projectIdentity(spec.project),
file: spec.moduleId,
pool: spec.pool,
taskId: spec.taskId,
};
}
// Native Istanbul reporter destinations (istanbul-reports/lib/*/index.js).
// Tuple options/custom reporters can select their own paths and need a separate contract.
const coverageFiles: Record<string, string[]> = {
text: [],
"text-summary": [],
"text-lcov": [],
teamcity: [],
none: [],
json: ["coverage-final.json"],
"json-summary": ["coverage-summary.json"],
lcov: ["lcov.info", "lcov-report/index.html"],
lcovonly: ["lcov.info"],
html: ["index.html"],
"html-spa": ["index.html"],
clover: ["clover.xml"],
cobertura: ["cobertura-coverage.xml"],
};
export type VitestReportCapture = {
pid: number;
command: string[];
root: string;
projects: ReturnType<typeof projectIdentity>[];
modules: ReturnType<typeof moduleIdentity>[];
coverageDirectory: string | null;
coverageFiles: string[];
coverageOnFailure: boolean;
processTimedOut: boolean;
passWithNoTests: boolean;
ignoreUnhandledErrors: boolean;
ended?: { reason: string; unhandledErrors: number; failedModules: number; suiteErrors: number };
};
const sorted = (values: unknown[]) => values.map((value) => JSON.stringify(value)).toSorted();
/** Observe native loading and replay; never replace reporters or reconstruct native tasks. */
export default class VitestReportCaptureReporter implements Reporter {
private output = "";
private capture!: VitestReportCapture;
private expected: VitestReportCapture[];
constructor(options: { expected?: VitestReportCapture[] } = {}) {
this.expected = options.expected ?? [];
}
onInit(ctx: Vitest) {
const output = ctx.config.outputFile;
assert(output && typeof output === "object" && typeof output.json === "string");
this.output = `${output.json}.capture.json`;
for (const reporter of ctx.config.reporters) {
if (Array.isArray(reporter) && ["json", "blob"].includes(reporter[0])) {
assert(
!("outputFile" in reporter[1] && reporter[1].outputFile != null) &&
!("filterMeta" in reporter[1] && reporter[1].filterMeta != null),
"Multi-invocation JSON cannot preserve config-owned reporter options. Run each config separately with its own native output file.",
);
}
}
// Wholly empty blob replay uses the merge config's default project only as a host.
const loadedProjects =
this.expected.length && this.expected.every((capture) => capture.projects.length === 0)
? []
: ctx.projects;
const projects = loadedProjects.map(projectIdentity);
assert(
projects.every(
(project, index) =>
project.name &&
typeof project.namePrefix === "string" &&
project.config &&
!loadedProjects[index]!.isBrowserEnabled(),
),
"Multi-invocation JSON requires named file-based Node projects; run inline/browser configurations separately.",
);
const coverage = ctx.config.coverage;
const expectedCoverage = coverage.enabled
? coverage.reporter.flatMap(([name, options]) => {
assert(
(coverage.provider === "v8" || coverage.provider === "istanbul") &&
Object.hasOwn(coverageFiles, name) &&
Object.keys(options).length === 0,
"Multi-invocation JSON cannot own custom coverage providers/reporters or tuple options. Run each config separately with unique coverage destinations.",
);
return coverageFiles[name]!;
})
: [];
this.capture = {
pid: process.pid,
command: [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
root: ctx.config.root,
projects,
modules: [],
coverageDirectory: ctx.config.coverage.enabled ? ctx.config.coverage.reportsDirectory : null,
coverageFiles: expectedCoverage,
coverageOnFailure: coverage.reportOnFailure,
processTimedOut: false,
passWithNoTests: ctx.config.passWithNoTests,
ignoreUnhandledErrors: ctx.config.dangerouslyIgnoreUnhandledErrors,
};
if (this.expected.length) {
const expectedProjects = [
...new Set(this.expected.flatMap((capture) => sorted(capture.projects))),
].toSorted();
assert.deepEqual(sorted(projects), expectedProjects, "Native merge project identity changed");
}
this.writeCapture();
}
onTestRunStart(specs: readonly TestSpecification[]) {
if (this.expected.length) {
assert.deepEqual(
sorted(specs.map(moduleIdentity)),
sorted(this.expected.flatMap((capture) => capture.modules)),
"Native merge module/project/root/pool attribution changed",
);
}
}
onTestRunEnd(modules: readonly TestModule[], errors: readonly unknown[], reason: string) {
// Match native BlobReporter: empty runs have no module owners to replay.
this.capture.projects = [...new Set(modules.map((module) => module.project))].map(
projectIdentity,
);
this.capture.modules = modules.map((module) => moduleIdentity(module.toTestSpecification()));
this.capture.ended = {
reason,
unhandledErrors: errors.length,
failedModules: modules.filter((module) => !module.ok()).length,
// Native JSON keeps only the first file error and omits nested suite errors.
suiteErrors: modules
.flatMap((module) => [module, ...module.children.allSuites()])
.reduce((count, suite) => count + suite.errors().length, 0),
};
this.writeCapture();
}
onProcessTimeout() {
this.capture.processTimedOut = true;
this.writeCapture();
}
private writeCapture() {
fs.mkdirSync(path.dirname(this.output), { recursive: true });
fs.writeFileSync(this.output, JSON.stringify(this.capture));
}
}