diff --git a/.configs/vitest.config.mjs b/.configs/vitest.config.mjs index ce71ffb..426b385 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,11 @@ export default defineConfig({ test: { environment: "node", include: ["tests/**/*.test.vitest.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 // "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/package-lock.json b/package-lock.json index 85669b1..a4f6f08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/fix-headers", - "version": "1.3.9", + "version": "1.3.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/fix-headers", - "version": "1.3.9", + "version": "1.3.10", "license": "Apache-2.0", "dependencies": { "ignore": "^7.0.5" @@ -548,9 +548,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index f1290f4..476a514 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/fix-headers", - "version": "1.3.9", + "version": "1.3.10", "description": "Multi-language project header normalizer with auto-detection and override support.", "type": "module", "main": "./index.cjs", diff --git a/tests/helpers/workspace.mjs b/tests/helpers/workspace.mjs index 4de9a09..a98b840 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, rm, 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"; /** @@ -19,6 +20,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,11 +41,31 @@ 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; } +/** + * 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. @@ -53,3 +88,41 @@ 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 + .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 */ + } + }) + ); +} 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 {