Files
fusion/scripts/check-test-isolation.mjs
gsxdsm 211c0fd557 perf(test): cut inner-loop fixed overhead to sub-second on cache-fresh runs
- skill-sync check conditioned on content hash of its inputs (skips ~0.3s spawn)
- ensure-test-artifacts: git-blob content-hash staleness; branch switches no longer trigger spurious ~2.6s tsc rebuilds (mtime fallback when dirty)
- isolation guard: cheap --before-fast reusing prior post-run baseline (~2.1s -> ~0.07s); detection proven preserved via injected-leak failure test
- vitest-setup: CI skips 4040-4045 discovery probe unless FUSION_RESERVED_PORTS set; kill-guard wrapper untouched, asymmetry pinned by port-probe-policy tests
- cache-fresh fast path skips sync/artifacts/HOME-prune entirely (mode line: fast-path=cache-fresh)
2026-06-03 17:57:29 -07:00

384 lines
14 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { readdirSync, statSync, existsSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
import { createHash } from "node:crypto";
import { homedir, tmpdir } from "node:os";
import { join, resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
function stableCwd() {
try {
return realpathSync(process.cwd());
} catch {
return process.cwd();
}
}
// Namespace the baseline by cwd so concurrent worktrees don't clobber each
// other's baseline. Without this, running `--before` in worktree A and the
// post-test check in worktree B reads a baseline that doesn't include B's
// `.fusion`, which then trips the "live data changed" failure path.
const cwdHash = createHash("sha1").update(stableCwd()).digest("hex").slice(0, 12);
const BASELINE_FILE = join(tmpdir(), `.fusion-isolation-baseline-${cwdHash}`);
const TRACKED_PREFIXES = [
"fusion-worker-",
"fusion-test-",
"fusion-test-cwd-",
"fusion-provider-settings-",
"fusion-provider-auth-",
"fusion-provider-auth-oauth-",
"fusion-agent-dir-",
"kb-db-test-",
"kb-backup-test-",
"kb-migration-test-",
"kb-fresh-",
"kb-needs-migration-",
"kb-compat-test-",
"kb-first-run-test-",
];
function stablePath(pathValue) {
try {
return realpathSync(pathValue);
} catch {
return resolve(pathValue);
}
}
function snapshotTmp() {
const entries = readdirSync(tmpdir());
const matching = [];
for (const name of entries) {
if (!TRACKED_PREFIXES.some((p) => name.startsWith(p))) continue;
const full = join(tmpdir(), name);
try {
const stat = statSync(full);
if (stat.isDirectory()) matching.push({ name, mtime: stat.mtimeMs });
} catch {
// Ignore transient file-system races while scanning /tmp.
}
}
return matching;
}
function listProtectedFusionDirs() {
const dirs = new Set();
dirs.add(stablePath(join(process.cwd(), ".fusion")));
dirs.add(stablePath(join(process.env.HOME || process.env.USERPROFILE || homedir(), ".fusion")));
return [...dirs];
}
// Paths inside a protected .fusion root that a concurrently-running fusion app
// is expected to mutate. Tests still must not write to these — the filter only
// suppresses noise from a live app sharing the same HOME during local dev.
const RUNTIME_IGNORE_PATTERNS = [
/^agent(?:[/\\]|$)/,
/^agents(?:[/\\]|$)/,
/^agent-memory(?:[/\\]|$)/,
/^automations(?:[/\\]|$)/,
/^backups(?:[/\\]|$)/,
/^plugins(?:[/\\]|$)/,
/^cache(?:[/\\]|$)/,
/^config\.json$/,
/^fusion-central\.db(?:-wal|-shm|-journal)?$/,
/^fusion\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?(?:\.pre-[\w-]+)?$/,
/^archive\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
/^kb\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
/^activity-log\.jsonl$/,
/^settings\.json$/,
/^logs(?:[/\\]|$)/,
/^tasks(?:[/\\]|$)/,
/^memory(?:[/\\]|$)/,
/^messages(?:[/\\]|$)/,
/^memory-insights\.md$/,
/^test-cache\.json$/,
/^HEARTBEAT\.md$/,
/^MEMORY\.md$/,
/^DREAMS\.md$/,
/^\d{4}-\d{2}-\d{2}\.md$/,
/^scripts\.json$/,
/^update-check\.json$/,
/^disabled-auto-extension-discovery$/,
// Engine singleton lock (proper-lockfile creates `engine.lock.lock/` while
// held; `engine.lock` is the sentinel file). Their entries appear/disappear
// as the local dashboard starts/stops, which is not test pollution.
/^engine\.lock$/,
/^engine\.lock\.lock(?:[/\\]|$)/,
];
function isRuntimePath(relPath) {
return RUNTIME_IGNORE_PATTERNS.some((re) => re.test(relPath));
}
// A .fusion dir is "engine-active" when an engine process currently holds the
// singleton lock. proper-lockfile materializes this as `engine.lock.lock/`
// being present alongside the `engine.lock` sentinel. This is a deterministic
// signal — no timing/probe required — so we use it to auto-skip live-engine
// dirs instead of relying on the post-run mutability burst landing inside
// our 2-second probe window.
function isFusionEngineActive(fusionDir) {
if (!existsSync(fusionDir)) return false;
try {
const lockHeldDir = join(fusionDir, "engine.lock.lock");
const stat = statSync(lockHeldDir);
return stat.isDirectory();
} catch {
return false;
}
}
function collectFusionSignature(rootDir, out = []) {
if (!existsSync(rootDir)) return out;
let stat;
try {
stat = statSync(rootDir);
} catch {
return out;
}
if (!stat.isDirectory()) return out;
const entries = readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(rootDir, entry.name);
const relPath = fullPath.slice(rootDir.length + (rootDir.endsWith(sep) ? 0 : 1));
if (isRuntimePath(relPath)) continue;
try {
statSync(fullPath);
} catch {
continue;
}
// Track only path + kind. Size/mtime would flip every time the live app
// touches an existing file (fusion.db-wal heartbeats, settings.json saves),
// producing false-positive "leak" failures. New files still get detected
// because they add a new entry to the set; tests are independently blocked
// from writing to the real .fusion via the fs guards in vitest-setup.ts.
out.push(`${relPath}|${entry.isDirectory() ? "d" : "f"}`);
if (entry.isDirectory()) collectFusionSignature(fullPath, out);
}
return out;
}
function snapshotProtectedFusion() {
return listProtectedFusionDirs().map((dir) => ({
dir,
exists: existsSync(dir),
entries: collectFusionSignature(dir).sort(),
}));
}
function sleepMs(ms) {
// Cross-platform enough for CI and local dev; fall back to best-effort no-op.
spawnSync(process.platform === "win32" ? "powershell" : "sleep", process.platform === "win32" ? ["-NoProfile", "-Command", `Start-Sleep -Milliseconds ${ms}`] : [String(ms / 1000)], { stdio: "ignore" });
}
function readPreviousBaseline() {
if (!existsSync(BASELINE_FILE)) return null;
try {
return JSON.parse(readFileSync(BASELINE_FILE, "utf-8"));
} catch {
return null;
}
}
// U3: fast baseline. The expensive part of recordBaseline() is the 2s mutability
// probe (5 snapshots × 500ms) that classifies which protected .fusion dirs are
// externally-active (a live dashboard). That classification is stable across
// back-to-back inner-loop runs, so when the previous run already recorded it we
// reuse it and skip the probe. Detection is NOT weakened: the post-run check
// still runs its own independent mutability probe on any candidate violation
// before failing, and engine-lock detection is race-free. If no previous
// baseline exists (first run, rotated tmp), we fall back to the full probe.
function recordBaselineFast() {
const previous = readPreviousBaseline();
if (!previous || !Array.isArray(previous.unstableProtectedDirs)) {
recordBaseline();
return;
}
const latestProtected = snapshotProtectedFusion();
// Re-confirm engine-lock-active dirs cheaply (no sleep) so a dashboard that
// started since the previous run is still classified unstable up front.
const unstableProtectedDirs = new Set(previous.unstableProtectedDirs);
for (const entry of latestProtected) {
if (isFusionEngineActive(entry.dir)) unstableProtectedDirs.add(entry.dir);
}
const payload = {
tmpNames: snapshotTmp().map((e) => e.name),
protectedFusion: latestProtected,
unstableProtectedDirs: [...unstableProtectedDirs],
};
writeFileSync(BASELINE_FILE, JSON.stringify(payload));
console.log(`[test-isolation] Baseline recorded (fast): ${payload.tmpNames.length} temp dir(s), ${payload.protectedFusion.length} protected .fusion root(s).`);
if (unstableProtectedDirs.size > 0) {
console.log(`[test-isolation] Reusing ${unstableProtectedDirs.size} externally-active protected dir(s) from prior run.`);
}
}
function recordBaseline() {
const samples = [snapshotProtectedFusion()];
for (let i = 0; i < 4; i++) {
sleepMs(500);
samples.push(snapshotProtectedFusion());
}
const latestProtected = samples[samples.length - 1];
const unstableProtectedDirs = [];
const firstProtected = samples[0];
for (const first of firstProtected) {
// Live engine lock present → auto-mark unstable. Same outcome as the
// mutability probe below would (eventually) reach, but deterministic and
// immune to write-cadence gaps.
if (isFusionEngineActive(first.dir)) {
unstableProtectedDirs.push(first.dir);
continue;
}
let unstable = false;
for (let i = 1; i < samples.length; i++) {
const current = samples[i].find((entry) => entry.dir === first.dir);
if (!current) continue;
if (JSON.stringify(first.entries) !== JSON.stringify(current.entries)) {
unstable = true;
break;
}
}
if (unstable) unstableProtectedDirs.push(first.dir);
}
const payload = {
tmpNames: snapshotTmp().map((e) => e.name),
protectedFusion: latestProtected,
unstableProtectedDirs,
};
writeFileSync(BASELINE_FILE, JSON.stringify(payload));
console.log(`[test-isolation] Baseline recorded: ${payload.tmpNames.length} temp dir(s), ${payload.protectedFusion.length} protected .fusion root(s).`);
if (unstableProtectedDirs.length > 0) {
console.log(`[test-isolation] Ignoring ${unstableProtectedDirs.length} externally-active protected dir(s):`);
for (const dir of unstableProtectedDirs) console.log(` ${dir}`);
}
}
function checkAgainstBaseline() {
let baseline = { tmpNames: [], protectedFusion: [] };
if (existsSync(BASELINE_FILE)) {
try {
baseline = JSON.parse(readFileSync(BASELINE_FILE, "utf-8"));
} catch {
// Ignore malformed baseline payloads and treat as empty baseline.
}
}
const baselineNames = new Set(baseline.tmpNames ?? []);
// Caller (scripts/test-changed.mjs) tells us which fusion-test-home-root-*
// basenames it minted this run. We allow-list them unconditionally so a
// transient cleanup failure or a rotated baseline file can't masquerade as
// a real test leak.
const callerIgnoreNames = (process.env.FUSION_TEST_ISOLATION_IGNORE_NAMES ?? "")
.split(",")
.map((name) => name.trim())
.filter(Boolean);
for (const name of callerIgnoreNames) baselineNames.add(name);
const leaks = snapshotTmp().filter((e) => {
if (baselineNames.has(e.name)) {
return false;
}
if (e.name.startsWith("fusion-test-home-root-")) {
return false;
}
return true;
});
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);
const currentProtected = snapshotProtectedFusion();
const candidateViolations = [];
const skippedUnknownDirs = [];
for (const current of currentProtected) {
if (unstableProtectedDirs.has(current.dir)) continue;
const base = baselineByDir.get(current.dir);
if (!base) {
// No baseline for this dir — the `--before` step ran from a different
// cwd (or never ran). We can't distinguish pre-existing entries from
// test-created ones, so warn and skip instead of false-failing.
skippedUnknownDirs.push(current.dir);
continue;
}
const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);
if (changedExistence || changedEntries) {
candidateViolations.push(current.dir);
}
}
const protectedViolations = [];
if (candidateViolations.length > 0) {
// First: skip any candidate dir where an engine is currently holding the
// singleton lock. This is the dominant local-dev case (`fn` dashboard
// running while tests are invoked) and the engine-lock signal is
// race-free, unlike the post-test mutability probe.
const remainingCandidates = candidateViolations.filter((dir) => !isFusionEngineActive(dir));
if (remainingCandidates.length > 0) {
// A live local app can write in bursts (e.g. heartbeat every few seconds),
// so do a short mutability probe before blaming tests. Retained as a
// backstop for dirs that don't have an engine lock but do have an
// external writer (e.g. an `fn` process that crashed mid-run and left
// the lock stale).
const postSamples = [currentProtected];
for (let i = 0; i < 4; i++) {
sleepMs(500);
postSamples.push(snapshotProtectedFusion());
}
for (const dir of remainingCandidates) {
let externallyActive = false;
for (let i = 1; i < postSamples.length; i++) {
const prev = postSamples[i - 1].find((entry) => entry.dir === dir);
const next = postSamples[i].find((entry) => entry.dir === dir);
if (!prev || !next) continue;
if (JSON.stringify(prev.entries) !== JSON.stringify(next.entries)) {
externallyActive = true;
break;
}
}
if (!externallyActive) {
protectedViolations.push(dir);
}
}
}
}
if (skippedUnknownDirs.length > 0) {
console.warn(`[test-isolation] WARN: ${skippedUnknownDirs.length} protected dir(s) absent from baseline (was \`--before\` run from a different cwd?):`);
for (const dir of skippedUnknownDirs) console.warn(` ${dir}`);
}
if (leaks.length === 0 && protectedViolations.length === 0) {
console.log("[test-isolation] No temp leaks or live .fusion mutations detected.");
process.exit(0);
}
if (leaks.length > 0) {
console.error(`[test-isolation] FAIL: ${leaks.length} leaked temp director${leaks.length === 1 ? "y" : "ies"}:`);
for (const leak of leaks) console.error(` ${join(tmpdir(), leak.name)}`);
console.error("");
}
if (protectedViolations.length > 0) {
console.error("[test-isolation] FAIL: protected live .fusion data changed during tests:");
for (const dir of protectedViolations) console.error(` ${dir}`);
console.error("Tests must use temp HOME / temp workspaces and never write repo or user .fusion data.");
}
process.exit(1);
}
const args = process.argv.slice(2);
if (args.includes("--before-fast")) {
recordBaselineFast();
} else if (args.includes("--before")) {
recordBaseline();
} else {
checkAgainstBaseline();
}