#!/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$/, ]; function isRuntimePath(relPath) { return RUNTIME_IGNORE_PATTERNS.some((re) => re.test(relPath)); } 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 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) { 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) { // A live local app can write in bursts (e.g. heartbeat every few seconds), // so do a short mutability probe before blaming tests. const postSamples = [currentProtected]; for (let i = 0; i < 4; i++) { sleepMs(500); postSamples.push(snapshotProtectedFusion()); } for (const dir of candidateViolations) { 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")) { recordBaseline(); } else { checkAgainstBaseline(); }