feat(FN-3978): split monolithic TaskStore test suite into focused domain fi

Split the monolithic `store.test.ts` suite into ten focused test modules by domain (attachments, migration, persistence, plugin routing, priority, prompt generation, settings, token usage, watcher) plus a shared helpers file, reducing the main suite by ~3000 lines while adding documentation on the s

Fusion-Task-Id: FN-3978
This commit is contained in:
Fusion
2026-05-10 23:56:02 -07:00
committed by gsxdsm
parent 29b5799c43
commit 31776c76ad
12 changed files with 3168 additions and 3026 deletions

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("attachments", () => {
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
it("adds an attachment and persists metadata in task.json", async () => {
const task = await harness.createTestTask();
const attachment = await harness.store().addAttachment(task.id, "screenshot.png", TINY_PNG, "image/png");
expect(attachment.originalName).toBe("screenshot.png");
expect(attachment.mimeType).toBe("image/png");
expect(attachment.size).toBe(TINY_PNG.length);
expect(attachment.filename).toMatch(/^\d+-screenshot\.png$/);
// Verify metadata persisted
const updated = await harness.store().getTask(task.id);
expect(updated.attachments).toHaveLength(1);
expect(updated.attachments![0].filename).toBe(attachment.filename);
// Verify file on disk
const filePath = join(harness.rootDir(), ".fusion", "tasks", task.id, "attachments", attachment.filename);
const content = await readFile(filePath);
expect(content).toEqual(TINY_PNG);
});
it("accepts text/plain mime type", async () => {
const task = await harness.createTestTask();
const attachment = await harness.store().addAttachment(task.id, "error.log", Buffer.from("log content"), "text/plain");
expect(attachment.originalName).toBe("error.log");
expect(attachment.mimeType).toBe("text/plain");
});
it("accepts application/json mime type", async () => {
const task = await harness.createTestTask();
const attachment = await harness.store().addAttachment(task.id, "config.json", Buffer.from('{"key":"val"}'), "application/json");
expect(attachment.mimeType).toBe("application/json");
});
it("accepts text/yaml mime type", async () => {
const task = await harness.createTestTask();
const attachment = await harness.store().addAttachment(task.id, "config.yaml", Buffer.from("key: val"), "text/yaml");
expect(attachment.mimeType).toBe("text/yaml");
});
it("rejects unsupported mime types", async () => {
const task = await harness.createTestTask();
await expect(
harness.store().addAttachment(task.id, "file.bin", Buffer.from("data"), "application/octet-stream"),
).rejects.toThrow("Invalid mime type");
});
it("rejects oversized files", async () => {
const task = await harness.createTestTask();
const bigBuffer = Buffer.alloc(6 * 1024 * 1024); // 6MB
await expect(
harness.store().addAttachment(task.id, "big.png", bigBuffer, "image/png"),
).rejects.toThrow("File too large");
});
it("gets attachment path and mime type", async () => {
const task = await harness.createTestTask();
const attachment = await harness.store().addAttachment(task.id, "shot.png", TINY_PNG, "image/png");
const result = await harness.store().getAttachment(task.id, attachment.filename);
expect(result.mimeType).toBe("image/png");
expect(result.path).toContain(attachment.filename);
});
it("deletes an attachment from disk and metadata", async () => {
const task = await harness.createTestTask();
const attachment = await harness.store().addAttachment(task.id, "del.png", TINY_PNG, "image/png");
const updated = await harness.store().deleteAttachment(task.id, attachment.filename);
expect(updated.attachments).toBeUndefined();
// Verify file removed from disk
const filePath = join(harness.rootDir(), ".fusion", "tasks", task.id, "attachments", attachment.filename);
expect(existsSync(filePath)).toBe(false);
});
it("throws ENOENT when getting non-existent attachment", async () => {
const task = await harness.createTestTask();
await expect(
harness.store().getAttachment(task.id, "nonexistent.png"),
).rejects.toThrow("not found");
});
it("throws ENOENT when deleting non-existent attachment", async () => {
const task = await harness.createTestTask();
await expect(
harness.store().deleteAttachment(task.id, "nonexistent.png"),
).rejects.toThrow("not found");
});
});
// ── Settings tests ────────────────────────────────────────────────
});

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("recovery metadata (recoveryRetryCount / nextRecoveryAt)", () => {
async function createRecoveryTask() {
return harness.store().createTask({ description: "recovery test task" });
}
it("new tasks have no recovery metadata (defaults to undefined)", async () => {
const task = await createRecoveryTask();
expect(task.recoveryRetryCount).toBeUndefined();
expect(task.nextRecoveryAt).toBeUndefined();
});
it("updateTask can set and clear recoveryRetryCount and nextRecoveryAt", async () => {
const task = await createRecoveryTask();
const futureTime = new Date(Date.now() + 60_000).toISOString();
const updated = await harness.store().updateTask(task.id, {
recoveryRetryCount: 2,
nextRecoveryAt: futureTime,
});
expect(updated.recoveryRetryCount).toBe(2);
expect(updated.nextRecoveryAt).toBe(futureTime);
const reread = await harness.store().getTask(task.id);
expect(reread.recoveryRetryCount).toBe(2);
expect(reread.nextRecoveryAt).toBe(futureTime);
const cleared = await harness.store().updateTask(task.id, {
recoveryRetryCount: null,
nextRecoveryAt: null,
});
expect(cleared.recoveryRetryCount).toBeUndefined();
expect(cleared.nextRecoveryAt).toBeUndefined();
});
it("moveTask to in-review clears recovery metadata", async () => {
const task = await createRecoveryTask();
await harness.store().moveTask(task.id, "todo");
await harness.store().updateTask(task.id, {
recoveryRetryCount: 3,
nextRecoveryAt: new Date().toISOString(),
});
await harness.store().moveTask(task.id, "in-progress");
const moved = await harness.store().moveTask(task.id, "in-review");
expect(moved.recoveryRetryCount).toBeUndefined();
expect(moved.nextRecoveryAt).toBeUndefined();
});
it("moveTask to done clears recovery metadata", async () => {
const task = await createRecoveryTask();
await harness.store().moveTask(task.id, "todo");
await harness.store().updateTask(task.id, {
recoveryRetryCount: 1,
nextRecoveryAt: new Date().toISOString(),
});
await harness.store().moveTask(task.id, "in-progress");
await harness.store().moveTask(task.id, "in-review");
const done = await harness.store().moveTask(task.id, "done");
expect(done.recoveryRetryCount).toBeUndefined();
expect(done.nextRecoveryAt).toBeUndefined();
});
it("moveTask from in-progress to todo preserves recovery metadata", async () => {
const task = await createRecoveryTask();
await harness.store().moveTask(task.id, "todo");
await harness.store().moveTask(task.id, "in-progress");
const futureTime = new Date(Date.now() + 60_000).toISOString();
await harness.store().updateTask(task.id, {
recoveryRetryCount: 2,
nextRecoveryAt: futureTime,
});
const moved = await harness.store().moveTask(task.id, "todo");
expect(moved.recoveryRetryCount).toBe(2);
expect(moved.nextRecoveryAt).toBe(futureTime);
});
it("recovery metadata persists across store re-initialization", async () => {
await harness.reopenDiskBackedStore();
const task = await createRecoveryTask();
const futureTime = new Date(Date.now() + 60_000).toISOString();
await harness.store().updateTask(task.id, {
recoveryRetryCount: 5,
nextRecoveryAt: futureTime,
});
await harness.reopenDiskBackedStore();
const reloaded = await harness.store().getTask(task.id);
expect(reloaded.recoveryRetryCount).toBe(5);
expect(reloaded.nextRecoveryAt).toBe(futureTime);
});
it("schema migration: existing rows default to NULL (undefined) for recovery fields", async () => {
const task = await createRecoveryTask();
const detail = await harness.store().getTask(task.id);
expect(detail.recoveryRetryCount).toBeUndefined();
expect(detail.nextRecoveryAt).toBeUndefined();
});
});
describe("FTS5 corruption recovery during upsert", () => {
it("rebuilds FTS5 and retries once when upsert fails with an FTS corruption error", async () => {
const db = harness.store().getDatabase();
const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true);
const upsertSpy = vi.spyOn(harness.store() as any, "upsertTask");
const originalUpsert = upsertSpy.getMockImplementation();
upsertSpy
.mockImplementationOnce(() => {
throw new Error("SQLITE_CORRUPT: corruption found reading blob in fts5");
})
.mockImplementation((task: any) => {
if (originalUpsert) {
return originalUpsert(task);
}
return (Object.getPrototypeOf(harness.store()) as any).upsertTask.call(harness.store(), task);
});
const created = await harness.store().createTask({ description: "Recover from FTS corruption" });
expect(created.id).toBeDefined();
expect(rebuildSpy).toHaveBeenCalledTimes(1);
expect(upsertSpy).toHaveBeenCalledTimes(2);
});
it("propagates non-FTS errors without rebuild", async () => {
const db = harness.store().getDatabase();
const rebuildSpy = vi.spyOn(db, "rebuildFts5Index").mockReturnValue(true);
vi.spyOn(harness.store() as any, "upsertTask").mockImplementationOnce(() => {
throw new Error("constraint failed");
});
await expect(harness.store().createTask({ description: "Should fail" })).rejects.toThrow("constraint failed");
expect(rebuildSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("assignedAgentId persistence", () => {
it("creates a task with assignedAgentId when provided", async () => {
const task = await harness.store().createTask({
description: "Assigned task",
assignedAgentId: "agent-123",
});
expect(task.assignedAgentId).toBe("agent-123");
const detail = await harness.store().getTask(task.id);
expect(detail.assignedAgentId).toBe("agent-123");
});
it("updates a task to set assignedAgentId", async () => {
const task = await harness.store().createTask({ description: "Unassigned task" });
const updated = await harness.store().updateTask(task.id, { assignedAgentId: "agent-456" });
expect(updated.assignedAgentId).toBe("agent-456");
const detail = await harness.store().getTask(task.id);
expect(detail.assignedAgentId).toBe("agent-456");
});
it("updates a task to clear assignedAgentId with null", async () => {
const task = await harness.store().createTask({
description: "Assigned then cleared",
assignedAgentId: "agent-789",
});
const cleared = await harness.store().updateTask(task.id, { assignedAgentId: null });
expect(cleared.assignedAgentId).toBeUndefined();
const detail = await harness.store().getTask(task.id);
expect(detail.assignedAgentId).toBeUndefined();
});
it("returns assignedAgentId values from listTasks", async () => {
const assigned = await harness.store().createTask({
description: "Assigned task in list",
assignedAgentId: "agent-list",
});
await harness.store().createTask({ description: "Unassigned task in list" });
const tasks = await harness.store().listTasks();
const listedAssigned = tasks.find((t) => t.id === assigned.id);
expect(listedAssigned?.assignedAgentId).toBe("agent-list");
});
});
describe("pausedByAgentId persistence", () => {
it("creates and lists a task with pausedByAgentId", async () => {
const task = await harness.store().createTask({ description: "Agent paused task" });
const updated = await harness.store().updateTask(task.id, { pausedByAgentId: "agent-1" });
expect(updated.pausedByAgentId).toBe("agent-1");
const detail = await harness.store().getTask(task.id);
expect(detail.pausedByAgentId).toBe("agent-1");
const tasks = await harness.store().listTasks();
const listed = tasks.find((t) => t.id === task.id);
expect(listed?.pausedByAgentId).toBe("agent-1");
});
it("clears pausedByAgentId with null via updateTask", async () => {
const task = await harness.store().createTask({ description: "Clear agent pause marker" });
await harness.store().updateTask(task.id, { pausedByAgentId: "agent-2" });
const cleared = await harness.store().updateTask(task.id, { pausedByAgentId: null });
expect(cleared.pausedByAgentId).toBeUndefined();
const detail = await harness.store().getTask(task.id);
expect(detail.pausedByAgentId).toBeUndefined();
});
it("auto-unpauses a task when the pausing agent is unassigned", async () => {
const task = await harness.store().createTask({ description: "Auto-unpause on unassign", assignedAgentId: "agent-7" });
await harness.store().pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-7" });
const beforeUnassign = await harness.store().getTask(task.id);
expect(beforeUnassign.paused).toBe(true);
expect(beforeUnassign.pausedByAgentId).toBe("agent-7");
const updated = await harness.store().updateTask(task.id, { assignedAgentId: null });
expect(updated.paused).toBeFalsy();
expect(updated.pausedByAgentId).toBeUndefined();
expect(updated.assignedAgentId).toBeUndefined();
});
it("does not auto-unpause when the pause was set by a different agent", async () => {
const task = await harness.store().createTask({ description: "Different agent paused", assignedAgentId: "agent-current" });
await harness.store().pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-other" });
const updated = await harness.store().updateTask(task.id, { assignedAgentId: null });
expect(updated.paused).toBe(true);
expect(updated.pausedByAgentId).toBe("agent-other");
});
});
describe("branch field persistence", () => {
it("persists baseBranch and branch when provided at create time", async () => {
const task = await harness.store().createTask({
description: "Branch fields on create",
baseBranch: "main",
branch: "fusion/fn-001-custom",
});
expect(task.baseBranch).toBe("main");
expect(task.branch).toBe("fusion/fn-001-custom");
const detail = await harness.store().getTask(task.id);
expect(detail.baseBranch).toBe("main");
expect(detail.branch).toBe("fusion/fn-001-custom");
});
it("preserves branch/baseBranch independently and clears with null without disturbing unrelated fields", async () => {
const task = await harness.store().createTask({
description: "Branch field update",
title: "Keep this title",
baseBranch: "main",
branch: "fusion/fn-001-initial",
});
const updatedBranchOnly = await harness.store().updateTask(task.id, {
branch: "fusion/fn-001-updated",
});
expect(updatedBranchOnly.branch).toBe("fusion/fn-001-updated");
expect(updatedBranchOnly.baseBranch).toBe("main");
const updatedBaseOnly = await harness.store().updateTask(task.id, {
baseBranch: "release/2026.05",
});
expect(updatedBaseOnly.baseBranch).toBe("release/2026.05");
expect(updatedBaseOnly.branch).toBe("fusion/fn-001-updated");
const clearedBranch = await harness.store().updateTask(task.id, { branch: null });
expect(clearedBranch.branch).toBeUndefined();
expect(clearedBranch.baseBranch).toBe("release/2026.05");
expect(clearedBranch.title).toBe("Keep this title");
const clearedBaseBranch = await harness.store().updateTask(task.id, { baseBranch: null });
expect(clearedBaseBranch.baseBranch).toBeUndefined();
expect(clearedBaseBranch.branch).toBeUndefined();
expect(clearedBaseBranch.title).toBe("Keep this title");
});
it("persists planning branch context metadata on create", async () => {
const task = await harness.store().createTask({
description: "Planning branch context",
baseBranch: "release/2026.10",
branch: "planning/session-42",
branchContext: {
groupId: "planning-session-42",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "release/2026.10",
},
});
expect(task.branchContext).toEqual({
groupId: "planning-session-42",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "release/2026.10",
});
const detail = await harness.store().getTask(task.id);
expect(detail.branchContext).toEqual(task.branchContext);
expect(detail.sourceMetadata).toMatchObject({
fusionBranchContext: {
groupId: "planning-session-42",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "release/2026.10",
},
});
});
it("round-trips branch fields through listTasks and reload", async () => {
harness.store().close();
await harness.reopenDiskBackedStore();
const created = await harness.store().createTask({
description: "Branch field reinit persistence",
baseBranch: "develop",
branch: "fusion/fn-001-reinit",
});
const listed = (await harness.store().listTasks()).find((task) => task.id === created.id);
expect(listed?.baseBranch).toBe("develop");
expect(listed?.branch).toBe("fusion/fn-001-reinit");
harness.store().close();
await harness.reopenDiskBackedStore();
const reloaded = await harness.store().getTask(created.id);
expect(reloaded.baseBranch).toBe("develop");
expect(reloaded.branch).toBe("fusion/fn-001-reinit");
});
});
describe("nodeId persistence", () => {
it("creates a task with nodeId when provided", async () => {
const task = await harness.store().createTask({
description: "Node-targeted task",
nodeId: "node-123",
});
expect(task.nodeId).toBe("node-123");
const detail = await harness.store().getTask(task.id);
expect(detail.nodeId).toBe("node-123");
});
it("updates and clears nodeId via updateTask", async () => {
const task = await harness.store().createTask({ description: "Task to mutate nodeId" });
const updated = await harness.store().updateTask(task.id, { nodeId: "node-456" });
expect(updated.nodeId).toBe("node-456");
const cleared = await harness.store().updateTask(task.id, { nodeId: null });
expect(cleared.nodeId).toBeUndefined();
});
it("returns nodeId values from listTasks", async () => {
const assignedNode = await harness.store().createTask({
description: "Task with node in list",
nodeId: "node-list",
});
await harness.store().createTask({ description: "Task without node in list" });
const tasks = await harness.store().listTasks();
const listed = tasks.find((t) => t.id === assignedNode.id);
expect(listed?.nodeId).toBe("node-list");
});
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { CentralDatabase } from "../central-db.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("plugin store routing", () => {
it("routes plugin writes to the configured central global dir", async () => {
const pluginStore = harness.store().getPluginStore();
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: {
id: "taskstore-plugin",
name: "TaskStore Plugin",
version: "1.0.0",
},
path: "/tmp/taskstore-plugin",
});
const centralDb = new CentralDatabase(harness.globalDir());
centralDb.init();
const installCount = centralDb
.prepare("SELECT COUNT(*) as count FROM plugin_installs WHERE id = ?")
.get("taskstore-plugin") as { count: number };
expect(installCount.count).toBe(1);
const localCount = harness.store()
.getDatabase()
.prepare("SELECT COUNT(*) as count FROM plugins WHERE id = ?")
.get("taskstore-plugin") as { count: number };
expect(localCount.count).toBe(0);
centralDb.close();
});
});
// ── Prompt generation (no duplicate description) ───────────────
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("task priority", () => {
it("defaults to normal priority when omitted", async () => {
const task = await harness.store().createTask({
description: "Priority default task",
});
expect(task.priority).toBe("normal");
const detail = await harness.store().getTask(task.id);
expect(detail.priority).toBe("normal");
});
it("persists explicit priority on create and update, and normalizes null update to default", async () => {
const task = await harness.store().createTask({
description: "Priority explicit task",
priority: "urgent",
});
expect(task.priority).toBe("urgent");
const lowered = await harness.store().updateTask(task.id, { priority: "low" });
expect(lowered.priority).toBe("low");
const reset = await harness.store().updateTask(task.id, { priority: null });
expect(reset.priority).toBe("normal");
const detail = await harness.store().getTask(task.id);
expect(detail.priority).toBe("normal");
});
it("preserves explicit priority through archive and unarchive", async () => {
const task = await harness.store().createTask({
description: "Archive priority task",
column: "done",
priority: "high",
});
await harness.store().archiveTask(task.id, false);
const archived = await harness.store().getTask(task.id);
expect(archived.priority).toBe("high");
const unarchived = await harness.store().unarchiveTask(task.id);
expect(unarchived.priority).toBe("high");
});
it("restores legacy archive entries missing priority as normal", async () => {
const now = new Date().toISOString();
const legacyEntry = {
id: "FN-999",
title: "Legacy archive task",
description: "Legacy task without explicit priority",
column: "archived" as const,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: now,
updatedAt: now,
archivedAt: now,
};
const restored = await (harness.store() as any).restoreFromArchive(legacyEntry);
expect(restored.priority).toBe("normal");
const unarchived = await harness.store().unarchiveTask(legacyEntry.id);
expect(unarchived.priority).toBe("normal");
});
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("prompt generation", () => {
it("triage task without title shows only ID in heading", async () => {
const task = await harness.store().createTask({ description: "Fix the login bug on the settings page" });
const detail = await harness.store().getTask(task.id);
// Heading should be just the task ID when no title is provided
expect(detail.prompt).toMatch(/^# FN-001\n/);
// Description appears exactly once in body (not duplicated in heading)
expect(detail.prompt).toContain("Fix the login bug on the settings page");
});
it("triage task with title uses title in heading and description in body", async () => {
const task = await harness.store().createTask({
title: "Login bug",
description: "Fix the login bug on the settings page",
});
const detail = await harness.store().getTask(task.id);
expect(detail.prompt).toMatch(/^# FN-001: Login bug\n/);
expect(detail.prompt).toContain("Fix the login bug on the settings page");
});
it("generateSpecifiedPrompt shows only ID when title is absent", async () => {
const task = await harness.store().createTask({
description: "Implement caching layer",
column: "todo",
});
const detail = await harness.store().getTask(task.id);
// Heading should be just the task ID when no title is provided
expect(detail.prompt).toMatch(/^# FN-001\n/);
// Description appears once in Mission section
expect(detail.prompt).toContain("Implement caching layer");
});
it("generateSpecifiedPrompt uses title in heading when present", async () => {
const task = await harness.store().createTask({
title: "Add caching",
description: "Implement caching layer for API responses",
column: "todo",
});
const detail = await harness.store().getTask(task.id);
expect(detail.prompt).toMatch(/^# FN-001: Add caching\n/);
expect(detail.prompt).toContain("Implement caching layer for API responses");
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import { mkdtempSync } from "node:fs";
import { rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
import { TaskStore } from "../store.js";
import type { Task } from "../types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
}
export function createTaskStoreTestHarness() {
let rootDir = "";
let globalDir = "";
let store: TaskStore;
return {
rootDir: () => rootDir,
globalDir: () => globalDir,
store: () => store,
beforeEach: async () => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
},
afterEach: async () => {
vi.useRealTimers();
store.stopWatching();
await new Promise<void>((resolve) => process.nextTick(resolve));
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
},
reopenDiskBackedStore: async () => {
store.close();
store = new TaskStore(rootDir, globalDir);
await store.init();
},
createTestTask: async (): Promise<Task> => store.createTask({ description: "Test task" }),
createTaskWithSteps: async (): Promise<Task> => {
const task = await store.createTask({ description: "Task with steps" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with steps
## Steps
### Step 0: Preflight
- [ ] Check things
### Step 1: Implementation
- [ ] Do stuff
### Step 2: Testing
- [ ] Test stuff
`,
);
return task;
},
deleteTaskDir: async (taskId: string): Promise<string> => {
const dir = join(rootDir, ".fusion", "tasks", taskId);
await rm(dir, { recursive: true, force: true });
return dir;
},
createSourceIssueFixture: () => ({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2471,
url: "https://github.com/runfusion/fusion/issues/2471",
}),
insertLogEntryWithTimestamp: (
taskId: string,
text: string,
type: string,
timestamp: string,
detail?: string,
agent?: string,
): void => {
(store as any).db.prepare(`
INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent)
VALUES (?, ?, ?, ?, ?, ?)
`).run(taskId, timestamp, text, type, detail ?? null, agent ?? null);
},
};
}

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("task token usage persistence", () => {
it("creates and reads tasks without token usage data as undefined", async () => {
const task = await harness.store().createTask({
description: "Task without token usage",
});
expect(task.tokenUsage).toBeUndefined();
const detail = await harness.store().getTask(task.id);
expect(detail.tokenUsage).toBeUndefined();
});
it("round-trips token usage totals and timestamps through create and read", async () => {
const tokenUsage = {
inputTokens: 120,
outputTokens: 45,
cachedTokens: 30,
totalTokens: 195,
firstUsedAt: "2026-04-23T10:00:00.000Z",
lastUsedAt: "2026-04-23T10:05:00.000Z",
};
const task = await harness.store().createTask({
description: "Task with token usage",
tokenUsage,
});
expect(task.tokenUsage).toEqual(tokenUsage);
const detail = await harness.store().getTask(task.id);
expect(detail.tokenUsage).toEqual(tokenUsage);
});
it("round-trips token usage through update and preserves exact values", async () => {
const task = await harness.store().createTask({ description: "Update token usage" });
const tokenUsage = {
inputTokens: 210,
outputTokens: 80,
cachedTokens: 40,
totalTokens: 330,
firstUsedAt: "2026-04-23T12:00:00.000Z",
lastUsedAt: "2026-04-23T12:30:00.000Z",
};
const updated = await harness.store().updateTask(task.id, { tokenUsage });
expect(updated.tokenUsage).toEqual(tokenUsage);
const detail = await harness.store().getTask(task.id);
expect(detail.tokenUsage).toEqual(tokenUsage);
});
it("persists token usage across TaskStore reinitialization", async () => {
// Cross-instance persistence test — swap beforeEach's in-memory
// store for disk-backed so the second `new TaskStore` below can
// observe what this instance writes.
harness.store().close();
await harness.reopenDiskBackedStore();
const tokenUsage = {
inputTokens: 300,
outputTokens: 120,
cachedTokens: 50,
totalTokens: 470,
firstUsedAt: "2026-04-23T13:00:00.000Z",
lastUsedAt: "2026-04-23T13:45:00.000Z",
};
const created = await harness.store().createTask({
description: "Reinit token usage persistence",
tokenUsage,
});
harness.store().close();
await harness.reopenDiskBackedStore();
const reloaded = await harness.store().getTask(created.id);
expect(reloaded.tokenUsage).toEqual(tokenUsage);
});
it("clears token usage via null update and keeps it absent after reload", async () => {
// Cross-instance persistence test — see counterpart above.
harness.store().close();
await harness.reopenDiskBackedStore();
const task = await harness.store().createTask({
description: "Clear token usage",
tokenUsage: {
inputTokens: 99,
outputTokens: 44,
cachedTokens: 11,
totalTokens: 154,
firstUsedAt: "2026-04-23T14:00:00.000Z",
lastUsedAt: "2026-04-23T14:01:00.000Z",
},
});
const cleared = await harness.store().updateTask(task.id, { tokenUsage: null });
expect(cleared.tokenUsage).toBeUndefined();
harness.store().close();
await harness.reopenDiskBackedStore();
const reloaded = await harness.store().getTask(task.id);
expect(reloaded.tokenUsage).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
describe("watcher and polling", () => {
it("cache is updated when polling is active even without fs.watch", async () => {
await harness.store().watch();
try {
const task = await harness.createTestTask();
const movedEvents: any[] = [];
harness.store().on("task:moved", (data: any) => movedEvents.push(data));
await harness.store().moveTask(task.id, "todo");
expect(movedEvents).toHaveLength(1);
expect(movedEvents[0].from).toBe("triage");
expect(movedEvents[0].to).toBe("todo");
} finally {
harness.store().stopWatching();
}
}, 30_000);
it("checkForChanges returns a Promise (is async)", async () => {
await harness.store().watch();
const result = (harness.store() as any).checkForChanges();
expect(result).toBeInstanceOf(Promise);
await result;
});
it("pollingInProgress guard prevents overlapping poll cycles", async () => {
await harness.store().watch();
const storeAny = harness.store() as any;
const firstCall = storeAny.checkForChanges();
const secondCall = storeAny.checkForChanges();
expect(firstCall).toBeInstanceOf(Promise);
expect(secondCall).toBeInstanceOf(Promise);
await Promise.all([firstCall, secondCall]);
expect(storeAny.pollingInProgress).toBe(false);
});
it("logs poll failures with context and keeps checkForChanges non-fatal", async () => {
await harness.store().watch();
const storeAny = harness.store() as any;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const originalGetLastModified = storeAny.db.getLastModified.bind(storeAny.db);
storeAny.db.getLastModified = vi.fn(() => {
throw new Error("poll db unavailable");
});
try {
await expect(storeAny.checkForChanges()).resolves.toBeUndefined();
expect(storeAny.pollingInProgress).toBe(false);
const pollFailureCall = warnSpy.mock.calls.find(
(call) =>
typeof call[0] === "string"
&& call[0].includes("[task-store] checkForChanges poll cycle failed"),
);
expect(pollFailureCall).toBeDefined();
const [, context] = pollFailureCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
lastPollTime: storeAny.lastPollTime,
error: "poll db unavailable",
});
} finally {
storeAny.db.getLastModified = originalGetLastModified;
warnSpy.mockRestore();
}
});
it("logs watcher failures and keeps polling operational", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await harness.store().watch();
const storeAny = harness.store() as any;
if (storeAny.watcher) {
storeAny.watcher.emit("error", new Error("watcher degraded"));
const watcherErrorCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] fs.watch emitted an error; polling will continue"),
);
expect(watcherErrorCall).toBeDefined();
const [, context] = watcherErrorCall as [string, Record<string, unknown>];
expect(context).toMatchObject({ error: "watcher degraded" });
} else {
const fallbackCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] fs.watch unavailable; falling back to polling-only updates"),
);
expect(fallbackCall).toBeDefined();
}
await expect(storeAny.checkForChanges()).resolves.toBeUndefined();
} finally {
warnSpy.mockRestore();
}
});
it("does not emit timing warning when polling is fast (<100ms)", async () => {
await harness.store().watch();
const storeAny = harness.store() as any;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await harness.store().createTask({ description: "fast poll test" });
await storeAny.checkForChanges();
const timingWarningEmitted = warnSpy.mock.calls.some(
(call) =>
typeof call[0] === "string"
&& call[0].includes("checkForChanges took")
&& call[0].includes("ms"),
);
expect(timingWarningEmitted).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

File diff suppressed because it is too large Load Diff