diff --git a/.configs/vitest.config.mjs b/.configs/vitest.config.mjs index ce71ffb..e283d93 100644 --- a/.configs/vitest.config.mjs +++ b/.configs/vitest.config.mjs @@ -11,6 +11,7 @@ * @Copyright: Copyright (c) 2026-2026 Catalyzed Motivation Inc. All rights reserved. */ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; /** @@ -22,6 +23,10 @@ export default defineConfig({ test: { environment: "node", include: ["tests/**/*.test.vitest.mjs"], + // Wipe the fixture root before/after the run so orphaned fixtures from a + // crashed/interrupted run self-heal (see tests/helpers/workspace.mjs). + // Absolute path so it resolves regardless of vitest's root. + globalSetup: [fileURLToPath(new URL("./vitest.globalSetup.mjs", import.meta.url))], // "dot" keeps CI logs to one character per test file instead of a full // "RUN vX.Y.Z" + per-file pass/fail block for every file — vitest's // non-interactive fallback (no TTY to redraw) otherwise reprints that diff --git a/.configs/vitest.globalSetup.mjs b/.configs/vitest.globalSetup.mjs new file mode 100644 index 0000000..d3f8b4c --- /dev/null +++ b/.configs/vitest.globalSetup.mjs @@ -0,0 +1,23 @@ +/** + * @fileoverview Vitest global setup for the fix-headers test suite. + * + * Reaps ONLY stale fixture directories (see reapStaleWorkspaces in + * tests/helpers/workspace.mjs) before and after the run, so orphans from a + * previously crashed/interrupted run self-heal WITHOUT touching fixtures a + * concurrent, still-running suite (another shard, or CI + local at once) is + * actively using — those have a fresh mtime and are left alone. Per-test cleanup + * (cleanupWorkspace) still handles the happy path; this is the crash-path backstop. + * @module fix-headers/vitest-global-setup + */ + +import { reapStaleWorkspaces } from "../tests/helpers/workspace.mjs"; + +/** Reap orphans left by a prior aborted run (age-guarded). @returns {Promise} */ +export async function setup() { + await reapStaleWorkspaces(); +} + +/** Reap any now-stale orphans on the way out (never this run's or a peer's live fixtures). @returns {Promise} */ +export async function teardown() { + await reapStaleWorkspaces(); +} diff --git a/tests/helpers/workspace.mjs b/tests/helpers/workspace.mjs index 4de9a09..aeb8a02 100644 --- a/tests/helpers/workspace.mjs +++ b/tests/helpers/workspace.mjs @@ -11,7 +11,7 @@ * @Copyright: Copyright (c) 2026-2026 Catalyzed Motivation Inc. All rights reserved. */ -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; /** @@ -19,6 +19,20 @@ import { join, resolve } from "node:path"; * @module fix-headers/tests/helpers/workspace */ +/** + * Root for every test fixture: the repo's OWN gitignored `tmp/` directory. + * Anchored to this file's location (not `process.cwd()`) so it's cwd-independent. + * + * Previously this was `join(resolve(process.cwd(), ".."), "tmp-fix-headers-tests")`, + * which resolved to the repo's PARENT (the shared repos root). Combined with + * unique-per-run names and cleanup that only runs on the happy path, every + * failed/interrupted run leaked its fixtures there, sprawling hundreds of orphaned + * git repos into the repos root. Keeping fixtures inside the gitignored `tmp/` + * contains any leak to a spot that's invisible to git and wiped by cleanup below. + * @type {string} + */ +export const FIXTURE_ROOT = resolve(import.meta.dirname, "..", "..", "tmp", "fix-headers-tests"); + /** * Creates an isolated test workspace under the project-local tmp directory. * @param {string} name - Workspace name suffix. @@ -26,7 +40,7 @@ import { join, resolve } from "node:path"; */ export async function createWorkspace(name) { const directoryName = `${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const workspacePath = join(resolve(process.cwd(), ".."), "tmp-fix-headers-tests", directoryName); + const workspacePath = join(FIXTURE_ROOT, directoryName); await mkdir(workspacePath, { recursive: true }); return workspacePath; } @@ -53,3 +67,39 @@ export async function writeWorkspaceFile(filePath, content) { export async function cleanupWorkspace(workspacePath) { await rm(workspacePath, { recursive: true, force: true }); } + +/** + * Reaps only STALE fixture directories under {@link FIXTURE_ROOT} — those whose + * mtime is older than `maxAgeMs`. This self-heals orphans from a crashed/interrupted + * run WITHOUT deleting fixtures a concurrent, still-running suite is actively using + * (an in-flight run's fixtures have a fresh mtime, so they're never reaped). A + * whole-root wipe would corrupt overlapping runs — CI + local, or two shards — so + * age is the guard. Per-test {@link cleanupWorkspace} still removes each fixture on + * the happy path; this is only the backstop for the crash/timeout path. + * + * The default 1h threshold is far longer than any real run of this suite (seconds) + * yet short enough to keep orphans from piling up; override for slower environments. + * Missing root and races (a dir removed mid-sweep by another run) are ignored. + * @param {number} [maxAgeMs=3600000] - Age past which a fixture counts as orphaned (default 1h). + * @returns {Promise} Completion promise. + */ +export async function reapStaleWorkspaces(maxAgeMs = 60 * 60 * 1000) { + let entries; + try { + entries = await readdir(FIXTURE_ROOT, { withFileTypes: true }); + } catch { + return; // fixture root doesn't exist yet — nothing to reap + } + const cutoff = Date.now() - maxAgeMs; + await Promise.all( + entries.map(async (entry) => { + const full = join(FIXTURE_ROOT, entry.name); + try { + const info = await stat(full); + if (info.mtimeMs < cutoff) await rm(full, { recursive: true, force: true }); + } catch { + /* vanished mid-sweep (a concurrent run cleaned it) — fine */ + } + }) + ); +}