FN-5840: harden test project teardown cleanup

Tighten test fixture cleanup so temporary test directories are reliably removed.

- track created test project directories and install process exit/beforeExit backstop cleanup hooks
- make destroyTestProject resilient with async rm plus rmSync fallback and tracked-dir removal
- add regression tests for sqlite wal/shm sibling cleanup and tracked-directory drain behavior
- make db lock-child teardown remove children from tracking during iteration

Files changed:
 packages/core/src/__tests__/db.test.ts           | 10 +++--
 packages/core/src/__tests__/test-project.test.ts | 34 ++++++++++++++-
 packages/core/src/__tests__/test-project.ts      | 54 +++++++++++++++++++++++-
 3 files changed, 92 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-5840

Fusion-Task-Lineage: 0977be1d-8334-413d-a652-318584d7058b
This commit is contained in:
gsxdsm
2026-06-01 16:03:05 -07:00
parent bf37ce89f6
commit a312acd754
3 changed files with 92 additions and 6 deletions

View File

@@ -71,14 +71,18 @@ function removeTrackedTmpDirSync(dir: string | undefined): void {
const activeLockChildren = new Set<ChildProcessWithoutNullStreams>();
function killLockChildrenSync(): void {
for (const child of activeLockChildren) {
const children = Array.from(activeLockChildren);
for (const child of children) {
try {
if (child.exitCode === null && !child.killed) child.kill("SIGKILL");
if (child.exitCode === null && !child.killed) {
child.kill("SIGKILL");
}
} catch {
// best-effort
} finally {
activeLockChildren.delete(child);
}
}
activeLockChildren.clear();
}
function cleanupTmpDirsSync(): void {

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, existsSync, readdirSync } from "node:fs";
import { mkdtempSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { TaskStore } from "../store.js";
import {
__getTrackedTestProjectDirsForTests,
createTestProject,
destroyTestProject,
seedTasks,
@@ -68,6 +69,22 @@ describe("test-project fixture", () => {
expect(existsSync(fixture.rootDir)).toBe(false);
});
it("destroyTestProject() removes directories containing sqlite wal/shm siblings", async () => {
const dir = mkdtempSync(join(tmpdir(), "fusion-test-project-wal-"));
extraDirs.add(dir);
const fusionDir = join(dir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "db");
writeFileSync(join(fusionDir, "fusion.db-wal"), "wal");
writeFileSync(join(fusionDir, "fusion.db-shm"), "shm");
await destroyTestProject(dir);
extraDirs.delete(dir);
expect(existsSync(dir)).toBe(false);
});
it(
"supports multiple isolated projects without cross-interference",
async () => {
@@ -160,4 +177,19 @@ describe("test-project fixture", () => {
expect(tasks).toHaveLength(4);
});
it("cleanup() drains tracked backstop directories", async () => {
const fixture = await createFixture();
const trackedDirs = __getTrackedTestProjectDirsForTests();
expect(trackedDirs.has(fixture.rootDir)).toBe(true);
expect(trackedDirs.has(fixture.globalDir)).toBe(true);
await fixture.cleanup();
expect(trackedDirs.has(fixture.rootDir)).toBe(false);
expect(trackedDirs.has(fixture.globalDir)).toBe(false);
expect(existsSync(fixture.rootDir)).toBe(false);
expect(existsSync(fixture.globalDir)).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { mkdtempSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
@@ -26,6 +26,15 @@ export interface TestProjectFixture {
cleanup: () => Promise<void>;
}
const TEST_PROJECT_RM_OPTIONS = {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 50,
} as const;
const TRACKED_TEST_PROJECT_DIRS = new Set<string>();
const TEST_PROJECT_CLEANUP_HOOK_KEY = Symbol.for("fusion.core.test-project.cleanup-hooks-installed");
function assertAbsolutePath(pathValue: string, label: string): void {
if (!isAbsolute(pathValue)) {
throw new Error(`${label} must be an absolute path`);
@@ -79,6 +88,11 @@ export async function createTestProject(
assertAbsolutePath(rootDir, "rootDir");
assertAbsolutePath(globalDir, "globalSettingsDir");
TRACKED_TEST_PROJECT_DIRS.add(rootDir);
if (ownsGlobalDir) {
TRACKED_TEST_PROJECT_DIRS.add(globalDir);
}
let store: TaskStore | undefined;
try {
@@ -199,5 +213,41 @@ export async function seedTasks(store: TaskStore, count = 3): Promise<Task[]> {
*/
export async function destroyTestProject(dir: string): Promise<void> {
assertAbsolutePath(dir, "dir");
await rm(dir, { recursive: true, force: true });
try {
await rm(dir, TEST_PROJECT_RM_OPTIONS);
} catch {
try {
rmSync(dir, TEST_PROJECT_RM_OPTIONS);
} catch {
// best-effort fallback during teardown
}
} finally {
TRACKED_TEST_PROJECT_DIRS.delete(dir);
}
}
function cleanupTrackedTestProjectDirsSync(): void {
const cleanup = Array.from(TRACKED_TEST_PROJECT_DIRS);
for (const dir of cleanup) {
try {
rmSync(dir, TEST_PROJECT_RM_OPTIONS);
} catch {
// best-effort fallback during process teardown
} finally {
TRACKED_TEST_PROJECT_DIRS.delete(dir);
}
}
}
const processWithCleanupFlag = process as typeof process & {
[TEST_PROJECT_CLEANUP_HOOK_KEY]?: boolean;
};
if (!processWithCleanupFlag[TEST_PROJECT_CLEANUP_HOOK_KEY]) {
process.once("beforeExit", cleanupTrackedTestProjectDirsSync);
process.once("exit", cleanupTrackedTestProjectDirsSync);
processWithCleanupFlag[TEST_PROJECT_CLEANUP_HOOK_KEY] = true;
}
export function __getTrackedTestProjectDirsForTests(): Set<string> {
return TRACKED_TEST_PROJECT_DIRS;
}