// Shared test helper: snapshot/restore working-tree files that test scripts // mutate as a side effect. // // Rationale: // // Several showcase test suites invoke real generator scripts (create-integration, // generate-registry, bundle-demo-content) that write to tracked files OUTSIDE // any tmp dir — workflow YAMLs in .github/workflows/ and data JSONs in // showcase/shell/src/data/. Without explicit restoration these writes leak // into the working tree on every `nx run-many -t test` and, on Node 20 CI with // vitest worker pools, the accumulated drift races the worker-RPC channel // (`Timeout calling "onTaskUpdate"` -> ELIFECYCLE). // // This module provides two pieces: // // 1. `restoreFromGitHead(repoRoot, paths)` — synchronously restore any file // from the most recent git HEAD. Used in `beforeAll` to heal a working // tree left dirty by a previously crashed test run before we snapshot. // // 2. `FileSnapshotRestorer` — captures content of a fixed file list at // snapshot time and rewrites only the files that drift. Idempotent. Used // in `afterEach` / `afterAll` as the inner loop. // // PARALLELISM: the in-memory FileSnapshotRestorer is per-process state, so // distinct suites snapshotting DIFFERENT file sets in separate vitest forks // do not collide. What DOES need serialization across processes is git — // `git checkout HEAD -- ` grabs `.git/index.lock`, and two concurrent // invocations (across suites, or between a suite and the pre-commit hook) // race for it. `restoreFromGitHead` below acquires a cross-process file lock // around every git invocation so parallel fork-pool execution under // `fileParallelism: true` is safe. Suites that snapshot and mutate the SAME // generated data files must additionally hold `acquireGeneratedDataLock()` // around the mutation window. // // WINDOWS: callers in sibling test files (create-integration.test.ts, // generate-registry.test.ts, bundle-demo-content.test.ts) invoke `npx` // through `execFileSync` — these will fail on Windows because `npx` is a // `.cmd` there and `execFileSync("npx", ...)` without `shell: true` fails. // Showcase tests currently run on Ubuntu/macOS CI only; if we ever add // Windows CI those call sites need a `process.platform === "win32"` gate. import fs from "fs"; import os from "os"; import path from "path"; import crypto from "crypto"; import { execFileSync } from "child_process"; import type { StdioOptions } from "child_process"; /** Escape a string for inclusion as a literal in a `RegExp`. */ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Construct an Error with `cause` attached, without relying on the ES2022 * two-argument `Error` constructor (which the TS lib used in this workspace * does not declare). Runtime behaviour is identical — Node has supported * `Error.cause` since v16.9 — but the TypeScript signature for our target * lib (ES2020) only accepts a message, so assigning after construction * keeps the type-checker happy without dropping diagnostic context. */ function errorWithCause(message: string, cause: unknown): Error { const err = new Error(message); (err as Error & { cause?: unknown }).cause = cause; return err; } /** Shared exec options for all child_process calls in the test harness. * * `stdio: ["ignore", "pipe", "pipe"]` — on Node 20 CI, inheriting stderr * into the parent vitest worker races the worker-RPC channel (observed as * `Timeout calling "onTaskUpdate"`). We capture instead; success-path stderr * is dropped by design — use the generator's stdout for test assertions. * * `maxBuffer: 10MiB` — defensive; the default 1MiB can deadlock if the * generator logs verbose output. * * `timeout: 30000` — generators shell out to npx/tsx which cold-boots on * first run; 15s produced flakes on slow CI runners. */ // Frozen at runtime (the test-cleanup.test.ts unit asserts the inner array // is frozen so suites can't mutate it) but typed as `StdioOptions` — NOT a // narrower `readonly` tuple — so spreading `SAFE_EXEC_OPTS` into // `execFileSync(..., opts)` doesn't trip the readonly-vs-mutable-array // mismatch in the node `@types/node` `StdioOptions` signature. const SAFE_STDIO: StdioOptions = Object.freeze([ "ignore", "pipe", "pipe", ]) as StdioOptions; export const SAFE_EXEC_OPTS = Object.freeze({ encoding: "utf-8" as const, timeout: 30000, maxBuffer: 10 * 1024 * 1024, stdio: SAFE_STDIO, }); /** Build exec options scoped to a specific cwd. Shared helper so suites don't * recompute the same frozen `{...SAFE_EXEC_OPTS, cwd}` shape. The freeze is * defensive — callers that accidentally mutate would corrupt subsequent * invocations. */ export function execOptsFor(cwd: string) { return Object.freeze({ ...SAFE_EXEC_OPTS, cwd }); } /** Build the env forced for every `git` invocation in this module. * * Computed lazily per call so a test that temporarily mutates `process.env` * (e.g. to toggle `CI`) observes the mutation instead of a module-load-time * snapshot. * * Strips `GIT_*` environment overrides from the parent process * (`GIT_DIR`, `GIT_INDEX_FILE`, `GIT_WORK_TREE`, ...). If the developer or * a wrapping tool has set any of these, git would silently redirect our * `ls-files` / `diff` / `checkout` calls to a different repository, * corrupting the snapshot baseline. We scrub ALL `GIT_*` vars (allowlist * is simpler and safer than enumerating the dozen+ recognized vars). * * `LC_ALL=C` / `LANG=C` — git localizes its error strings; the benign * "pathspec did not match" detection is regex-based and must not depend on * the developer's locale. DO NOT remove LC_ALL — the `benign-pathspec` * regex below is English-only by design. */ function gitEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const [k, v] of Object.entries(process.env)) { if (k.startsWith("GIT_")) continue; env[k] = v; } env.LC_ALL = "C"; env.LANG = "C"; return env; } /** Shared exec options for git invocations inside this module. Captures * stderr (we interrogate it for benign-pathspec detection) and applies the * SAFE_EXEC_OPTS timeout / buffer bounds — a hung git (corrupt worktree, * signing prompt, network filesystem) would otherwise wedge the suite. * * Frozen for symmetry with `execOptsFor` — callers can't accidentally * mutate shared options. */ function gitExecOpts(cwd: string) { return Object.freeze({ ...SAFE_EXEC_OPTS, cwd, env: gitEnv(), }); } /** True when running under a recognized truthy CI env var. Accepts exactly * the common values — `"true"`, `"1"`, `"yes"` (case-insensitive). Anything * else (including unset, `""`, `"0"`, `"false"`, arbitrary strings) is off. * Allowlist over blocklist for strictness. */ function isCI(): boolean { const v = process.env.CI; if (!v) return false; const normalized = v.toLowerCase(); return normalized === "true" || normalized === "1" || normalized === "yes"; } /** Cross-process advisory lock via exclusive `mkdir`. Serializes every * `restoreFromGitHead` invocation across vitest forks so concurrent * `git checkout HEAD --` calls don't race for `.git/index.lock` (or for * it with an external git op like the pre-commit hook). Mirrors the * `proper-lockfile` approach but uses no deps — `fs.mkdirSync` is atomic * on POSIX and Windows and is the canonical primitive for this pattern. * * The lock directory lives in `os.tmpdir()` (not repo-local) so a crash * can't leave a stale lock inside the worktree where `git status` would * show it. Stale locks older than `STALE_LOCK_MS` are reaped before the * wait loop to heal any orphan left by a hard-killed previous run. * * Callers should always release via the returned `release()` in a * finally — a thrown git error must not leave the lock held. */ const GIT_LOCK_DIR = path.join( os.tmpdir(), "copilotkit-showcase-git-restore.lock", ); const GENERATED_DATA_LOCK_DIR = path.join( os.tmpdir(), "copilotkit-showcase-generated-data.lock", ); const STALE_LOCK_MS = 60_000; const WAIT_TIMEOUT_MS = 30_000; const WAIT_POLL_MS = 25; function reapStaleLock(lockDir: string): void { try { const st = fs.statSync(lockDir); if (Date.now() - st.mtimeMs > STALE_LOCK_MS) { try { fs.rmdirSync(lockDir); } catch { /* another process already reaped / released it */ } } } catch { /* not present */ } } function acquireLock(lockDir: string, label: string): () => void { const start = Date.now(); reapStaleLock(lockDir); for (;;) { try { fs.mkdirSync(lockDir); return () => { try { fs.rmdirSync(lockDir); } catch { /* already released */ } }; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; if (Date.now() - start > WAIT_TIMEOUT_MS) { throw new Error( `${label}: timed out after ${WAIT_TIMEOUT_MS}ms waiting for ${lockDir}.` + ` A previous run may have crashed holding the lock; remove the directory and retry.`, { cause: err }, ); } // Busy-wait at 25ms. Node lacks a sync sleep; a tight loop on a // 25ms granularity adds negligible CPU and matches proper-lockfile's // default retry cadence. const deadline = Date.now() + WAIT_POLL_MS; while (Date.now() < deadline) { /* spin */ } reapStaleLock(lockDir); } } } function acquireGitLock(): () => void { return acquireLock(GIT_LOCK_DIR, "acquireGitLock"); } export function acquireGeneratedDataLock(): () => void { return acquireLock(GENERATED_DATA_LOCK_DIR, "acquireGeneratedDataLock"); } export function withGeneratedDataLock(fn: () => T): T { const release = acquireGeneratedDataLock(); try { return fn(); } finally { release(); } } /** Split input paths into tracked vs untracked relative to HEAD. Uses * `git ls-files --error-unmatch` per path; exit 0 = tracked, exit 1 = * untracked. Any OTHER failure (ENOENT git binary, EACCES, corrupt * worktree, signal kill, timeout) is re-raised — otherwise a broken * environment gets silently treated as "everything untracked" and the * caller skips its destructive healing, locking in any drifted baseline. * * N complexity — we run one subprocess per path. Acceptable while the * snapshot scope is single-digit paths (3 workflow YAMLs / 2 data JSONs). * If scope grows materially, batch via a single `git ls-files -z -- ` * and diff the output against the input. */ function partitionTrackedPaths( repoRoot: string, paths: readonly string[], ): { tracked: string[]; untracked: string[] } { const tracked: string[] = []; const untracked: string[] = []; for (const p of paths) { try { execFileSync("git", ["ls-files", "--error-unmatch", "--", p], { ...gitExecOpts(repoRoot), stdio: ["ignore", "ignore", "pipe"], }); tracked.push(p); } catch (err) { const code = (err as { status?: number | null }).status; const errno = (err as NodeJS.ErrnoException).code; // SAFE_EXEC_OPTS pins `encoding: "utf-8"`, so stderr is a string here. const stderr = (err as { stderr?: string }).stderr ?? ""; // Exit 1 == pathspec unmatched (git's documented "not tracked" code). // Anything else (ENOENT = git missing, EACCES, ETIMEDOUT, SIGKILL with // status=null, exit 128 for corrupt repo, ...) is an environment // failure, not a tracked/untracked question. if (code === 1 && typeof errno !== "string") { untracked.push(p); continue; } throw errorWithCause( `partitionTrackedPaths: unexpected git ls-files failure for ${p}` + ` (exit ${code ?? "?"}, errno ${errno ?? "n/a"}): ${stderr.trim()}`, err, ); } } return { tracked, untracked }; } /** * Restore the given files from `git HEAD` (working tree refresh). * * @param repoRoot Absolute path to the git repository root. Used as `cwd` for * all git invocations so resolution doesn't depend on the * caller's current working directory. * @param paths Absolute or repo-relative paths to restore. Empty array is * a no-op. Mixed tracked/untracked lists are supported: this * function partitions them via `git ls-files --error-unmatch` * and operates ONLY on tracked paths. * * This is destructive for tracked paths — it clobbers any uncommitted * tracked-file edits. To prevent silently destroying a developer's in-progress * work: * * - On CI (`process.env.CI` set to a truthy value) we always heal, since * CI checkouts start clean and any drift is leaked state from a previous * test run. * - Off CI, we refuse to heal if the developer has uncommitted changes to * any of the tracked target paths, throwing an error that tells them to * stash, commit, or discard (`git checkout HEAD -- `) first. If * the tree is already clean wrt these paths, we heal. * * Tracked paths are restored via `git checkout HEAD -- `; untracked * paths are skipped (off-CI) or throw (on CI) per the drifted-baseline guard. * An entirely-untracked input list throws on CI and logs a warning off-CI. * Anything else — EACCES, git missing, corrupt worktree — is re-raised so the * test suite fails loudly rather than silently seeding a drifted baseline into * the subsequent snapshot. */ export function restoreFromGitHead( repoRoot: string, paths: readonly string[], ): void { if (paths.length === 0) return; // Serialize every git invocation against concurrent callers (other vitest // forks, pre-commit hooks) so `git checkout HEAD --` doesn't race for // `.git/index.lock`. Held across partition + diff + checkout + post-heal // diff so any intermediate state is consistent from the caller's POV. const release = acquireGitLock(); try { restoreFromGitHeadLocked(repoRoot, paths); } finally { release(); } } function restoreFromGitHeadLocked( repoRoot: string, paths: readonly string[], ): void { // Partition BEFORE any destructive op. Mixing untracked paths into // `git diff --quiet` would produce exit 128 (pathspec mismatch) and // cause the off-CI guard to mis-treat a legitimate dirty-tracked case // as "nothing to clobber", silently overwriting developer edits. const { tracked } = partitionTrackedPaths(repoRoot, paths); if (tracked.length === 0) { // Silent early-return here used to lock in a drifted baseline: a // previous run's generator output that was then committed (or a // developer moved the paths out of tracking entirely) would leave // `partitionTrackedPaths` returning an empty list and the caller // would happily snapshot the already-drifted content. Surface it so // the user investigates: on CI we throw outright; off-CI we warn // (developers may legitimately be exercising the test harness against // a tree that hasn't had those files committed yet). const msg = `restoreFromGitHead: no input path is tracked by git:\n` + paths.map((p) => ` ${p}`).join("\n") + `\nSnapshot baseline would be drifted — investigate before running tests.`; if (isCI()) { throw new Error(msg); } // eslint-disable-next-line no-console console.warn(`[test-cleanup] ${msg}`); return; } if (!isCI()) { // Off-CI guard: bail before clobbering developer edits. Only runs // against tracked paths so untracked path components can't mask a // dirty tracked file. try { execFileSync("git", ["diff", "--quiet", "HEAD", "--", ...tracked], { ...gitExecOpts(repoRoot), stdio: ["ignore", "pipe", "pipe"], }); } catch (err) { const code = (err as { status?: number }).status; // SAFE_EXEC_OPTS pins `encoding: "utf-8"`, so stderr is a string here. const stderr = (err as { stderr?: string }).stderr ?? ""; if (code === 1) { // `git diff --quiet` exits 1 when there ARE differences. throw errorWithCause( `restoreFromGitHead: refusing to overwrite uncommitted changes to:\n` + tracked.map((p) => ` ${p}`).join("\n") + `\nStash, commit, or discard these changes before running the test` + ` suite (e.g. \`git checkout HEAD -- \`).`, err, ); } // Any other failure is unexpected now that we've pre-filtered to // tracked paths. Re-raise with stderr attached so the caller sees // what git actually said. throw errorWithCause( `restoreFromGitHead: unexpected git diff failure (exit ${code ?? "?"}): ${stderr.trim()}`, err, ); } } try { execFileSync("git", ["checkout", "HEAD", "--", ...tracked], { ...gitExecOpts(repoRoot), stdio: ["ignore", "pipe", "pipe"], }); } catch (err) { // Belt-and-braces: handles a race where a tracked file is removed // between partitionTrackedPaths and this checkout (e.g. a parallel // rm from another harness touching the same tree). Realistically // unreachable in this suite — suites run under fileParallelism: // true, but their snapshot targets are disjoint (see the // PARALLELISM note above) and no concurrent harness removes // tracked files — but cheap to tolerate. Git produces this as // `error: pathspec '…' did not match any file(s) known to git` // (exit 1). Anything else — EACCES, git missing, corrupt worktree — // bubbles up so the suite fails loudly instead of seeding a drifted // baseline. const stderr = // SAFE_EXEC_OPTS pins `encoding: "utf-8"`, so stderr is a string here. (err as { stderr?: string }).stderr ?? ""; const isBenignPathspec = /did not match any file\(s\) known to git/.test( stderr, ); if (!isBenignPathspec) { const code = (err as { status?: number }).status; throw errorWithCause( `restoreFromGitHead: git checkout failed (exit ${code ?? "?"}): ${stderr.trim()}`, err, ); } } // Drifted-baseline guard (post-heal): after the `git checkout HEAD --` // above, every tracked path we just healed MUST now be byte-identical // to HEAD. If it isn't, something is mutating these files between our // checkout and here — an external process, a filesystem layer, or // (most likely in practice) a parallel test suite we haven't accounted // for. That's a drifted baseline: our subsequent snapshot would bake // in the drift and the restore loop would happily maintain it forever. // On CI this is a hard error; off-CI we let the snapshot proceed but // warn, since a developer may be iterating on a dirty tree. try { execFileSync("git", ["diff", "--quiet", "HEAD", "--", ...tracked], { ...gitExecOpts(repoRoot), stdio: ["ignore", "pipe", "pipe"], }); } catch (err) { const code = (err as { status?: number }).status; const stderr = (err as { stderr?: string }).stderr ?? ""; const msg = `restoreFromGitHead: drifted-baseline guard: post-heal diff failed` + ` (exit ${code ?? "?"}) for:\n` + tracked.map((p) => ` ${p}`).join("\n") + (stderr.trim() ? `\ngit stderr: ${stderr.trim()}` : ""); if (isCI()) { throw errorWithCause(msg, err); } // eslint-disable-next-line no-console console.warn(`[test-cleanup] ${msg}`); } } /** * Snapshots file content in-memory and restores any file that drifts. Uses * an atomic temp-file + rename only when the on-disk bytes differ from the * snapshot, which avoids touching mtime on clean runs and guarantees readers * never observe a truncated file. * * ASYMMETRY: `restore()` undoes drift to snapshotted files and re-creates * snapshotted files that were deleted. It does NOT remove files that tests * create which weren't in the snapshot — the test is responsible for cleaning * up new files it creates (the create-integration suite does this explicitly * via `fs.rmSync(TEST_DIR, {recursive: true, force: true})`). */ export class FileSnapshotRestorer { private readonly snapshots = new Map(); // Tracks whether `snapshot()` has been invoked — set unconditionally // before any throwing work so the double-snapshot guard fires even when // the path list is empty or every path is missing (size-based guards // would silently allow a second call in that case). private snapshotted = false; constructor(private readonly paths: readonly string[]) {} /** Capture current content for every path that exists on disk. * * Also sweeps any stragger atomic-write temp files in each path's parent * directory — `...tmp` — that a prior crashed run left * behind. Without the sweep, those tmp files accumulate in tracked * directories (`.github/workflows/`, `showcase/shell/src/data/`) and * reintroduce the exact pollution this harness exists to prevent. * * Throws if called after a previous snapshot — snapshotting twice silently * discards the original baseline and is almost always a bug. Use a fresh * restorer instance per test suite. */ snapshot(): void { if (this.snapshotted) { throw new Error( "FileSnapshotRestorer.snapshot() called on a restorer that already" + " has a snapshot. Construct a new instance per suite.", ); } this.snapshotted = true; // Sweep atomic-write temp stragglers BEFORE capturing. The sweep is // scoped per-basename: for each snapshot target, we look ONLY for // `..<16hex>.tmp` stragglers of that specific target. Earlier // revisions used a generic `/^\..+\.[0-9a-f]{16}\.tmp$/` regex which // would match any same-shaped tmp file in the directory — a landmine in // shared dirs like `.github/workflows/` where an unrelated tool could // have created a similarly-named file. Tightened to per-basename scope // to preserve unrelated tmp files in shared directories. const bucketed = new Map>(); for (const p of this.paths) { const dir = path.dirname(p); let bucket = bucketed.get(dir); if (!bucket) { bucket = new Set(); bucketed.set(dir, bucket); } bucket.add(path.basename(p)); } for (const [dir, basenames] of bucketed) { this.sweepTmpStragglers(dir, basenames); } for (const p of this.paths) { if (fs.existsSync(p)) { this.snapshots.set(p, fs.readFileSync(p)); } } } /** Remove `..<16hex>.tmp` stragglers in `dir`, where `basename` * is drawn from the snapshot target list for that directory. Tolerant of * a missing directory (parent dir may not exist yet in a fresh clone). * * The pattern matches files produced by `atomicWrite` for the specific * targets we're about to snapshot, and ONLY those — stragglers for * unrelated files in the same directory are left alone. EACCES/EBUSY on * a specific unlink is debug-logged (`DEBUG_TEST_CLEANUP=1`) so hangs * are diagnosable, then swallowed per best-effort sweep semantics. */ private sweepTmpStragglers( dir: string, basenames: ReadonlySet, ): void { let entries: string[]; try { entries = fs.readdirSync(dir); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return; throw err; } // Escape each target basename for literal regex inclusion, then build // an alternation. `crypto.randomBytes(8)` is 16 hex chars. const escaped = Array.from(basenames, escapeRegExp); const re = new RegExp(`^\\.(?:${escaped.join("|")})\\.[0-9a-f]{16}\\.tmp$`); for (const name of entries) { if (!re.test(name)) continue; const target = path.join(dir, name); try { fs.unlinkSync(target); } catch (err) { if (process.env.DEBUG_TEST_CLEANUP) { const code = (err as NodeJS.ErrnoException).code ?? "?"; // eslint-disable-next-line no-console console.warn( `[test-cleanup] sweep: unlink ${target} failed (${code})`, ); } /* best effort */ } } } /** * Restore every snapshotted path. If the on-disk bytes match the snapshot, * no write happens. Writes are atomic: we write the snapshot bytes to a * sibling temp file and `fs.renameSync` it into place, so readers never * observe a truncated intermediate state. Missing files are re-created * from the snapshot (including re-creating parent directories if they * were deleted). Any unexpected read error (EACCES, EISDIR, EBUSY, ...) * propagates. */ restore(): void { for (const [p, content] of this.snapshots) { let current: Buffer | null; try { current = fs.readFileSync(p); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { current = null; } else { throw err; } } if (current === null || !current.equals(content)) { this.atomicWrite(p, content); } } } /** Atomic write via temp file + rename. Creates parent dir if missing. * * Temp filename uses `crypto.randomBytes(8).toString("hex")` so two * concurrent writes from the same process can't collide (they would with * `Date.now()` at ms resolution) and so the `snapshot()` sweep regex can * match stragglers unambiguously. */ private atomicWrite(target: string, content: Buffer): void { const dir = path.dirname(target); const ensureDir = () => { try { fs.mkdirSync(dir, { recursive: true }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; } }; const write = () => { // Temp file sits in the same directory so rename is atomic (same // filesystem). `os.tmpdir()` could be a different mount. const suffix = crypto.randomBytes(8).toString("hex"); const tmp = path.join(dir, `.${path.basename(target)}.${suffix}.tmp`); fs.writeFileSync(tmp, content); try { fs.renameSync(tmp, target); } catch (err) { // Best effort — don't leak temp files on rename failure. try { fs.unlinkSync(tmp); } catch { /* swallow */ } throw err; } }; try { write(); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { // Parent directory was removed between snapshot and restore — create // it and retry once. ensureDir(); write(); } else { throw err; } } } /** Expose the snapshot map (read-only) so tests can assert against it. */ get snapshotMap(): ReadonlyMap { return this.snapshots; } }