FN-9139: add bounded PostgreSQL pre-admission measurement
Establish an inert PostgreSQL setup signal and repeatable evidence tooling without changing harness behavior. - add explicit setup participation semantics and inertness coverage - survey Vitest setup boundaries with isolated report-only fixtures - bound interleaved campaigns by process group and campaign deadline - reject missing backend samples and enable candidate diagnostics - document the rejected boundary result and successor protocol Files changed: .../test-failures/postgres-ddl-admission-bound.md | 15 ++ docs/testing.md | 10 ++ packages/core/package.json | 2 +- .../src/__test-utils__/pg-setup-participation.ts | 22 +++ .../src/__tests__/pg-setup-participation.test.ts | 21 +++ .../__tests__/vitest-setup-pg-inertness.test.ts | 10 ++ packages/core/vitest.pg.config.ts | 10 ++ .../__tests__/pg-preadmission-campaign.test.mjs | 63 +++++++ scripts/__tests__/pg-setup-boundary-probe.test.mjs | 92 ++++++++++ scripts/pg-preadmission-campaign.mjs | 194 +++++++++++++++++++++ scripts/pg-setup-boundary-probe.mjs | 182 +++++++++++++++++++ 11 files changed, 620 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-9139 Fusion-Task-Lineage: 2cf8ccbf-37f9-4fdc-8e8f-326df823e1cd Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
63
scripts/__tests__/pg-preadmission-campaign.test.mjs
Normal file
63
scripts/__tests__/pg-preadmission-campaign.test.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DEFAULT_CAMPAIGN_TIMEOUT_MS,
|
||||
DEFAULT_RUN_TIMEOUT_MS,
|
||||
aggregate,
|
||||
campaignRunEnvironment,
|
||||
classifyLog,
|
||||
countProbeDegradations,
|
||||
decideVerdict,
|
||||
parseArgs,
|
||||
remainingRunTimeoutMs,
|
||||
terminateProcessGroup,
|
||||
} from "../pg-preadmission-campaign.mjs";
|
||||
|
||||
test("parses campaign arguments", () => {
|
||||
assert.deepEqual(parseArgs(["--runs", "5", "--control", "a", "--candidate", "b"]), { runs: 5, scratchDir: undefined, control: "a", candidate: "b" });
|
||||
});
|
||||
|
||||
test("classifies failed files and project identity timeout", () => {
|
||||
assert.deepEqual(classifyLog("Test Files 2 failed | 9 passed\nproject-identity.test.ts hook timed out"), { failingFiles: 2, projectIdentityTimedOut: true });
|
||||
});
|
||||
|
||||
test("counts only degraded preadmission rows", () => {
|
||||
assert.equal(countProbeDegradations('{"event":"pg-preadmission-probe","outcome":"acquired-and-released"}\n{"event":"pg-preadmission-probe","outcome":"connect-failed"}\ninvalid'), 1);
|
||||
});
|
||||
|
||||
test("enables diagnostics and addresses the run process group", () => {
|
||||
assert.deepEqual(campaignRunEnvironment("sample.jsonl"), {
|
||||
FUSION_PG_TEST_TEARDOWN_DIAGNOSTICS: "1",
|
||||
FUSION_PG_TEST_TEARDOWN_DIAGNOSTICS_LOG: "sample.jsonl",
|
||||
});
|
||||
const kills = [];
|
||||
terminateProcessGroup({ pid: 42, kill: assert.fail }, "SIGKILL", (...args) => kills.push(args));
|
||||
assert.deepEqual(kills, [[-42, "SIGKILL"]]);
|
||||
});
|
||||
|
||||
test("caps each run by the remaining campaign deadline", () => {
|
||||
assert.equal(remainingRunTimeoutMs(1_000, 1_000), DEFAULT_RUN_TIMEOUT_MS);
|
||||
assert.equal(remainingRunTimeoutMs(1_000, 1_000 + DEFAULT_CAMPAIGN_TIMEOUT_MS - 25), 25);
|
||||
assert.equal(remainingRunTimeoutMs(1_000, 1_000 + DEFAULT_CAMPAIGN_TIMEOUT_MS), 0);
|
||||
});
|
||||
|
||||
const control = Array.from({ length: 5 }, (_, i) => ({ arm: "control", valid: true, wallTimeMs: 100 + i, failingFiles: 2, peakBackends: 10, measurementAvailable: true, projectIdentityTimedOut: false, probeRecords: 0, probeDegradations: null }));
|
||||
const candidate = Array.from({ length: 5 }, (_, i) => ({ arm: "candidate", valid: true, wallTimeMs: 100 + i, failingFiles: 2, peakBackends: 11, measurementAvailable: true, projectIdentityTimedOut: false, probeRecords: 1, probeDegradations: 0 }));
|
||||
test("accepts affordable synthetic evidence", () => assert.equal(decideVerdict([...control, ...candidate]), "boundary-affordable"));
|
||||
test("rejects taxing synthetic evidence", () => assert.equal(decideVerdict([...control, ...candidate.map((run) => ({ ...run, wallTimeMs: 999 }))]), "boundary-taxing"));
|
||||
|
||||
test("retains sampled peak backends and rejects a two-backend increase", () => {
|
||||
const elevatedCandidate = candidate.map((run) => ({ ...run, peakBackends: 12 }));
|
||||
const report = aggregate([...control, ...elevatedCandidate], false);
|
||||
assert.equal(report.candidate.peakBackends, 12);
|
||||
assert.equal(report.verdict, "boundary-taxing");
|
||||
});
|
||||
|
||||
test("rejects incomplete evidence", () => assert.equal(decideVerdict([...control, ...candidate.slice(0, 4)]), "insufficient-data"));
|
||||
|
||||
test("rejects evidence when backend measurement is unavailable", () => {
|
||||
const unavailable = candidate.map((run, index) => index === 0
|
||||
? { ...run, measurementAvailable: false, peakBackends: null }
|
||||
: run);
|
||||
assert.equal(decideVerdict([...control, ...unavailable]), "insufficient-data");
|
||||
});
|
||||
92
scripts/__tests__/pg-setup-boundary-probe.test.mjs
Normal file
92
scripts/__tests__/pg-setup-boundary-probe.test.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
classifyVitestOutput,
|
||||
parseProbeArgs,
|
||||
parseProbeEvents,
|
||||
summarizeBoundary,
|
||||
} from "../pg-setup-boundary-probe.mjs";
|
||||
|
||||
/*
|
||||
FNXC:PgSetupBoundaryProbe 2026-08-17-03:25:
|
||||
FN-9139 keeps this report-only survey's unit proof connectionless: fixture strings validate the
|
||||
parser and summary contract without spawning Vitest or making a PostgreSQL admission attempt.
|
||||
*/
|
||||
|
||||
test("parseProbeArgs uses bounded defaults and accepts explicit positive integers", () => {
|
||||
assert.deepEqual(parseProbeArgs([]), { delayMs: 40, timeoutMs: 15, workers: 2 });
|
||||
assert.deepEqual(
|
||||
parseProbeArgs(["--delay-ms", "25", "--timeout-ms", "12", "--workers", "3"]),
|
||||
{ delayMs: 25, timeoutMs: 12, workers: 3 },
|
||||
);
|
||||
assert.deepEqual(parseProbeArgs(["--help"]), { help: true });
|
||||
});
|
||||
|
||||
test("parseProbeArgs rejects unknown, missing, and non-positive values", () => {
|
||||
assert.throws(() => parseProbeArgs(["--unexpected"]), /Unknown argument/);
|
||||
assert.throws(() => parseProbeArgs(["--workers"]), /positive integer/);
|
||||
assert.throws(() => parseProbeArgs(["--delay-ms", "0"]), /positive integer/);
|
||||
assert.throws(() => parseProbeArgs(["--timeout-ms", "1.5"]), /positive integer/);
|
||||
});
|
||||
|
||||
test("parseProbeEvents retains only well-formed prefixed fixture events", () => {
|
||||
const output = [
|
||||
"ordinary Vitest output",
|
||||
'PG_SETUP_BOUNDARY_EVENT:{"type":"boundary","boundary":"global-setup","phase":"start","pid":42}',
|
||||
"PG_SETUP_BOUNDARY_EVENT:not-json",
|
||||
'PG_SETUP_BOUNDARY_EVENT:{"type":"test","boundary":"test","phase":"start","pid":42,"file":"one"}',
|
||||
].join("\n");
|
||||
|
||||
assert.deepEqual(parseProbeEvents(output), [
|
||||
{ type: "boundary", boundary: "global-setup", phase: "start", pid: 42, outputIndex: 1 },
|
||||
{ type: "test", boundary: "test", phase: "start", pid: 42, file: "one", outputIndex: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("classifyVitestOutput separates hook and test budgets from off-budget success", () => {
|
||||
assert.equal(classifyVitestOutput("Error: Hook timed out in 15ms.", 1), "hook-timeout");
|
||||
assert.equal(classifyVitestOutput("Error: Test timed out in 15ms.", 1), "test-timeout");
|
||||
assert.equal(classifyVitestOutput("all tests passed", 0), "off-budget");
|
||||
assert.equal(classifyVitestOutput("unexpected fixture failure", 1), "unknown-failure");
|
||||
});
|
||||
|
||||
test("summarizeBoundary reports per-file execution and ordering before the first test", () => {
|
||||
const normalRun = {
|
||||
exitCode: 0,
|
||||
output: [
|
||||
'PG_SETUP_BOUNDARY_EVENT:{"type":"boundary","boundary":"per-file-before-all","phase":"start","pid":11,"file":"one"}',
|
||||
'PG_SETUP_BOUNDARY_EVENT:{"type":"boundary","boundary":"per-file-before-all","phase":"start","pid":12,"file":"two"}',
|
||||
'PG_SETUP_BOUNDARY_EVENT:{"type":"test","boundary":"test","phase":"start","pid":11,"file":"one"}',
|
||||
].join("\n"),
|
||||
};
|
||||
const stressedRun = { exitCode: 1, output: "Error: Hook timed out in 15ms." };
|
||||
|
||||
assert.deepEqual(summarizeBoundary("per-file-before-all", normalRun, stressedRun), {
|
||||
boundary: "per-file-before-all",
|
||||
granularity: "per file",
|
||||
executions: 2,
|
||||
workersObserved: 2,
|
||||
filesObserved: 2,
|
||||
timeoutCharge: "hook-timeout",
|
||||
orderingRelativeToFirstTest: "before-first-test",
|
||||
});
|
||||
});
|
||||
|
||||
test("summarizeBoundary uses global invocation semantics and reports absent ordering honestly", () => {
|
||||
const normalRun = {
|
||||
exitCode: 0,
|
||||
output: 'PG_SETUP_BOUNDARY_EVENT:{"type":"boundary","boundary":"global-setup","phase":"start","pid":99}',
|
||||
};
|
||||
const stressedRun = { exitCode: 0, output: "passed" };
|
||||
|
||||
assert.deepEqual(summarizeBoundary("global-setup", normalRun, stressedRun), {
|
||||
boundary: "global-setup",
|
||||
granularity: "per invocation",
|
||||
executions: 1,
|
||||
workersObserved: 1,
|
||||
filesObserved: 0,
|
||||
timeoutCharge: "off-budget",
|
||||
orderingRelativeToFirstTest: "not-observed",
|
||||
});
|
||||
});
|
||||
194
scripts/pg-preadmission-campaign.mjs
Normal file
194
scripts/pg-preadmission-campaign.mjs
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/*
|
||||
FNXC:PgTestPreAdmission 2026-08-17-03:20:
|
||||
FN-9139 requires pre-admission affordability evidence to be collected by one
|
||||
bounded shell, so a missing operator terminal cannot turn measurement into an
|
||||
unbounded handoff. The driver alternates arms and treats incomplete or invalid
|
||||
samples as insufficient data instead of retrying toward a preferred outcome.
|
||||
*/
|
||||
export const MINIMUM_RUNS_PER_ARM = 5;
|
||||
export const DEFAULT_RUN_TIMEOUT_MS = 300_000;
|
||||
export const DEFAULT_CAMPAIGN_TIMEOUT_MS = 3_600_000;
|
||||
|
||||
export function parseArgs(args) {
|
||||
const result = { runs: MINIMUM_RUNS_PER_ARM, scratchDir: undefined, control: undefined, candidate: undefined };
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const value = args[i];
|
||||
if (value === "--runs") result.runs = Number(args[++i]);
|
||||
else if (value === "--scratch-dir") result.scratchDir = args[++i];
|
||||
else if (value === "--control") result.control = args[++i];
|
||||
else if (value === "--candidate") result.candidate = args[++i];
|
||||
else throw new Error(`Unknown argument: ${value}`);
|
||||
}
|
||||
if (!Number.isInteger(result.runs) || result.runs < 1) throw new Error("--runs must be a positive integer");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stripAnsi(text) {
|
||||
return text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g"), "");
|
||||
}
|
||||
|
||||
export function classifyLog(log) {
|
||||
const clean = stripAnsi(log);
|
||||
const failed = [...clean.matchAll(/^\s*Test Files\s+.*?(\d+)\s+failed\b/gim)].at(-1);
|
||||
return {
|
||||
failingFiles: failed ? Number(failed[1]) : 0,
|
||||
projectIdentityTimedOut: /project-identity\.test\.ts[\s\S]{0,500}?timed out/i.test(clean),
|
||||
};
|
||||
}
|
||||
|
||||
export function countProbeDegradations(jsonl) {
|
||||
return jsonl.split(/\r?\n/).filter(Boolean).reduce((count, line) => {
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
return count + (row.event === "pg-preadmission-probe" && row.outcome !== "acquired-and-released" ? 1 : 0);
|
||||
} catch { return count; }
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function median(values) {
|
||||
const sorted = values.filter(Number.isFinite).toSorted((a, b) => a - b);
|
||||
return sorted.length === 0 ? null : sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
export function decideVerdict(runs, { incomplete = false } = {}) {
|
||||
const control = runs.filter((run) => run.arm === "control" && run.valid);
|
||||
const candidate = runs.filter((run) => run.arm === "candidate" && run.valid);
|
||||
const measurementsAvailable = [...control, ...candidate].every((run) => run.measurementAvailable === true && Number.isFinite(run.peakBackends));
|
||||
if (incomplete || control.length < MINIMUM_RUNS_PER_ARM || candidate.length < MINIMUM_RUNS_PER_ARM || !measurementsAvailable) return "insufficient-data";
|
||||
if (candidate.some((run) => run.probeRecords === 0 || run.probeDegradations !== 0)) return "boundary-taxing";
|
||||
const controlMaxWall = Math.max(...control.map((run) => run.wallTimeMs));
|
||||
const controlMaxFailures = Math.max(...control.map((run) => run.failingFiles));
|
||||
const controlPeak = Math.max(...control.map((run) => run.peakBackends));
|
||||
const candidateMedian = median(candidate.map((run) => run.wallTimeMs));
|
||||
const candidateMaxFailures = Math.max(...candidate.map((run) => run.failingFiles));
|
||||
const candidatePeak = Math.max(...candidate.map((run) => run.peakBackends));
|
||||
const identityWorsened = candidate.some((run) => run.projectIdentityTimedOut) && !control.some((run) => run.projectIdentityTimedOut);
|
||||
return candidateMedian <= controlMaxWall && candidateMaxFailures <= controlMaxFailures && candidatePeak <= controlPeak + 1 && !identityWorsened
|
||||
? "boundary-affordable" : "boundary-taxing";
|
||||
}
|
||||
|
||||
export function aggregate(runs, incomplete) {
|
||||
const summarize = (arm) => {
|
||||
const selected = runs.filter((run) => run.arm === arm);
|
||||
return { count: selected.length, medianWallTimeMs: median(selected.map((run) => run.wallTimeMs)), maxWallTimeMs: Math.max(...selected.map((run) => run.wallTimeMs), 0), maxFailingFiles: Math.max(...selected.map((run) => run.failingFiles), 0), peakBackends: Math.max(...selected.map((run) => run.peakBackends), 0) };
|
||||
};
|
||||
return { runs, control: summarize("control"), candidate: summarize("candidate"), incomplete, verdict: decideVerdict(runs, { incomplete }) };
|
||||
}
|
||||
|
||||
export function terminateProcessGroup(child, signal, kill = process.kill) {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
kill(-child.pid, signal);
|
||||
} catch {
|
||||
try { child.kill(signal); } catch { /* The process already exited. */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function campaignRunEnvironment(diagnostics) {
|
||||
return {
|
||||
FUSION_PG_TEST_TEARDOWN_DIAGNOSTICS: "1",
|
||||
FUSION_PG_TEST_TEARDOWN_DIAGNOSTICS_LOG: diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function remainingRunTimeoutMs(startedAt, now = Date.now()) {
|
||||
return Math.min(DEFAULT_RUN_TIMEOUT_MS, DEFAULT_CAMPAIGN_TIMEOUT_MS - (now - startedAt));
|
||||
}
|
||||
|
||||
export async function runCommand(command, env, timeoutMs) {
|
||||
return await new Promise((resolveRun) => {
|
||||
const startedAt = Date.now();
|
||||
// process-supervisor-allowlist: isolation is required so the bounded campaign can kill the shell command's entire process group
|
||||
const child = spawn(command, { shell: true, detached: true, env: { ...process.env, ...env } });
|
||||
// FNXC:PgTestPreAdmission 2026-08-17-04:05:
|
||||
// The timeout owns the shell's whole process group. Killing only the shell
|
||||
// can orphan Vitest or its workers and violate both campaign deadlines.
|
||||
const samplerScript = `while kill -0 ${child.pid} 2>/dev/null; do if value=$(psql --no-password -X -Atqc 'select count(*) from pg_stat_activity;' postgres 2>/dev/null); then printf 'SAMPLE:%s\\n' "$value"; else echo SAMPLE_ERROR; exit 1; fi; sleep 1; done`;
|
||||
// process-supervisor-allowlist: the sampler shares the campaign's bounded process-group cleanup contract
|
||||
const sampler = spawn(samplerScript, { shell: true, detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
||||
let output = "";
|
||||
let sampled = "";
|
||||
let childResult;
|
||||
let samplerClosed = false;
|
||||
let samplerErrored = false;
|
||||
let timedOut = false;
|
||||
const finish = () => {
|
||||
if (!childResult || !samplerClosed) return;
|
||||
const samples = [...sampled.matchAll(/^SAMPLE:(\d+)$/gm)].map((match) => Number(match[1]));
|
||||
const measurementAvailable = !samplerErrored && !sampled.includes("SAMPLE_ERROR") && samples.length > 0;
|
||||
resolveRun({
|
||||
output,
|
||||
...childResult,
|
||||
wallTimeMs: Date.now() - startedAt,
|
||||
peakBackends: measurementAvailable ? Math.max(...samples) : null,
|
||||
measurementAvailable,
|
||||
timedOut,
|
||||
});
|
||||
};
|
||||
child.stdout.on("data", (chunk) => { output += chunk; });
|
||||
child.stderr.on("data", (chunk) => { output += chunk; });
|
||||
sampler.stdout.on("data", (chunk) => { sampled += chunk; });
|
||||
sampler.once("error", () => { samplerErrored = true; samplerClosed = true; finish(); });
|
||||
sampler.once("close", () => { samplerClosed = true; finish(); });
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
terminateProcessGroup(child, "SIGTERM");
|
||||
terminateProcessGroup(child, "SIGKILL");
|
||||
}, timeoutMs);
|
||||
child.once("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
childResult = { code, signal };
|
||||
terminateProcessGroup(sampler, "SIGTERM");
|
||||
terminateProcessGroup(sampler, "SIGKILL");
|
||||
finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.control || !args.candidate) throw new Error("Supply --control and --candidate commands");
|
||||
const scratch = resolve(args.scratchDir ?? join(tmpdir(), `fusion-pg-preadmission-${process.pid}`));
|
||||
mkdirSync(scratch, { recursive: true });
|
||||
const startedAt = Date.now();
|
||||
const results = [];
|
||||
let incomplete = false;
|
||||
for (let index = 0; index < args.runs * 2; index += 1) {
|
||||
const runTimeoutMs = remainingRunTimeoutMs(startedAt);
|
||||
if (runTimeoutMs <= 0) { incomplete = true; break; }
|
||||
const arm = index % 2 === 0 ? "control" : "candidate";
|
||||
const diagnostics = join(scratch, `${String(index + 1).padStart(2, "0")}-${arm}.jsonl`);
|
||||
// FNXC:PgTestPreAdmission 2026-08-17-04:05:
|
||||
// Diagnostics must be explicitly enabled, and an arm may consume only the
|
||||
// smaller of its five-minute allowance and the campaign's remaining time.
|
||||
const result = await runCommand(
|
||||
arm === "control" ? args.control : args.candidate,
|
||||
campaignRunEnvironment(diagnostics),
|
||||
runTimeoutMs,
|
||||
);
|
||||
const logPath = join(scratch, `${String(index + 1).padStart(2, "0")}-${arm}.log`);
|
||||
writeFileSync(logPath, result.output);
|
||||
const parsed = classifyLog(result.output);
|
||||
const jsonl = (() => { try { return readFileSync(diagnostics, "utf8"); } catch { return ""; } })();
|
||||
const probeRecords = [...jsonl.matchAll(/"event":"pg-preadmission-probe"/g)].length;
|
||||
// FNXC:PgTestPreAdmission 2026-08-17-03:38:
|
||||
// FN-9139's affordability verdict must retain the sampler's observed peak.
|
||||
// Replacing it would falsely certify a candidate whose extra backend exceeds
|
||||
// the one-connection observation-only footprint.
|
||||
results.push({ arm, ...result, ...parsed, probeRecords, probeDegradations: arm === "control" ? null : countProbeDegradations(jsonl), valid: !result.timedOut && result.signal === null && result.measurementAvailable && (arm === "control" || probeRecords > 0) });
|
||||
if (result.timedOut || Date.now() - startedAt >= DEFAULT_CAMPAIGN_TIMEOUT_MS) { incomplete = true; break; }
|
||||
}
|
||||
const report = aggregate(results, incomplete);
|
||||
writeFileSync(join(scratch, "results.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ ...report, scratchDir: scratch }, null, 2));
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
182
scripts/pg-setup-boundary-probe.mjs
Normal file
182
scripts/pg-setup-boundary-probe.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/*
|
||||
FNXC:PgSetupBoundaryProbe 2026-08-17-03:25:
|
||||
FN-9139 must establish whether Vitest lifecycle setup can host a future PostgreSQL admission
|
||||
without changing the PostgreSQL harness. This isolated fixture observes only lifecycle timing and
|
||||
never loads a repository config, a database client, or a project test suite.
|
||||
*/
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const BOUNDARIES = ["global-setup", "setup-top-level-await", "setup-before-all", "per-file-before-all"];
|
||||
const DEFAULTS = Object.freeze({ delayMs: 40, timeoutMs: 15, workers: 2 });
|
||||
|
||||
export function parseProbeArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
const names = new Map([
|
||||
["--delay-ms", "delayMs"],
|
||||
["--timeout-ms", "timeoutMs"],
|
||||
["--workers", "workers"],
|
||||
]);
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--help") return { help: true };
|
||||
const name = names.get(argument);
|
||||
if (!name) throw new Error(`Unknown argument: ${argument}`);
|
||||
const value = Number(argv[++index]);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${argument} must be a positive integer`);
|
||||
}
|
||||
options[name] = value;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Extract deliberately prefixed fixture events without coupling classification to Vitest's reporter. */
|
||||
export function parseProbeEvents(output) {
|
||||
return output.split(/\r?\n/).flatMap((line, index) => {
|
||||
const marker = line.indexOf("PG_SETUP_BOUNDARY_EVENT:");
|
||||
if (marker < 0) return [];
|
||||
try {
|
||||
const event = JSON.parse(line.slice(marker + "PG_SETUP_BOUNDARY_EVENT:".length));
|
||||
return [{ ...event, outputIndex: index }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Classify timeout ownership from stable Vitest diagnostic phrases, preserving unknown output. */
|
||||
export function classifyVitestOutput(output, exitCode = 0) {
|
||||
if (/Hook timed out in \d+ms\./i.test(output)) return "hook-timeout";
|
||||
if (/Test timed out in \d+ms\./i.test(output)) return "test-timeout";
|
||||
if (exitCode === 0) return "off-budget";
|
||||
return "unknown-failure";
|
||||
}
|
||||
|
||||
export function summarizeBoundary(boundary, normalRun, stressedRun) {
|
||||
const events = parseProbeEvents(normalRun.output);
|
||||
const starts = events.filter((event) => event.boundary === boundary && event.phase === "start");
|
||||
const firstTest = events.find((event) => event.type === "test" && event.phase === "start");
|
||||
const lastBoundary = starts.at(-1);
|
||||
const files = new Set(starts.map((event) => event.file).filter(Boolean));
|
||||
const pids = new Set(starts.map((event) => event.pid).filter(Boolean));
|
||||
const granularity = boundary === "global-setup"
|
||||
? "per invocation"
|
||||
: files.size > 1
|
||||
? "per file"
|
||||
: pids.size > 1
|
||||
? "per worker"
|
||||
: "per invocation";
|
||||
|
||||
return {
|
||||
boundary,
|
||||
granularity,
|
||||
executions: starts.length,
|
||||
workersObserved: pids.size,
|
||||
filesObserved: files.size,
|
||||
timeoutCharge: classifyVitestOutput(stressedRun.output, stressedRun.exitCode),
|
||||
orderingRelativeToFirstTest: !firstTest || !lastBoundary
|
||||
? "not-observed"
|
||||
: lastBoundary.outputIndex < firstTest.outputIndex
|
||||
? "before-first-test"
|
||||
: "after-first-test",
|
||||
};
|
||||
}
|
||||
|
||||
function eventSource(type, boundary, phase, file = "") {
|
||||
return `console.log("PG_SETUP_BOUNDARY_EVENT:" + JSON.stringify({type:${JSON.stringify(type)},boundary:${JSON.stringify(boundary)},phase:${JSON.stringify(phase)},pid:process.pid,file:${JSON.stringify(file)}}));`;
|
||||
}
|
||||
|
||||
function fixtureFiles(boundary, delayMs, timeoutMs, workers, vitestApiUrl) {
|
||||
const delay = `await new Promise((resolve) => setTimeout(resolve, ${delayMs}));`;
|
||||
// The fixture is outside the repository, so its Vitest API import must be absolute.
|
||||
const config = `export default { test: { include: ["tests/**/*.test.mjs"], pool: "forks", maxWorkers: ${workers}, minWorkers: ${workers}, testTimeout: ${timeoutMs}, hookTimeout: ${timeoutMs}, setupFiles: ["./setup.mjs"]${boundary === "global-setup" ? ', globalSetup: ["./global-setup.mjs"]' : ""} } };\n`;
|
||||
const setup = boundary === "setup-top-level-await"
|
||||
? `${eventSource("boundary", boundary, "start")}${delay}${eventSource("boundary", boundary, "end")}`
|
||||
: boundary === "setup-before-all"
|
||||
? `import { beforeAll } from ${JSON.stringify(vitestApiUrl)};\nbeforeAll(async () => { ${eventSource("boundary", boundary, "start")}${delay}${eventSource("boundary", boundary, "end")} });`
|
||||
: "";
|
||||
const global = boundary === "global-setup"
|
||||
? `export default async function setup() { ${eventSource("boundary", boundary, "start")}${delay}${eventSource("boundary", boundary, "end")} }`
|
||||
: "export default async function setup() {}";
|
||||
const test = (file) => `import { beforeAll, test } from ${JSON.stringify(vitestApiUrl)};\n${boundary === "per-file-before-all" ? `beforeAll(async () => { ${eventSource("boundary", boundary, "start", file)}${delay}${eventSource("boundary", boundary, "end", file)} });` : ""}\ntest(${JSON.stringify(file)}, () => { ${eventSource("test", "test", "start", file)} });\n`;
|
||||
return {
|
||||
"vitest.config.mjs": config,
|
||||
"setup.mjs": setup,
|
||||
"global-setup.mjs": global,
|
||||
"tests/one.test.mjs": test("one"),
|
||||
"tests/two.test.mjs": test("two"),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeFixture(directory, files) {
|
||||
await Promise.all(Object.entries(files).map(async ([file, content]) => {
|
||||
const path = join(directory, file);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, content, "utf8");
|
||||
}));
|
||||
}
|
||||
|
||||
function runVitest(directory) {
|
||||
const vitest = join(dirname(require.resolve("vitest", { paths: [resolve(process.cwd(), "packages/core")] })), "vitest.mjs");
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [vitest, "run", "--config", "vitest.config.mjs", "--reporter=verbose"], {
|
||||
cwd: directory,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => { output += chunk; });
|
||||
child.stderr.on("data", (chunk) => { output += chunk; });
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => resolve({ output, exitCode: exitCode ?? 1 }));
|
||||
});
|
||||
}
|
||||
|
||||
async function runBoundary(boundary, options, stressed) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "fusion-pg-setup-boundary-"));
|
||||
try {
|
||||
const delayMs = stressed ? Math.max(options.delayMs, options.timeoutMs + 20) : Math.min(options.delayMs, Math.max(1, options.timeoutMs - 1));
|
||||
const vitestApiUrl = pathToFileURL(require.resolve("vitest", { paths: [resolve(process.cwd(), "packages/core")] })).href;
|
||||
await writeFixture(directory, fixtureFiles(boundary, delayMs, options.timeoutMs, options.workers, vitestApiUrl));
|
||||
return await runVitest(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProbe(options) {
|
||||
const summaries = [];
|
||||
for (const boundary of BOUNDARIES) {
|
||||
const normalRun = await runBoundary(boundary, options, false);
|
||||
const stressedRun = await runBoundary(boundary, options, true);
|
||||
summaries.push(summarizeBoundary(boundary, normalRun, stressedRun));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log("Usage: node scripts/pg-setup-boundary-probe.mjs [--delay-ms N] [--timeout-ms N] [--workers N]");
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
const options = parseProbeArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
} else {
|
||||
console.table(await runProbe(options));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[pg-setup-boundary-probe] ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user