664 lines
26 KiB
TypeScript
664 lines
26 KiB
TypeScript
// 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 -- <paths>` 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<T>(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 -- <paths>`
|
|
* 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 -- <paths>`) first. If
|
|
* the tree is already clean wrt these paths, we heal.
|
|
*
|
|
* Tracked paths are restored via `git checkout HEAD -- <paths>`; 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 -- <paths>\`).`,
|
|
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<string, Buffer>();
|
|
// 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 — `.<basename>.<hex>.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
|
|
// `.<basename>.<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<string, Set<string>>();
|
|
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 `.<basename>.<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<string>,
|
|
): 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<string, Buffer> {
|
|
return this.snapshots;
|
|
}
|
|
}
|