perf(test): add timing telemetry, cold-start probe, and baseline snapshot

- ci-test-shard.mjs: --write-timings aggregation into scripts/test-timings.json (bucketed, newer-snapshot-protected, corrupt-shard tolerant) and --cold-start-probe; CI shard invocations emit vitest json timings
- test-changed.mjs: structured mode/reason telemetry line (+ --print-mode)
- pr-checks.yml: upload per-shard timing artifacts
- baseline: docs/test-speed-baseline-2026-06-03.md (core 41s, engine 179s, cli 49s; cold-start ~1.3-1.8s/process => U8 gate: worthwhile-not-urgent)
This commit is contained in:
gsxdsm
2026-06-03 16:48:50 -07:00
parent 84bd78c07b
commit c69d384e67
9 changed files with 1767 additions and 3 deletions

View File

@@ -85,3 +85,17 @@ jobs:
- name: Test (deterministic shard)
run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4
# U1 (R4): each shard emits per-file vitest JSON timing reporter output
# under .timings/. Upload as an artifact so the timing snapshot can be
# refreshed locally/from the default branch via
# `node scripts/ci-test-shard.mjs --write-timings`. We do NOT commit the
# snapshot from PR branches — refresh is manual/scheduled only.
- name: Upload per-shard test timings
if: always()
uses: actions/upload-artifact@v4
with:
name: test-timings-shard-${{ matrix.shard }}
path: .timings/timings-*.json
if-no-files-found: ignore
retention-days: 14

4
.gitignore vendored
View File

@@ -77,3 +77,7 @@ fusion.db-shm
# Capacitor mobile platform directories (generated by `cap add`)
packages/dashboard/ios/
packages/dashboard/android/
# Per-shard vitest JSON timing reporter outputs (raw; merged into
# scripts/test-timings.json via `ci-test-shard.mjs --write-timings`).
.timings/

View File

@@ -1,5 +1,7 @@
# FN-5048 Test-Speed Audit
> **Refreshed baseline (2026-06-03):** see `docs/test-speed-baseline-2026-06-03.md` for the U1 machine-readable per-file timing snapshot, refreshed top-10 offenders, and the cold-start/transform-cost probe feeding the U8 vitest-4 gate.
## Scope and method
- Related baseline context:
- `docs/test-audit-report.md` (prior workspace test audit baseline)

View File

@@ -0,0 +1,149 @@
# Test-Speed Baseline — 2026-06-03 (U1 refresh)
Successor to `docs/test-speed-audit-FN-5048.md`. This baseline is captured from the
machine-readable per-file timing telemetry added in U1 of
`docs/plans/2026-06-03-001-perf-test-suite-speedup-plan.md`. It feeds the U6
(duration-based sharding), U7 (slow-test triage), and U8 (vitest 4.x gate)
decisions.
## Method
- Per-file durations come from vitest's `--reporter=json` output
(`endTime − startTime` per test file), merged into `scripts/test-timings.json`
via `node scripts/ci-test-shard.mjs --write-timings`.
- Cold-start overhead comes from `node scripts/ci-test-shard.mjs --cold-start-probe <pkg>`,
which runs one cheap test file and reports `wallClock − sum(testDurations)`.
- Worker caps were left at defaults (no `FUSION_TEST_*` / `VITEST_MAX_WORKERS`
overrides), per FN-5048.
- Capture invocations (one per package/lane):
- `pnpm --filter @fusion/core exec vitest run --silent=passed-only --reporter=dot --reporter=json --outputFile.json=...`
- `pnpm --filter @fusion/engine exec vitest run ... --project=engine-default --project=engine-reliability`
- `pnpm --filter @runfusion/fusion exec vitest run ...`
- dashboard curated lanes via `run-vitest-with-heap.mjs` for
`dashboard-api-quality` and `dashboard-app-quality-components-a`
(the dashboard `test` script is a 14-lane chain; two representative lanes
were captured for the snapshot — a full lane sweep is a follow-up).
Note: per-file durations are summed wall-clock per file; because files run in
parallel, the **sum across files exceeds the run wall-clock**. The per-file
numbers are correct for *relative ranking* (which file is heaviest), which is
what U6/U7 consume. The "run wall-clock" column below is the real elapsed time.
## Per-package run totals (wall-clock, this capture)
| Package | Run wall-clock | Test files | Σ per-file (parallel) | Notes |
|---|---:|---:|---:|---|
| `@fusion/core` | **41.1s** | 264 | 185.2s | default project |
| `@fusion/engine` | **178.7s** | 521 | 273.3s | engine-default + engine-reliability |
| `@runfusion/fusion` (cli) | **48.9s** | 92 | 32.0s | default project |
| `@fusion/dashboard` (api-quality lane) | 46.7s | 58 | — | one curated lane |
| `@fusion/dashboard` (app components-a lane) | 27.6s | 44 | — | one curated lane |
Snapshot (`scripts/test-timings.json`) `capturedAt`: `2026-06-03T23:45:49Z`,
covering 4 packages.
For context, the prior FN-5048 baseline measured core ~26s, engine ~93s,
cli ~14s, dashboard ~360s (full multi-project). These were captured on a
different machine/load; treat the two baselines as independent snapshots, not a
trend line. Engine and core are larger here because the executed test inventory
has grown (engine now 521 files across default+reliability).
## Top-10 slowest files per major package
(Σ per-file wall-clock, bucketed to 100ms in the snapshot.)
### @fusion/core
| File | Σ duration |
|---|---:|
| src/__tests__/agent-store.test.ts | 11.6s |
| src/__tests__/mission-store.test.ts | 10.7s |
| src/__tests__/db.test.ts | 10.1s |
| src/__tests__/task-documents.test.ts | 8.3s |
| src/__tests__/run-audit.test.ts | 6.9s |
| src/__tests__/store-merge-queue.test.ts | 5.2s |
| src/__tests__/mission-integration.test.ts | 4.8s |
| src/__tests__/run-audit.integration.test.ts | 4.6s |
| src/__tests__/plugin-loader.test.ts | 4.5s |
| src/__tests__/mission-factory-parity.integration.test.ts | 4.2s |
### @fusion/engine
| File | Σ duration |
|---|---:|
| src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts | 13.9s |
| src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts | 9.0s |
| src/__tests__/merger-ai.test.ts | 8.7s |
| src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts | 8.4s |
| src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts | 8.4s |
| src/runtimes/__tests__/in-process-runtime.test.ts | 7.8s |
| src/__tests__/reliability-interactions/branch-group-promotion.test.ts | 6.1s |
| src/__tests__/reliability-interactions/integration-worktree-state.test.ts | 4.9s |
| src/__tests__/self-healing-already-merged.real-git.test.ts | 4.9s |
| src/__tests__/branch-conflicts-recovery.test.ts | 4.5s |
### @runfusion/fusion (cli)
| File | Σ duration |
|---|---:|
| src/__tests__/extension.test.ts | 7.0s |
| src/commands/__tests__/init.test.ts | 3.4s |
| src/__tests__/bin.test.ts | 3.2s |
| src/__tests__/extension-task-tools.test.ts | 1.7s |
| src/commands/dashboard-tui/__tests__/app.test.tsx | 1.6s |
| src/__tests__/vitest-workspace-resolution.test.ts | 1.4s |
| src/commands/__tests__/chat.test.ts | 1.3s |
| src/__tests__/research-extension-tools.test.ts | 1.1s |
| src/__tests__/extension-github-tracking.test.ts | 0.5s |
| src/commands/__tests__/dashboard.test.ts | 0.5s |
### @fusion/dashboard (captured curated lanes)
| File | Σ duration |
|---|---:|
| src/__tests__/routes-agents.test.ts | 11.2s |
| src/__tests__/routes-git.test.ts | 9.4s |
| src/__tests__/routes-planning.test.ts | 5.6s |
| app/components/__tests__/FileEditor.test.tsx | 5.1s |
| app/components/__tests__/NewTaskModal.test.tsx | 3.4s |
| app/components/__tests__/ChatView.rooms.test.tsx | 2.8s |
| src/__tests__/routes-github.test.ts | 2.8s |
| src/__tests__/setup-routes.test.ts | 2.6s |
| src/__tests__/routes-secrets-sync.test.ts | 2.5s |
| src/__tests__/websocket.test.ts | 2.1s |
## Cold-start / transform-cost probe (U8 gate input)
`overhead = wallClock − sum(per-file test durations)` for a single cheap test file.
| Package | Probe file | Wall | Test time | Overhead |
|---|---|---:|---:|---:|
| `@fusion/engine` | src/__tests__/pi.test.ts | 1843ms | 23ms | **1820ms** |
| `@fusion/core` | src/__tests__/db.test.ts | 13944ms | 12337ms | 1607ms |
| `@fusion/dashboard` | src/__tests__/sse.test.ts | 1349ms | 24ms | **1325ms** |
| `@runfusion/fusion` (cli) | src/__tests__/bin.test.ts | 6292ms | 5354ms | 938ms |
The cleanest signals are engine and dashboard, where the probe file's own test
time is ~24ms so almost all wall-clock is startup: **~1.3–1.8s of fixed
per-process overhead** (vitest boot + transform + collect + worker spawn). The
engine run breakdown confirms this is dominated by transform (~0.8s) and collect
(~1.0s). The core/cli probes auto-selected heavier files (path-length heuristic,
not runtime), so their overhead figure is conservative but consistent (~0.9–1.6s).
## Conclusion — U8 gate signal
Fixed per-process startup/transform overhead is **~1.3–1.8s per vitest
invocation**. In the inner loop (one or two packages) and full per-package runs
this is a small fraction of total wall-clock (engine 178s, core 41s), so it is
**not the top contributor** for those paths. However, the repo runs **~25
separate vitest processes** across packages, plugins, and the dashboard's 14-lane
chain; at ~1.5s each that is **~35–40s of pure cold-start tax aggregated across a
full CI/`test:full` sweep**, paid on every run with no cross-process sharing in
vitest 3.2.
Read against the U8 gate ("is cold-start/transform cost a top contributor
blocking the targets?"): for single-package inner-loop runs, **no** — wall-clock
is dominated by individual heavy integration tests (engine branch-group/real-git
suites, core stores, dashboard route suites), which U7 triage targets. For the
aggregate full-suite/CI path the cold-start tax is **material but second-order**
(~10% of full-suite wall-clock), making the vitest-4 `fsModuleCache` upgrade a
**worthwhile-but-not-urgent** lever — recommend proceeding with U3 (overhead
trim), U5 (config tuning), U6 (duration sharding), and U7 (slow-test triage)
first, then re-evaluating the U8 gate once those land, since they shrink both the
per-process count and the heavy-test tail that currently dominate.

View File

@@ -0,0 +1,205 @@
/**
* Unit tests for the U1 timing-telemetry aggregation built into
* scripts/ci-test-shard.mjs.
*
* Runner: node --test scripts/__tests__/ci-test-shard-timings.test.mjs
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
bucketDuration,
attributeTestFile,
extractFileDurations,
buildTimingsSnapshot,
writeTimings,
TIMINGS_SNAPSHOT_RELATIVE,
} from "../ci-test-shard.mjs";
const PACKAGES = [
{ name: "@fusion/core", dir: "packages/core" },
{ name: "@fusion/engine", dir: "packages/engine" },
];
function makeReport(projectRoot, files) {
// files: Array<{ rel: string, durationMs: number }>
return {
testResults: files.map(({ rel, durationMs }) => ({
name: path.join(projectRoot, rel),
startTime: 1000,
endTime: 1000 + durationMs,
assertionResults: [],
})),
};
}
function tmpRoot() {
return mkdtempSync(path.join(tmpdir(), "fusion-timings-test-"));
}
test("bucketDuration rounds to nearest 100ms, floors non-zero to one bucket", () => {
assert.equal(bucketDuration(0), 0);
assert.equal(bucketDuration(40), 100); // sub-bucket non-zero floors up
assert.equal(bucketDuration(149), 100);
assert.equal(bucketDuration(150), 200);
assert.equal(bucketDuration(1234), 1200);
assert.equal(bucketDuration(-5), 0);
});
test("attributeTestFile maps absolute paths to owning package, repo-relative", () => {
const root = "/repo";
const got = attributeTestFile("/repo/packages/core/src/__tests__/a.test.ts", PACKAGES, root);
assert.deepEqual(got, { pkg: "@fusion/core", file: "packages/core/src/__tests__/a.test.ts" });
assert.equal(attributeTestFile("/repo/tools/x.test.ts", PACKAGES, root), null);
});
test("extractFileDurations sums per-file durations and tolerates bad rows", () => {
const root = "/repo";
const report = {
testResults: [
{ name: "/repo/packages/core/a.test.ts", startTime: 0, endTime: 250 },
{ name: "/repo/packages/core/a.test.ts", startTime: 250, endTime: 500 }, // same file, summed
{ name: "/repo/packages/engine/b.test.ts", startTime: 0, endTime: 700 },
{ name: 42, startTime: 0, endTime: 1 }, // bad name
{ name: "/repo/packages/core/c.test.ts", startTime: 500, endTime: 100 }, // end<start ignored
{ name: "/repo/outside/d.test.ts", startTime: 0, endTime: 5 }, // unattributable
],
};
const byPkg = extractFileDurations(report, PACKAGES, root);
assert.equal(byPkg.get("@fusion/core").get("packages/core/a.test.ts"), 500);
assert.equal(byPkg.get("@fusion/engine").get("packages/engine/b.test.ts"), 700);
assert.ok(!byPkg.get("@fusion/core").has("packages/core/c.test.ts"));
});
test("buildTimingsSnapshot merges two shard JSON fixtures, sums per file, buckets", () => {
const root = tmpRoot();
try {
const f1 = path.join(root, "s1.json");
const f2 = path.join(root, "s2.json");
writeFileSync(f1, JSON.stringify(makeReport(root, [
{ rel: "packages/core/src/__tests__/a.test.ts", durationMs: 240 },
{ rel: "packages/engine/src/__tests__/b.test.ts", durationMs: 1010 },
])));
writeFileSync(f2, JSON.stringify(makeReport(root, [
// same file as f1 → durations sum across shards before bucketing
{ rel: "packages/core/src/__tests__/a.test.ts", durationMs: 60 },
])));
const snap = buildTimingsSnapshot([f1, f2], { projectRoot: root, packages: PACKAGES, capturedAt: "2026-06-03T00:00:00.000Z" });
assert.equal(snap.capturedAt, "2026-06-03T00:00:00.000Z");
// 240 + 60 = 300 → bucketed to 300
assert.equal(snap.packages["@fusion/core"].files["packages/core/src/__tests__/a.test.ts"], 300);
// 1010 → 1000
assert.equal(snap.packages["@fusion/engine"].files["packages/engine/src/__tests__/b.test.ts"], 1000);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("buildTimingsSnapshot tolerates a corrupt shard file: skips it, keeps others", () => {
const root = tmpRoot();
try {
const good = path.join(root, "good.json");
const bad = path.join(root, "bad.json");
writeFileSync(good, JSON.stringify(makeReport(root, [
{ rel: "packages/core/x.test.ts", durationMs: 300 },
])));
writeFileSync(bad, "{not valid json");
const snap = buildTimingsSnapshot([bad, good, path.join(root, "missing.json")], {
projectRoot: root,
packages: PACKAGES,
capturedAt: "2026-06-03T00:00:00.000Z",
});
assert.equal(snap.packages["@fusion/core"].files["packages/core/x.test.ts"], 300);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("buildTimingsSnapshot omits a zero-test package entirely (no zero entry)", () => {
const root = tmpRoot();
try {
const f = path.join(root, "s.json");
writeFileSync(f, JSON.stringify(makeReport(root, [
{ rel: "packages/core/y.test.ts", durationMs: 200 },
])));
const snap = buildTimingsSnapshot([f], { projectRoot: root, packages: PACKAGES, capturedAt: "2026-06-03T00:00:00.000Z" });
assert.ok(snap.packages["@fusion/core"]);
assert.ok(!("@fusion/engine" in snap.packages));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("writeTimings writes snapshot to scripts/test-timings.json under a project root", () => {
const root = tmpRoot();
try {
const inputDir = path.join(root, ".timings");
mkdirSync(inputDir, { recursive: true });
writeFileSync(path.join(inputDir, "timings-shard1-0.json"), JSON.stringify(makeReport(root, [
{ rel: "packages/core/z.test.ts", durationMs: 500 },
])));
const snapshotPath = path.join(root, TIMINGS_SNAPSHOT_RELATIVE);
const result = writeTimings({
projectRoot: root,
inputDir,
snapshotPath,
packages: PACKAGES,
capturedAt: "2026-06-03T00:00:00.000Z",
});
assert.equal(result.written, true);
const written = JSON.parse(readFileSync(snapshotPath, "utf8"));
assert.equal(written.packages["@fusion/core"].files["packages/core/z.test.ts"], 500);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("writeTimings refuses to overwrite a newer snapshot", () => {
const root = tmpRoot();
try {
const inputDir = path.join(root, ".timings");
mkdirSync(inputDir, { recursive: true });
writeFileSync(path.join(inputDir, "timings-shard1-0.json"), JSON.stringify(makeReport(root, [
{ rel: "packages/core/z.test.ts", durationMs: 500 },
])));
const snapshotPath = path.join(root, "snap.json");
// Existing snapshot dated in the future.
writeFileSync(snapshotPath, JSON.stringify({ capturedAt: "2999-01-01T00:00:00.000Z", packages: { keep: { files: {} } } }));
const result = writeTimings({
projectRoot: root,
inputDir,
snapshotPath,
packages: PACKAGES,
capturedAt: "2026-06-03T00:00:00.000Z",
});
assert.equal(result.written, false);
assert.equal(result.reason, "newer-snapshot");
// Original untouched.
const after = JSON.parse(readFileSync(snapshotPath, "utf8"));
assert.equal(after.capturedAt, "2999-01-01T00:00:00.000Z");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("writeTimings warns and does not write when there are no input files", () => {
const root = tmpRoot();
try {
const result = writeTimings({
projectRoot: root,
inputDir: path.join(root, ".timings-empty"),
snapshotPath: path.join(root, "snap.json"),
});
assert.equal(result.written, false);
assert.equal(result.reason, "no-inputs");
} finally {
rmSync(root, { recursive: true, force: true });
}
});

View File

@@ -27,6 +27,7 @@ import {
cleanupIsolatedHomePath,
knownIsolatedHomeBasenames,
__setCleanupRmSyncForTests,
emitModeDecision,
} from "../test-changed.mjs";
import { mkdirSync, writeFileSync, mkdtempSync, rmSync, existsSync } from "node:fs";
@@ -826,3 +827,33 @@ test("createIsolatedHomeEnv: records raw/realpath basenames in allow-list set",
cleanupIsolatedHomePath(isolatedHome);
});
// ---------------------------------------------------------------------------
// R5: mode-decision telemetry
// ---------------------------------------------------------------------------
test("emitModeDecision: changed plan reports changed-packages reason + package count", () => {
const lines = [];
const line = emitModeDecision({ mode: "changed", packages: ["a", "b", "c"] }, (l) => lines.push(l));
assert.equal(line, "[test-changed] mode=changed reason=changed-packages packages=3");
assert.deepEqual(lines, [line]);
});
test("emitModeDecision: full plan surfaces the decideExecutionPlan reason, packages=0", () => {
assert.equal(
emitModeDecision({ mode: "full", reason: "missing-comparison-base" }, () => {}),
"[test-changed] mode=full reason=missing-comparison-base packages=0",
);
assert.equal(
emitModeDecision({ mode: "full", reason: "shared-infra-changed" }, () => {}),
"[test-changed] mode=full reason=shared-infra-changed packages=0",
);
});
test("emitModeDecision: distinct full reasons round-trip from decideExecutionPlan", () => {
const full = decideExecutionPlan({ forceFullSuite: false, comparisonBase: null });
assert.equal(emitModeDecision(full, () => {}), "[test-changed] mode=full reason=missing-comparison-base packages=0");
const forced = decideExecutionPlan({ forceFullSuite: true });
assert.equal(emitModeDecision(forced, () => {}), "[test-changed] mode=full reason=forced packages=0");
});

View File

@@ -14,7 +14,7 @@
*/
import { spawnSync } from "node:child_process";
import { globSync } from "node:fs";
import { globSync, readFileSync, writeFileSync, readdirSync, mkdirSync, renameSync } from "node:fs";
import { cpus } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -361,7 +361,310 @@ function entryLabel(entry) {
return entry.name;
}
// ---------------------------------------------------------------------------
// Timing telemetry aggregation (U1 / R4)
// ---------------------------------------------------------------------------
/** @type {string} Repo-relative path of the committed timing snapshot. */
export const TIMINGS_SNAPSHOT_RELATIVE = "scripts/test-timings.json";
/** @type {number} Durations are rounded to this bucket (ms) to suppress noise. */
export const DURATION_BUCKET_MS = 100;
/**
* Round a raw duration (ms) to the nearest DURATION_BUCKET_MS, with a floor of
* one bucket for any non-zero duration so sub-bucket files are not lost.
*
* @param {number} durationMs
* @returns {number}
*/
export function bucketDuration(durationMs, bucket = DURATION_BUCKET_MS) {
if (!Number.isFinite(durationMs) || durationMs <= 0) return 0;
const rounded = Math.round(durationMs / bucket) * bucket;
return rounded === 0 ? bucket : rounded;
}
/**
* Map an absolute or repo-relative test-file path to its owning package name,
* using the workspace dir→name table. Returns { pkg, file } where `file` is
* repo-relative, or null when the file is outside any known package.
*
* @param {string} filePath
* @param {Array<{ name: string, dir: string }>} packages
* @param {string} projectRoot
*/
export function attributeTestFile(filePath, packages, projectRoot = process.cwd()) {
const relative = path.isAbsolute(filePath)
? path.relative(projectRoot, filePath)
: filePath;
const normalized = relative.split(path.sep).join("/");
// Longest dir first so nested packages win over their parents.
const sorted = [...packages].sort((a, b) => b.dir.length - a.dir.length);
for (const pkg of sorted) {
if (normalized === pkg.dir || normalized.startsWith(`${pkg.dir}/`)) {
return { pkg: pkg.name, file: normalized };
}
}
return null;
}
/**
* Parse one vitest `--reporter=json` output object and return per-file
* durations attributed to packages. Tolerant of partial/odd shapes.
*
* @param {unknown} report Parsed JSON reporter output.
* @param {Array<{ name: string, dir: string }>} packages
* @param {string} projectRoot
* @returns {Map<string, Map<string, number>>} pkg → (file → durationMs)
*/
export function extractFileDurations(report, packages, projectRoot = process.cwd()) {
const byPackage = new Map();
const results = report && typeof report === "object" ? report.testResults : null;
if (!Array.isArray(results)) return byPackage;
for (const entry of results) {
if (!entry || typeof entry.name !== "string") continue;
const start = Number(entry.startTime);
const end = Number(entry.endTime);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
const attributed = attributeTestFile(entry.name, packages, projectRoot);
if (!attributed) continue;
const { pkg, file } = attributed;
if (!byPackage.has(pkg)) byPackage.set(pkg, new Map());
const files = byPackage.get(pkg);
files.set(file, (files.get(file) ?? 0) + (end - start));
}
return byPackage;
}
/**
* Build a fresh timing snapshot object from a set of per-shard JSON reporter
* files. Missing/corrupt files are warned about and skipped (exit 0 path).
*
* @param {string[]} outputFiles Absolute paths to vitest JSON reporter outputs.
* @param {{ projectRoot?: string, capturedAt?: string, packages?: Array<{name:string,dir:string}> }} [options]
* @returns {{ capturedAt: string, packages: Record<string, { files: Record<string, number> }> }}
*/
export function buildTimingsSnapshot(outputFiles, options = {}) {
const projectRoot = options.projectRoot ?? process.cwd();
const packages = options.packages ?? listWorkspaceTestPackages({ projectRoot });
const capturedAt = options.capturedAt ?? new Date().toISOString();
/** @type {Map<string, Map<string, number>>} */
const merged = new Map();
for (const outputFile of outputFiles) {
let report;
try {
report = JSON.parse(readFileSync(outputFile, "utf8"));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[ci-test-shard] skipping unreadable timing file ${outputFile}: ${message}`);
continue;
}
const perFile = extractFileDurations(report, packages, projectRoot);
for (const [pkg, files] of perFile) {
if (!merged.has(pkg)) merged.set(pkg, new Map());
const target = merged.get(pkg);
for (const [file, duration] of files) {
target.set(file, (target.get(file) ?? 0) + duration);
}
}
}
const packagesOut = {};
for (const pkg of [...merged.keys()].sort()) {
const files = merged.get(pkg);
if (files.size === 0) continue; // zero-test package → no entry
const filesOut = {};
for (const file of [...files.keys()].sort()) {
filesOut[file] = bucketDuration(files.get(file));
}
packagesOut[pkg] = { files: filesOut };
}
return { capturedAt, packages: packagesOut };
}
/**
* Read an existing snapshot (or null when absent/corrupt).
* @param {string} snapshotPath
*/
export function readTimingsSnapshot(snapshotPath) {
try {
const parsed = JSON.parse(readFileSync(snapshotPath, "utf8"));
if (parsed && typeof parsed === "object" && typeof parsed.capturedAt === "string") {
return parsed;
}
return null;
} catch {
return null;
}
}
/**
* Discover candidate vitest JSON reporter output files in a directory.
* Looks for files matching `*timings*.json` (the convention CI shards write).
*
* @param {string} dir
* @returns {string[]} absolute paths
*/
export function discoverTimingFiles(dir) {
let entries = [];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isFile() && /timings.*\.json$/.test(e.name))
.map((e) => path.join(dir, e.name))
.sort();
}
/**
* Merge per-shard JSON reporter outputs into the committed snapshot.
* Refuses to overwrite a snapshot whose capturedAt is newer than this run's.
*
* @param {{ inputDir?: string, inputs?: string[], projectRoot?: string, snapshotPath?: string, capturedAt?: string }} [options]
* @returns {{ written: boolean, snapshot: object, reason?: string }}
*/
export function writeTimings(options = {}) {
const projectRoot = options.projectRoot ?? process.cwd();
const snapshotPath = options.snapshotPath ?? path.join(projectRoot, TIMINGS_SNAPSHOT_RELATIVE);
const inputs = options.inputs
?? discoverTimingFiles(options.inputDir ?? path.join(projectRoot, ".timings"));
if (inputs.length === 0) {
console.warn("[ci-test-shard] no timing input files found; snapshot unchanged.");
return { written: false, snapshot: readTimingsSnapshot(snapshotPath) ?? null, reason: "no-inputs" };
}
const capturedAt = options.capturedAt ?? new Date().toISOString();
const snapshot = buildTimingsSnapshot(inputs, { projectRoot, capturedAt, packages: options.packages });
if (Object.keys(snapshot.packages).length === 0) {
console.warn("[ci-test-shard] timing inputs yielded zero packages; snapshot unchanged.");
return { written: false, snapshot, reason: "empty" };
}
const existing = readTimingsSnapshot(snapshotPath);
if (existing && new Date(existing.capturedAt).getTime() > new Date(capturedAt).getTime()) {
console.warn(
`[ci-test-shard] existing snapshot (${existing.capturedAt}) is newer than this run (${capturedAt}); refusing to overwrite.`,
);
return { written: false, snapshot: existing, reason: "newer-snapshot" };
}
mkdirSync(path.dirname(snapshotPath), { recursive: true });
const tmp = `${snapshotPath}.tmp.${process.pid}`;
writeFileSync(tmp, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
renameSync(tmp, snapshotPath);
const pkgCount = Object.keys(snapshot.packages).length;
console.log(`[ci-test-shard] wrote ${TIMINGS_SNAPSHOT_RELATIVE} (${pkgCount} packages, capturedAt ${capturedAt}).`);
return { written: true, snapshot };
}
/**
* Cold-start probe: measure per-package vitest startup-to-first-test overhead.
* Runs `vitest run <oneCheapFile>` with the JSON reporter, then estimates
* overhead = totalWallClockMs − sum(per-file test durations).
*
* @param {string} packageName
* @param {{ projectRoot?: string, env?: NodeJS.ProcessEnv, testFile?: string }} [options]
* @returns {{ packageName: string, wallClockMs: number, testDurationMs: number, overheadMs: number, testFile: string|null }}
*/
export function runColdStartProbe(packageName, options = {}) {
const projectRoot = options.projectRoot ?? process.cwd();
const env = options.env ?? process.env;
const packages = listWorkspaceTestPackages({ projectRoot });
const pkg = packages.find((p) => p.name === packageName);
if (!pkg) {
throw new Error(`[ci-test-shard] cold-start-probe: unknown package "${packageName}"`);
}
// Pick the cheapest (smallest) test file as the probe target unless given.
let testFile = options.testFile ?? null;
if (!testFile) {
const candidates = globSync("**/__tests__/**/*.test.{ts,tsx,mjs}", {
cwd: path.join(projectRoot, pkg.dir),
nodir: true,
exclude: (p) => p.startsWith("dist/") || p.includes("/dist/") || /\.slow\./.test(p),
});
testFile = candidates.sort((a, b) => a.length - b.length)[0] ?? null;
}
if (!testFile) {
throw new Error(`[ci-test-shard] cold-start-probe: no test file found for ${packageName}`);
}
const outputFile = path.join(projectRoot, ".timings", `coldstart-${packageName.replace(/[^a-z0-9]+/gi, "-")}.json`);
mkdirSync(path.dirname(outputFile), { recursive: true });
const start = Date.now();
// NB: no `--` before flags (cac mis-parse); mirror the virtual-shard pattern.
spawnSync(
"pnpm",
[
"--filter",
packageName,
"exec",
"vitest",
"run",
testFile,
"--reporter=dot",
"--reporter=json",
`--outputFile.json=${outputFile}`,
],
{ cwd: projectRoot, stdio: "inherit", env },
);
const wallClockMs = Date.now() - start;
let testDurationMs = 0;
const perFile = (() => {
try {
return extractFileDurations(JSON.parse(readFileSync(outputFile, "utf8")), packages, projectRoot);
} catch {
return new Map();
}
})();
for (const files of perFile.values()) {
for (const duration of files.values()) testDurationMs += duration;
}
return {
packageName,
testFile,
wallClockMs,
testDurationMs: Math.round(testDurationMs),
overheadMs: Math.max(0, Math.round(wallClockMs - testDurationMs)),
};
}
export function main(argv = process.argv.slice(2), env = process.env) {
if (argv.includes("--write-timings")) {
const dirIdx = argv.indexOf("--inputs-dir");
const inputDir = dirIdx >= 0 ? argv[dirIdx + 1] : undefined;
writeTimings({ inputDir });
return;
}
if (argv.includes("--cold-start-probe")) {
const pkgIdx = argv.indexOf("--cold-start-probe");
const packageName = argv[pkgIdx + 1];
if (!packageName || packageName.startsWith("--")) {
throw new Error("Usage: node scripts/ci-test-shard.mjs --cold-start-probe <package-name>");
}
const result = runColdStartProbe(packageName, { env });
console.log(
`[ci-test-shard] cold-start probe ${result.packageName}: wall=${result.wallClockMs}ms ` +
`tests=${result.testDurationMs}ms overhead=${result.overheadMs}ms (file ${result.testFile})`,
);
console.log(JSON.stringify(result));
return;
}
const { shard, total } = parseShardArgs(argv, env);
const shardEntries = selectShardPackages(listWorkspaceTestPackages(), shard, total);
@@ -382,6 +685,20 @@ export function main(argv = process.argv.slice(2), env = process.env) {
run("pnpm", ["sync:fusion-skill:check"], { env: shardEnv });
ensureTestArtifacts(process.cwd());
// Per-shard timing telemetry (U1 / R4): each test invocation also emits a
// vitest JSON reporter file under .timings/. These are uploaded as CI
// artifacts and consumed by `--write-timings` to refresh the snapshot.
// Reporters are appended as CLI flags following the same no-`--` quirk as the
// virtual `--shard` forwarding; package `test` scripts already pass
// `--reporter=dot`, and vitest accepts multiple `--reporter` flags.
const timingsDir = path.join(process.cwd(), ".timings");
mkdirSync(timingsDir, { recursive: true });
let invocationIndex = 0;
const timingFlags = () => {
const outputFile = path.join(timingsDir, `timings-shard${shard}-${invocationIndex++}.json`);
return ["--reporter=json", `--outputFile.json=${outputFile}`];
};
// Group entries: plain packages run together in one pnpm invocation;
// virtual (sharded) entries each get their own vitest --shard invocation.
const plain = shardEntries.filter((e) => !e.shardCount);
@@ -389,7 +706,7 @@ export function main(argv = process.argv.slice(2), env = process.env) {
if (plain.length > 0) {
const filters = plain.flatMap((e) => ["--filter", e.name]);
run("pnpm", [...filters, "test"], { env: shardEnv });
run("pnpm", [...filters, "test", ...timingFlags()], { env: shardEnv });
}
for (const entry of virtual) {
@@ -402,7 +719,7 @@ export function main(argv = process.argv.slice(2), env = process.env) {
// silently disabled and every shard runs the full suite.
run(
"pnpm",
["--filter", entry.name, "test", `--shard=${entry.shardIndex}/${entry.shardCount}`],
["--filter", entry.name, "test", `--shard=${entry.shardIndex}/${entry.shardCount}`, ...timingFlags()],
{ env: shardEnv },
);
}

View File

@@ -840,6 +840,25 @@ export function decideExecutionPlan({
};
}
/**
* R5: Emit one structured line describing why the inner loop chose its mode.
* Shape: `[test-changed] mode=<changed|full> reason=<reason> packages=<n>`.
*
* For changed plans the reason is `changed-packages`; for full plans the
* reason mirrors decideExecutionPlan's reason field.
*
* @param {{ mode: string, reason?: string, packages?: string[] }} plan
* @param {(line: string) => void} [log]
* @returns {string} the emitted line (for testing)
*/
export function emitModeDecision(plan, log = console.log) {
const reason = plan.mode === "changed" ? (plan.reason ?? "changed-packages") : (plan.reason ?? "unknown");
const packageCount = plan.mode === "changed" ? (plan.packages?.length ?? 0) : 0;
const line = `[test-changed] mode=${plan.mode} reason=${reason} packages=${packageCount}`;
log(line);
return line;
}
export function normalizeForwardedArgs(argv) {
const normalized = [];
@@ -864,6 +883,26 @@ export function main(argv = process.argv.slice(2)) {
const forwardedArgs = normalizeForwardedArgs(argv);
// Dry mode-decision probe (R5): compute and print the mode/reason line without
// running tests. Used by `node scripts/test-changed.mjs --print-mode`.
if (argv.includes("--print-mode") || argv.includes("--help")) {
const baseBranch = getBaseBranch();
const comparisonBase = detectComparisonBase(baseBranch);
const changedFiles = comparisonBase ? changedFilesSince(comparisonBase) : null;
const workspacePackages = listWorkspacePackageInfos();
const packageNameByDir = listWorkspacePackages(workspacePackages);
const reverseDependencyMap = buildReverseDependencyMap(workspacePackages);
const plan = decideExecutionPlan({
forceFullSuite,
comparisonBase,
changedFiles,
packageNameByDir,
reverseDependencyMap,
});
emitModeDecision(plan);
return;
}
run("pnpm", ["sync:fusion-skill:check"]);
ensureTestArtifacts(rootDir);
@@ -891,6 +930,9 @@ export function main(argv = process.argv.slice(2)) {
reverseDependencyMap,
});
// R5: structured mode-decision telemetry so fast-path hit rate is observable.
emitModeDecision(plan);
if (plan.mode === "full") {
if (plan.reason === "missing-comparison-base") {
console.log(`[test-changed] could not resolve merge-base with ${baseBranch}; running full suite.`);

1000
scripts/test-timings.json Normal file

File diff suppressed because it is too large Load Diff