FN-6905: add build preflight to velocity measurement

Add a non-measured build setup step so velocity baselines do not misclassify missing dist artifacts as lane time.

- Run a default `pnpm build` preflight before measured test velocity lanes, with an explicit skip flag for pre-built CI.
- Add injectable command-runner coverage for preflight ordering, failure reporting, and skip behavior.
- Document the preflight behavior in the generated baseline report and testing docs.

Files changed:
 docs/test-velocity-baseline.md                    |   2 +
 docs/testing.md                                   |   4 +-
 scripts/__tests__/test-velocity-baseline.test.mjs | 159 ++++++++++++++++++++++
 scripts/test-velocity-baseline.mjs                |  38 +++++-
 4 files changed, 195 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-6905

Fusion-Task-Lineage: 3016a542-4395-4d06-9f2e-1a91f4939e07
This commit is contained in:
gsxdsm
2026-06-21 22:29:13 -07:00
parent cf6718f3c1
commit 8f4897d3aa
4 changed files with 195 additions and 8 deletions

View File

@@ -85,6 +85,8 @@ FN-6612 weekly test velocity: gate 8.3s (+2.9s), boot smoke unavailable (n/a), p
pnpm test:velocity -- --measure --write-report
```
In measure mode, the script runs a non-measured `pnpm build` preflight before timing `pnpm test:gate`, `pnpm smoke:boot`, or `pnpm test`. The preflight time is setup only and is excluded from lane metrics; if it fails, the Measurement failures section records `Build preflight (pnpm build)` as the reason. Use `--skip-build-preflight` only when the workspace is already built by CI.
Report-only regeneration is cheap and does not run any suite:
```bash

View File

@@ -297,7 +297,9 @@ FN-6612 tracks feedback-loop velocity as signal-per-second, not as a new blockin
pnpm test:velocity -- --measure --write-report
```
The script runs `pnpm test:gate`, `pnpm smoke:boot`, and `pnpm test` with bounded async process supervision, then appends the measured row to `scripts/test-velocity-history.json` and rewrites the postable artifact at `docs/test-velocity-baseline.md`. It reads the slowest 20 files from the committed `scripts/test-timings.json` snapshot and the flake/quarantine count plus 14-day deletion-clock buckets directly from `scripts/lib/test-quarantine.json`; do not run the full suite just to populate the slowest-file table.
In `--measure` mode, the script first runs a non-measured build preflight (`pnpm build`) so the built CLI and workspace dist artifacts exist before any lane is timed. The preflight duration is setup cost and is excluded from `pnpm test:gate`, `pnpm smoke:boot`, and `pnpm test` history fields; if the preflight fails, the report records `Build preflight (pnpm build)` in Measurement failures instead of fabricating lane times or letting boot smoke appear unavailable. Use `--skip-build-preflight` only in CI or another environment that has already built the workspace.
After the preflight, the script runs `pnpm test:gate`, `pnpm smoke:boot`, and `pnpm test` with bounded async process supervision, then appends the measured row to `scripts/test-velocity-history.json` and rewrites the postable artifact at `docs/test-velocity-baseline.md`. It reads the slowest 20 files from the committed `scripts/test-timings.json` snapshot and the flake/quarantine count plus 14-day deletion-clock buckets directly from `scripts/lib/test-quarantine.json`; do not run the full suite just to populate the slowest-file table.
Use cheap report-only regeneration when measurements already exist:

View File

@@ -1,12 +1,25 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
main,
measureCommands,
readQuarantineCount,
renderReport,
topSlowestFiles,
} from "../test-velocity-baseline.mjs";
function nullStream() {
return { write() {} };
}
function tempRoot() {
return mkdtempSync(path.join(tmpdir(), "fusion-test-velocity-"));
}
function makeTimings(count = 25) {
const files = {};
for (let index = 0; index < count; index += 1) {
@@ -72,6 +85,152 @@ describe("readQuarantineCount", () => {
});
});
describe("measureCommands", () => {
it("runs the build preflight before measured lanes and excludes setup time from lane ms", async () => {
const calls = [];
let built = false;
const result = await measureCommands({
timeoutMs: 10_000,
cwd: "/repo",
stdout: nullStream(),
stderr: nullStream(),
commandRunner: async (measurement) => {
calls.push(measurement.command === "pnpm" ? `pnpm ${measurement.args.join(" ")}` : measurement.label);
if (measurement.args[0] === "build") {
built = true;
return { ms: 50_000, failure: null };
}
if (measurement.args[0] === "smoke:boot") {
assert.equal(built, true, "boot smoke should only run after the build preflight creates CLI dist");
return { ms: 406, failure: null };
}
if (measurement.args[0] === "test") return { ms: 7_300, failure: null };
return { ms: 12_000, failure: null };
},
});
assert.deepEqual(calls, ["pnpm build", "pnpm test:gate", "pnpm smoke:boot", "pnpm test"]);
assert.equal(result.bootSmokeMs, 406);
assert.equal(result.testMs, 7_300);
assert.deepEqual(result.measurementFailures, []);
});
it("records preflight failure instead of silently attributing missing build output to boot smoke", async () => {
const calls = [];
const result = await measureCommands({
timeoutMs: 10_000,
cwd: "/repo",
stdout: nullStream(),
stderr: nullStream(),
commandRunner: async (measurement) => {
calls.push(`pnpm ${measurement.args.join(" ")}`);
return {
ms: null,
failure: { label: measurement.label, status: "exit 1 after 400ms" },
};
},
});
assert.deepEqual(calls, ["pnpm build"]);
assert.equal(result.gateMs, undefined);
assert.equal(result.bootSmokeMs, undefined);
assert.equal(result.testMs, undefined);
assert.deepEqual(result.measurementFailures, [
{ label: "Build preflight (`pnpm build`)", status: "exit 1 after 400ms" },
]);
});
it("honors --skip-build-preflight-style opt out while still measuring lanes", async () => {
const calls = [];
const result = await measureCommands({
timeoutMs: 10_000,
cwd: "/repo",
stdout: nullStream(),
stderr: nullStream(),
skipBuildPreflight: true,
commandRunner: async (measurement) => {
calls.push(`pnpm ${measurement.args.join(" ")}`);
return { ms: 100 + calls.length, failure: null };
},
});
assert.deepEqual(calls, ["pnpm test:gate", "pnpm smoke:boot", "pnpm test"]);
assert.equal(result.gateMs, 101);
assert.equal(result.bootSmokeMs, 102);
assert.equal(result.testMs, 103);
});
});
describe("main", () => {
it("keeps report-only regeneration cheap by invoking neither preflight nor suites", async () => {
const rootDir = tempRoot();
try {
const exitCode = await main([], {
rootDir,
stdout: nullStream(),
stderr: nullStream(),
now: new Date("2026-06-21T12:00:00.000Z"),
commandRunner: async (measurement) => {
throw new Error(`unexpected command: ${measurement.label}`);
},
});
assert.equal(exitCode, 0);
const report = readFileSync(path.join(rootDir, "docs/test-velocity-baseline.md"), "utf8");
assert.match(report, /Report-only regeneration is cheap and does not run any suite/);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
it("honors --skip-build-preflight while still measuring lanes", async () => {
const rootDir = tempRoot();
const calls = [];
try {
const exitCode = await main(["--measure", "--write-report", "--skip-build-preflight"], {
rootDir,
stdout: nullStream(),
stderr: nullStream(),
now: new Date("2026-06-21T12:00:00.000Z"),
commandRunner: async (measurement) => {
calls.push(`pnpm ${measurement.args.join(" ")}`);
return { ms: 1_000 * calls.length, failure: null };
},
});
assert.equal(exitCode, 0);
assert.deepEqual(calls, ["pnpm test:gate", "pnpm smoke:boot", "pnpm test"]);
const report = readFileSync(path.join(rootDir, "docs/test-velocity-baseline.md"), "utf8");
assert.match(report, /Boot smoke wall-time \(`pnpm smoke:boot`\) \| 2\.0s/);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
it("records preflight failure in the generated measurement failures section", async () => {
const rootDir = tempRoot();
try {
const exitCode = await main(["--measure", "--write-report"], {
rootDir,
stdout: nullStream(),
stderr: nullStream(),
now: new Date("2026-06-21T12:00:00.000Z"),
commandRunner: async (measurement) => ({
ms: null,
failure: { label: measurement.label, status: "exit 2 after 1.0s" },
}),
});
assert.equal(exitCode, 0);
const report = readFileSync(path.join(rootDir, "docs/test-velocity-baseline.md"), "utf8");
assert.match(report, /- Build preflight \(`pnpm build`\): exit 2 after 1\.0s/);
assert.doesNotMatch(report, /Boot smoke \(`pnpm smoke:boot`\):/);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
});
describe("renderReport", () => {
it("includes metrics, slowest rows, quarantine count, and previous-run deltas", () => {
const report = renderReport({

View File

@@ -16,6 +16,13 @@ export const DEFAULT_REPORT_PATH = "docs/test-velocity-baseline.md";
export const DEFAULT_MEASURE_TIMEOUT_MS = 10 * 60 * 1000;
export const DELETION_CLOCK_DAYS = 14;
const BUILD_PREFLIGHT_COMMAND = {
key: "buildPreflightMs",
label: "Build preflight (`pnpm build`)",
command: "pnpm",
args: ["build"],
};
const MEASURE_COMMANDS = [
{ key: "gateMs", label: "Merge gate (`pnpm test:gate`)", command: "pnpm", args: ["test:gate"] },
{ key: "bootSmokeMs", label: "Boot smoke (`pnpm smoke:boot`)", command: "pnpm", args: ["smoke:boot"] },
@@ -172,7 +179,7 @@ export function renderReport({ gateMs, bootSmokeMs, testMs, slowest = [], quaran
? `| Previous | ${previous.capturedAt ?? "unknown"} | ${formatDuration(previous.gateMs)} | ${formatDuration(previous.bootSmokeMs)} | ${formatDuration(previous.testMs)} | ${previous.quarantineCount ?? "n/a"} |\n| Latest | ${latest.capturedAt} | ${formatDuration(latest.gateMs)} | ${formatDuration(latest.bootSmokeMs)} | ${formatDuration(latest.testMs)} | ${latest.quarantineCount} |\n| Delta | — | ${delta(latest, previous, "gateMs")} | ${delta(latest, previous, "bootSmokeMs")} | ${delta(latest, previous, "testMs")} | ${trendCell(latest.quarantineCount, previous.quarantineCount)} |`
: `| Previous | _(seed baseline)_ | — | — | — | — |\n| Latest | ${latest.capturedAt} | ${formatDuration(latest.gateMs)} | ${formatDuration(latest.bootSmokeMs)} | ${formatDuration(latest.testMs)} | ${latest.quarantineCount} |\n| Delta | — | n/a | n/a | n/a | n/a |`;
return `# Test velocity baseline\n\n> Weekly FN-6612 signal-per-second baseline. Measure and report feedback-loop velocity; do **not** add slow tests or wire this report into blocking PR checks. The merge gate remains the existing thin Lint, Typecheck, Build, and Gate path.\n\n## Latest baseline\n\n- Cycle: **${cycle}**\n- Captured at: **${latest.capturedAt}**\n- Timing snapshot: \`${DEFAULT_TIMINGS_PATH}\`${timingSnapshotCapturedAt ? ` captured at **${timingSnapshotCapturedAt}**` : ""}\n- Quarantine ledger: \`${DEFAULT_QUARANTINE_PATH}\`\n\n## Metrics\n\n| Metric | Current | Delta vs previous |\n|---|---:|---:|\n${renderMetricRow("Merge gate wall-time (`pnpm test:gate`)", latest, previous, "gateMs")}\n${renderMetricRow("Boot smoke wall-time (`pnpm smoke:boot`)", latest, previous, "bootSmokeMs")}\n${renderMetricRow("Changed-only test wall-time (`pnpm test`)", latest, previous, "testMs")}\n| Quarantine / flake count | ${latest.quarantineCount} | ${trendCell(latest.quarantineCount, previous?.quarantineCount)} |\n| Deletion-due quarantines | ${quarantine?.deletionDueCount ?? 0} | n/a |\n\n## Measurement failures\n\n${failures}\n\n## Slowest 20 test files\n\n| Rank | File | Package | Duration |\n|---:|---|---|---:|\n${slowRows || "| — | — | — | — |"}\n\n## Quarantine age buckets\n\n| Age bucket | Count |\n|---|---:|\n| 0-6 days | ${quarantine?.byAgeBucket?.["0-6d"] ?? 0} |\n| 7-13 days | ${quarantine?.byAgeBucket?.["7-13d"] ?? 0} |\n| deletion due (>=14 days) | ${quarantine?.byAgeBucket?.deletionDue ?? 0} |\n| unknown/future | ${quarantine?.byAgeBucket?.unknown ?? 0} |\n\n### Deletion-due entries\n\n| File | Quarantined at | Age (days) |\n|---|---:|---:|\n${dueRows || "| — | — | — |"}\n\n## Before / after trend\n\n| Row | Captured at | Gate | Boot smoke | \`pnpm test\` | Quarantine count |\n|---|---|---:|---:|---:|---:|\n${previousRows}\n\n_Future weekly rows append to \`${DEFAULT_HISTORY_PATH}\`; compare the latest row against the previous row before posting to #leads._\n\n## Post to #leads\n\n\`\`\`text\nFN-6612 weekly test velocity: gate ${formatDuration(latest.gateMs)} (${delta(latest, previous, "gateMs")}), boot smoke ${formatDuration(latest.bootSmokeMs)} (${delta(latest, previous, "bootSmokeMs")}), pnpm test ${formatDuration(latest.testMs)} (${delta(latest, previous, "testMs")}), quarantine ledger ${latest.quarantineCount} (${trendCell(latest.quarantineCount, previous?.quarantineCount)}). Slowest file: ${slowest[0]?.file ?? "none"} at ${formatDuration(slowest[0]?.ms)}. Deletion-due quarantines: ${quarantine?.deletionDueCount ?? 0}.\n\`\`\`\n\n## How to refresh\n\n\`\`\`bash\npnpm test:velocity -- --measure --write-report\n\`\`\`\n\nReport-only regeneration is cheap and does not run any suite:\n\n\`\`\`bash\npnpm test:velocity\n\`\`\`\n`;
return `# Test velocity baseline\n\n> Weekly FN-6612 signal-per-second baseline. Measure and report feedback-loop velocity; do **not** add slow tests or wire this report into blocking PR checks. The merge gate remains the existing thin Lint, Typecheck, Build, and Gate path.\n\n## Latest baseline\n\n- Cycle: **${cycle}**\n- Captured at: **${latest.capturedAt}**\n- Timing snapshot: \`${DEFAULT_TIMINGS_PATH}\`${timingSnapshotCapturedAt ? ` captured at **${timingSnapshotCapturedAt}**` : ""}\n- Quarantine ledger: \`${DEFAULT_QUARANTINE_PATH}\`\n\n## Metrics\n\n| Metric | Current | Delta vs previous |\n|---|---:|---:|\n${renderMetricRow("Merge gate wall-time (`pnpm test:gate`)", latest, previous, "gateMs")}\n${renderMetricRow("Boot smoke wall-time (`pnpm smoke:boot`)", latest, previous, "bootSmokeMs")}\n${renderMetricRow("Changed-only test wall-time (`pnpm test`)", latest, previous, "testMs")}\n| Quarantine / flake count | ${latest.quarantineCount} | ${trendCell(latest.quarantineCount, previous?.quarantineCount)} |\n| Deletion-due quarantines | ${quarantine?.deletionDueCount ?? 0} | n/a |\n\n## Measurement failures\n\n${failures}\n\n## Slowest 20 test files\n\n| Rank | File | Package | Duration |\n|---:|---|---|---:|\n${slowRows || "| — | — | — | — |"}\n\n## Quarantine age buckets\n\n| Age bucket | Count |\n|---|---:|\n| 0-6 days | ${quarantine?.byAgeBucket?.["0-6d"] ?? 0} |\n| 7-13 days | ${quarantine?.byAgeBucket?.["7-13d"] ?? 0} |\n| deletion due (>=14 days) | ${quarantine?.byAgeBucket?.deletionDue ?? 0} |\n| unknown/future | ${quarantine?.byAgeBucket?.unknown ?? 0} |\n\n### Deletion-due entries\n\n| File | Quarantined at | Age (days) |\n|---|---:|---:|\n${dueRows || "| — | — | — |"}\n\n## Before / after trend\n\n| Row | Captured at | Gate | Boot smoke | \`pnpm test\` | Quarantine count |\n|---|---|---:|---:|---:|---:|\n${previousRows}\n\n_Future weekly rows append to \`${DEFAULT_HISTORY_PATH}\`; compare the latest row against the previous row before posting to #leads._\n\n## Post to #leads\n\n\`\`\`text\nFN-6612 weekly test velocity: gate ${formatDuration(latest.gateMs)} (${delta(latest, previous, "gateMs")}), boot smoke ${formatDuration(latest.bootSmokeMs)} (${delta(latest, previous, "bootSmokeMs")}), pnpm test ${formatDuration(latest.testMs)} (${delta(latest, previous, "testMs")}), quarantine ledger ${latest.quarantineCount} (${trendCell(latest.quarantineCount, previous?.quarantineCount)}). Slowest file: ${slowest[0]?.file ?? "none"} at ${formatDuration(slowest[0]?.ms)}. Deletion-due quarantines: ${quarantine?.deletionDueCount ?? 0}.\n\`\`\`\n\n## How to refresh\n\n\`\`\`bash\npnpm test:velocity -- --measure --write-report\n\`\`\`\n\nIn measure mode, the script runs a non-measured \`pnpm build\` preflight before timing \`pnpm test:gate\`, \`pnpm smoke:boot\`, or \`pnpm test\`. The preflight time is setup only and is excluded from lane metrics; if it fails, the Measurement failures section records \`Build preflight (pnpm build)\` as the reason. Use \`--skip-build-preflight\` only when the workspace is already built by CI.\n\nReport-only regeneration is cheap and does not run any suite:\n\n\`\`\`bash\npnpm test:velocity\n\`\`\`\n`;
}
function historyEntries(history) {
@@ -195,13 +202,14 @@ function createEntry({ capturedAt = new Date().toISOString(), gateMs = null, boo
}
function parseArgs(argv) {
const args = { measure: false, writeReport: false, reportOnly: true, timeoutMs: DEFAULT_MEASURE_TIMEOUT_MS, help: false };
const args = { measure: false, writeReport: false, reportOnly: true, timeoutMs: DEFAULT_MEASURE_TIMEOUT_MS, help: false, skipBuildPreflight: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") continue;
else if (arg === "--measure") args.measure = true;
else if (arg === "--write-report") args.writeReport = true;
else if (arg === "--report-only") args.reportOnly = true;
else if (arg === "--skip-build-preflight" || arg === "--no-build-preflight") args.skipBuildPreflight = true;
else if (arg === "--timeout-ms") args.timeoutMs = Number(argv[++index]);
else if (arg === "--help" || arg === "-h") args.help = true;
else throw new Error(`Unknown argument: ${arg}`);
@@ -248,11 +256,27 @@ async function timeCommand({ command, args, label, timeoutMs, cwd, stdout, stder
});
}
async function measureCommands({ timeoutMs, cwd, stdout, stderr }) {
/*
FNXC:TestVelocityBaseline 2026-06-21-00:00:
FN-6905 needs seam-based orchestration tests for command ordering and lane timing without running real builds or suites. Keep production behavior on the default `timeCommand` path while allowing tests to inject a deterministic command runner.
FNXC:TestVelocityBaseline 2026-06-21-00:07:
Clean-worktree velocity measurement must not let missing CLI dist make boot smoke look unavailable or push setup cost into `pnpm test`. Run `pnpm build` as non-measured setup before timed lanes, allow explicit opt-out for pre-built CI, and record preflight failure as the real measurement failure instead of fabricating lane timings.
*/
export async function measureCommands({ timeoutMs, cwd, stdout, stderr, commandRunner = timeCommand, skipBuildPreflight = false }) {
const results = {};
const failures = [];
if (!skipBuildPreflight) {
const preflight = await commandRunner({ ...BUILD_PREFLIGHT_COMMAND, timeoutMs, cwd, stdout, stderr });
if (preflight.failure) {
failures.push(preflight.failure);
return { ...results, measurementFailures: failures };
}
}
for (const measurement of MEASURE_COMMANDS) {
const result = await timeCommand({ ...measurement, timeoutMs, cwd, stdout, stderr });
const result = await commandRunner({ ...measurement, timeoutMs, cwd, stdout, stderr });
results[measurement.key] = result.ms;
if (result.failure) failures.push(result.failure);
}
@@ -273,7 +297,7 @@ function renderFromEntry(entry, previous, quarantine) {
});
}
export async function main(argv = process.argv.slice(2), { rootDir = repoRoot, stdout = process.stdout, stderr = process.stderr, now = new Date() } = {}) {
export async function main(argv = process.argv.slice(2), { rootDir = repoRoot, stdout = process.stdout, stderr = process.stderr, now = new Date(), commandRunner = timeCommand } = {}) {
let args;
try {
args = parseArgs(argv);
@@ -283,7 +307,7 @@ export async function main(argv = process.argv.slice(2), { rootDir = repoRoot, s
}
if (args.help) {
stdout.write("Usage: node scripts/test-velocity-baseline.mjs [--measure] [--write-report] [--report-only] [--timeout-ms <ms>]\n");
stdout.write("Usage: node scripts/test-velocity-baseline.mjs [--measure] [--write-report] [--report-only] [--skip-build-preflight] [--timeout-ms <ms>]\n");
return 0;
}
@@ -295,7 +319,7 @@ export async function main(argv = process.argv.slice(2), { rootDir = repoRoot, s
const slowest = topSlowestFiles(timings, 20);
if (args.measure) {
const measured = await measureCommands({ timeoutMs: args.timeoutMs, cwd: rootDir, stdout, stderr });
const measured = await measureCommands({ timeoutMs: args.timeoutMs, cwd: rootDir, stdout, stderr, commandRunner, skipBuildPreflight: args.skipBuildPreflight });
const entry = createEntry({
capturedAt: now.toISOString(),
gateMs: measured.gateMs,