test(FN-1115): add reusable test project fixture coverage

- Add a shared test-project fixture module that provisions isolated TaskStore-backed projects with cleanup helpers
- Implement seedTasks and destroyTestProject utilities for realistic task data setup and teardown in tests
- Add Vitest coverage for fixture initialization, seeded task counts, project isolation, custom settings, and real TaskStore operations
This commit is contained in:
gsxdsm
2026-04-07 22:25:16 -07:00
parent 1982362fb5
commit 04107abe09
2 changed files with 309 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
import { mkdtempSync, existsSync } from "node:fs";
import { rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { TaskStore } from "../store.js";
import {
createTestProject,
destroyTestProject,
seedTasks,
type TestProjectFixture,
} from "./test-project.js";
const fixtures: TestProjectFixture[] = [];
const extraDirs = new Set<string>();
async function createFixture(options?: Parameters<typeof createTestProject>[0]): Promise<TestProjectFixture> {
const fixture = await createTestProject(options);
fixtures.push(fixture);
return fixture;
}
afterEach(async () => {
await Promise.allSettled(fixtures.splice(0).map((fixture) => fixture.cleanup()));
await Promise.allSettled([...extraDirs].map((dir) => rm(dir, { recursive: true, force: true })));
extraDirs.clear();
});
describe("test-project fixture", () => {
it("createTestProject() returns a valid isolated project with initialized .fusion structure", async () => {
const fixture = await createFixture();
expect(isAbsolute(fixture.rootDir)).toBe(true);
expect(isAbsolute(fixture.globalDir)).toBe(true);
expect(existsSync(join(fixture.rootDir, ".fusion"))).toBe(true);
expect(existsSync(join(fixture.rootDir, ".fusion", "fusion.db"))).toBe(true);
expect(existsSync(join(fixture.rootDir, ".fusion", "config.json"))).toBe(true);
expect(existsSync(join(fixture.rootDir, ".fusion", "tasks"))).toBe(true);
expect(existsSync(join(fixture.rootDir, ".fusion", "memory.md"))).toBe(true);
const configRaw = await readFile(join(fixture.rootDir, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.nextId).toBe(1);
expect(config.settings.taskPrefix).toBe("FN");
const tasks = await fixture.store.listTasks();
expect(tasks).toHaveLength(0);
});
it("seedTasks(store, 3) creates exactly 3 tasks", async () => {
const fixture = await createFixture();
const seeded = await seedTasks(fixture.store, 3);
const tasks = await fixture.store.listTasks();
expect(seeded).toHaveLength(3);
expect(tasks).toHaveLength(3);
});
it("destroyTestProject() removes the project directory recursively", async () => {
const fixture = await createFixture();
fixture.store.close();
await destroyTestProject(fixture.rootDir);
expect(existsSync(fixture.rootDir)).toBe(false);
});
it("supports multiple isolated projects without cross-interference", async () => {
const first = await createFixture({ seedTasks: 1 });
const second = await createFixture({ seedTasks: 2 });
expect(first.rootDir).not.toBe(second.rootDir);
expect(first.globalDir).not.toBe(second.globalDir);
const firstTasks = await first.store.listTasks();
const secondTasks = await second.store.listTasks();
expect(firstTasks).toHaveLength(1);
expect(secondTasks).toHaveLength(2);
expect(firstTasks[0].id).toBe("FN-001");
expect(secondTasks[0].id).toBe("FN-001");
});
it("applies custom settings and honors a custom global settings directory", async () => {
const customGlobalDir = mkdtempSync(join(tmpdir(), "fusion-custom-global-"));
extraDirs.add(customGlobalDir);
const fixture = await createFixture({
globalSettingsDir: customGlobalDir,
settings: {
maxConcurrent: 7,
taskPrefix: "TP",
themeMode: "light",
},
});
const settings = await fixture.store.getSettings();
expect(fixture.globalDir).toBe(customGlobalDir);
expect(settings.maxConcurrent).toBe(7);
expect(settings.taskPrefix).toBe("TP");
expect(settings.themeMode).toBe("light");
expect(existsSync(join(customGlobalDir, "settings.json"))).toBe(true);
});
it("returns a real TaskStore instance that can create, list, and fetch tasks", async () => {
const fixture = await createFixture();
expect(fixture.store).toBeInstanceOf(TaskStore);
const created = await fixture.store.createTask({
description: "Validate real TaskStore operations in fixture",
});
const listed = await fixture.store.listTasks();
const fetched = await fixture.store.getTask(created.id);
expect(listed).toHaveLength(1);
expect(listed[0].id).toBe(created.id);
expect(fetched.description).toContain("Validate real TaskStore operations");
});
it("createTestProject({ seedTasks }) pre-seeds tasks during setup", async () => {
const fixture = await createFixture({ seedTasks: 4 });
const tasks = await fixture.store.listTasks();
expect(tasks).toHaveLength(4);
});
});

View File

@@ -0,0 +1,176 @@
import { mkdtempSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { TaskStore } from "../store.js";
import {
DEFAULT_PROJECT_SETTINGS,
GLOBAL_SETTINGS_KEYS,
type GlobalSettings,
type Settings,
type Task,
} from "../types.js";
export interface CreateTestProjectOptions {
seedTasks?: number;
globalSettingsDir?: string;
settings?: Partial<Settings>;
}
export interface TestProjectFixture {
rootDir: string;
store: TaskStore;
globalDir: string;
cleanup: () => Promise<void>;
}
function assertAbsolutePath(pathValue: string, label: string): void {
if (!isAbsolute(pathValue)) {
throw new Error(`${label} must be an absolute path`);
}
}
function isGlobalSettingsKey(key: string): key is keyof GlobalSettings {
return (GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key);
}
function splitSettings(settings?: Partial<Settings>): {
globalPatch: Partial<GlobalSettings>;
projectPatch: Partial<Settings>;
} {
const globalPatch: Partial<GlobalSettings> = {};
const projectPatch: Partial<Settings> = { ...DEFAULT_PROJECT_SETTINGS };
for (const [key, value] of Object.entries(settings ?? {})) {
if (isGlobalSettingsKey(key)) {
(globalPatch as Record<string, unknown>)[key] = value;
continue;
}
(projectPatch as Record<string, unknown>)[key] = value;
}
return { globalPatch, projectPatch };
}
/**
* Create an isolated temporary test project with a real TaskStore + SQLite DB.
*/
export async function createTestProject(
options: CreateTestProjectOptions = {},
): Promise<TestProjectFixture> {
const rootDir = mkdtempSync(join(tmpdir(), "fusion-test-project-"));
const globalDir = options.globalSettingsDir
? options.globalSettingsDir
: mkdtempSync(join(tmpdir(), "fusion-test-global-"));
assertAbsolutePath(rootDir, "rootDir");
assertAbsolutePath(globalDir, "globalSettingsDir");
await mkdir(globalDir, { recursive: true });
const store = new TaskStore(rootDir, globalDir);
await store.init();
const { globalPatch, projectPatch } = splitSettings(options.settings);
await store.updateSettings(projectPatch);
if (Object.keys(globalPatch).length > 0) {
await store.updateGlobalSettings(globalPatch);
}
const requestedSeedCount = Math.max(0, Math.floor(options.seedTasks ?? 0));
if (requestedSeedCount > 0) {
await seedTasks(store, requestedSeedCount);
}
const cleanup = async () => {
store.close();
await destroyTestProject(rootDir);
if (!options.globalSettingsDir) {
await destroyTestProject(globalDir);
}
};
return { rootDir, store, globalDir, cleanup };
}
/**
* Seed a TaskStore with realistic sample tasks in varied columns.
*/
export async function seedTasks(store: TaskStore, count = 3): Promise<Task[]> {
const templates = [
{
title: "Stabilize webhook retries",
description:
"Ensure webhook delivery retries are tracked and surfaced in diagnostics.",
finalColumn: "todo" as const,
},
{
title: "Backfill mission metrics",
description:
"Calculate missing mission rollup metrics and persist migration-safe defaults.",
finalColumn: "in-progress" as const,
},
{
title: "Validate PR badge freshness",
description:
"Confirm websocket badge snapshots never override newer REST refresh responses.",
finalColumn: "in-review" as const,
},
{
title: "Draft follow-up triage",
description:
"Collect edge cases discovered during rollout and capture them in triage.",
finalColumn: "triage" as const,
},
];
const seeded: Task[] = [];
const total = Math.max(0, Math.floor(count));
for (let i = 0; i < total; i++) {
const template = templates[i % templates.length];
const dependency = seeded.length > 0 && i % 2 === 1 ? [seeded[seeded.length - 1].id] : [];
const created = await store.createTask({
title: `${template.title} ${i + 1}`,
description: `${template.description} [seed-${i + 1}]`,
column: "todo",
dependencies: dependency,
});
// Force step hydration from PROMPT.md into persisted task metadata.
await store.updateStep(created.id, 0, "in-progress");
const targetColumn = template.finalColumn;
if (targetColumn === "in-progress" || targetColumn === "in-review") {
await store.moveTask(created.id, "in-progress");
}
if (targetColumn === "in-review") {
await store.moveTask(created.id, "in-review");
}
if (targetColumn === "triage") {
await store.moveTask(created.id, "triage");
}
await store.updateTask(created.id, {
size: i % 3 === 0 ? "S" : i % 3 === 1 ? "M" : "L",
reviewLevel: i % 4,
});
seeded.push(await store.getTask(created.id));
}
return seeded;
}
/**
* Remove an entire test project directory recursively.
*/
export async function destroyTestProject(dir: string): Promise<void> {
assertAbsolutePath(dir, "dir");
await rm(dir, { recursive: true, force: true });
}