Skip to content
Merged
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
6 changes: 6 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,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
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();
}
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
77 changes: 75 additions & 2 deletions tests/helpers/workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,61 @@
* @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";

/**
* @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;
}

/**
* 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<string>} 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.
Expand All @@ -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<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
.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 */
}
})
);
}
4 changes: 2 additions & 2 deletions tests/integration.module.test.vitest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions tests/project-metadata-edge.test.vitest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down
Loading