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