From f9cda2923e174db63c0444494081fb6de263cdac Mon Sep 17 00:00:00 2001 From: Shinrai Date: Tue, 11 Aug 2026 06:31:22 -0700 Subject: [PATCH 1/3] test(fixtures): keep fixtures in repo tmp/ + age-based orphan reaper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test fixtures were written to /../tmp-fix-headers-tests (the repos root) and only cleaned on the happy path, so failed/interrupted runs leaked git-repo fixtures into the shared repos root — hundreds accumulated. Anchor the fixture root to the repo's own gitignored tmp/ (import.meta.dirname-relative, cwd-independent), and add a vitest globalSetup that reaps only STALE (mtime older than 1h) orphans so a concurrent in-flight run's fresh fixtures are never touched. --- .configs/vitest.config.mjs | 5 +++ .configs/vitest.globalSetup.mjs | 23 ++++++++++++++ tests/helpers/workspace.mjs | 54 +++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .configs/vitest.globalSetup.mjs 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 */ + } + }) + ); +} From 8f717786e7ec8012c29fd119a7ca43650024004b Mon Sep 17 00:00:00 2001 From: Shinrai Date: Tue, 18 Aug 2026 14:34:24 -0700 Subject: [PATCH 2/3] fix(tests): give fallback-path tests a workspace with no project ancestry Anchoring FIXTURE_ROOT under this repo's own tmp/ (to stop leaked fixtures sprawling into the shared repos root) means every fixture now sits under this repo's own package.json and .git. That breaks the two tests that assert the "nothing detected" fallback path: unknown project language now resolves to "node" (finds this repo's package.json walking up), and unknown git author now resolves to the real local identity (finds this repo's .git config, which GIT_CONFIG_GLOBAL/HOME overrides can't touch since those only affect the global scope, not a discovered local repo). Add createIsolatedWorkspace(), using the OS temp directory instead of FIXTURE_ROOT, for the two tests that need genuinely zero project ancestry. Everything else keeps using createWorkspace() (in-repo tmp/, leak-contained, age-reaped) since it doesn't care about ancestor isolation. --- tests/helpers/workspace.mjs | 23 ++++++++++++++++++++- tests/integration.module.test.vitest.mjs | 4 ++-- tests/project-metadata-edge.test.vitest.mjs | 4 ++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/helpers/workspace.mjs b/tests/helpers/workspace.mjs index aeb8a02..ea9bcf1 100644 --- a/tests/helpers/workspace.mjs +++ b/tests/helpers/workspace.mjs @@ -11,7 +11,8 @@ * @Copyright: Copyright (c) 2026-2026 Catalyzed Motivation Inc. All rights reserved. */ -import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; /** @@ -45,6 +46,26 @@ export async function createWorkspace(name) { return workspacePath; } +/** + * Creates a workspace with NO ancestor `package.json` and NO ancestor `.git` — + * for tests that assert the "nothing detected" fallback path (unknown project + * language, unknown git author). {@link createWorkspace}'s fixture root now lives + * under this repo's own `tmp/` specifically so leaked fixtures don't sprawl into + * the shared repos root — but that means every fixture it creates sits under this + * repo's own `package.json` and `.git`, which upward marker/config search will + * always find. A fallback-path test needs a workspace with genuinely zero project + * ancestry, which no location under this repo can provide by definition. The OS + * temp directory is the right tool for that: it's outside any project tree, so it + * doesn't reintroduce the original clutter problem (a human-browsed shared repos + * root), and each call still cleans up via {@link cleanupWorkspace} same as any + * other workspace. + * @param {string} name - Workspace name prefix. + * @returns {Promise} Absolute workspace path. + */ +export async function createIsolatedWorkspace(name) { + return mkdtemp(join(tmpdir(), `${name}-`)); +} + /** * Writes a UTF-8 file ensuring parent folders are created. * @param {string} filePath - Absolute file path. diff --git a/tests/integration.module.test.vitest.mjs b/tests/integration.module.test.vitest.mjs index 2939564..74a618b 100644 --- a/tests/integration.module.test.vitest.mjs +++ b/tests/integration.module.test.vitest.mjs @@ -24,7 +24,7 @@ import { detectProjectFromMarkers, resolveProjectMetadata } from "../src/detect/ import { readFileDates, findProjectRoot, pathExists, readTextIfExists, walkFiles } from "../src/utils/fs.mjs"; import { detectGitAuthor, getGitCreationDate, getGitLastModifiedDate, runGit } from "../src/utils/git.mjs"; -import { cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs"; +import { cleanupWorkspace, createIsolatedWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs"; const execFileAsync = promisify(execFile); @@ -155,7 +155,7 @@ describe("module integration and coverage", () => { expect(markerDetection.marker).toBe("package.json"); expect(markerDetection.projectName).toBe("fixture-node-project"); - const unknownWorkspace = await createWorkspace("detection-unknown"); + const unknownWorkspace = await createIsolatedWorkspace("detection-unknown"); workspaces.push(unknownWorkspace); await writeWorkspaceFile(join(unknownWorkspace, "file.txt"), "hello\n"); const unknownDetection = await detectProjectFromMarkers(unknownWorkspace); diff --git a/tests/project-metadata-edge.test.vitest.mjs b/tests/project-metadata-edge.test.vitest.mjs index 7f37f48..bcfb33a 100644 --- a/tests/project-metadata-edge.test.vitest.mjs +++ b/tests/project-metadata-edge.test.vitest.mjs @@ -17,7 +17,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { detector as nodeDetector } from "../src/detectors/node.mjs"; import { detectProjectFromMarkers, resolveProjectMetadata } from "../src/detect/project.mjs"; -import { cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs"; +import { cleanupWorkspace, createIsolatedWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs"; const execFileAsync = promisify(execFile); @@ -150,7 +150,7 @@ describe("project metadata edge branches", () => { }); it("uses unknown author/email fallback when git identity is unavailable", async () => { - const workspace = await createWorkspace("project-unknown-author"); + const workspace = await createIsolatedWorkspace("project-unknown-author"); const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; const previousHome = process.env.HOME; try { From eaa9fd4511512c8a648d41bf9496c769f7e7def2 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Tue, 18 Aug 2026 15:58:50 -0700 Subject: [PATCH 3/3] fix(tests): skip non-directory entries in stale-fixture reaper, fix stale comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reapStaleWorkspaces() requested withFileTypes but never checked isDirectory() before reaping an entry, so any unexpected non-directory file under FIXTURE_ROOT would also get swept up. Filter to directories only. Also correct vitest.config.mjs's globalSetup comment, which said the setup "wipes" the fixture root before/after the run — it only reaps STALE (age- guarded) directories, never a whole-root wipe. Addresses review comments on PR #28. --- .configs/vitest.config.mjs | 5 +++-- tests/helpers/workspace.mjs | 20 +++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.configs/vitest.config.mjs b/.configs/vitest.config.mjs index e283d93..426b385 100644 --- a/.configs/vitest.config.mjs +++ b/.configs/vitest.config.mjs @@ -23,8 +23,9 @@ 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). + // Reap only STALE fixture directories (age-guarded) before/after the run so + // orphaned fixtures from a crashed/interrupted run self-heal, without touching + // a concurrent run's fresh fixtures (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 diff --git a/tests/helpers/workspace.mjs b/tests/helpers/workspace.mjs index ea9bcf1..a98b840 100644 --- a/tests/helpers/workspace.mjs +++ b/tests/helpers/workspace.mjs @@ -113,14 +113,16 @@ export async function reapStaleWorkspaces(maxAgeMs = 60 * 60 * 1000) { } 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 */ - } - }) + entries + .filter((entry) => entry.isDirectory()) + .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 */ + } + }) ); }