Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .configs/vitest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions .configs/vitest.globalSetup.mjs
Original file line number Diff line number Diff line change
@@ -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<void>} */
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<void>} */
export async function teardown() {
await reapStaleWorkspaces();
}
54 changes: 52 additions & 2 deletions tests/helpers/workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,36 @@
* @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";

/**
* @fileoverview Test workspace helpers for creating isolated project fixtures.
* @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.
* @returns {Promise<string>} Absolute workspace 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;
}
Expand All @@ -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<void>} 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 */
}
})
);
}
Loading