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:
133
packages/core/src/__tests__/test-project.test.ts
Normal file
133
packages/core/src/__tests__/test-project.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
176
packages/core/src/__tests__/test-project.ts
Normal file
176
packages/core/src/__tests__/test-project.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user