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:
110
packages/core/src/__tests__/store-attachments.test.ts
Normal file
110
packages/core/src/__tests__/store-attachments.test.ts
Normal 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 ────────────────────────────────────────────────
|
||||
});
|
||||
148
packages/core/src/__tests__/store-migration.test.ts
Normal file
148
packages/core/src/__tests__/store-migration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
248
packages/core/src/__tests__/store-persistence.test.ts
Normal file
248
packages/core/src/__tests__/store-persistence.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
43
packages/core/src/__tests__/store-plugin-routing.test.ts
Normal file
43
packages/core/src/__tests__/store-plugin-routing.test.ts
Normal 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) ───────────────
|
||||
});
|
||||
77
packages/core/src/__tests__/store-priority.test.ts
Normal file
77
packages/core/src/__tests__/store-priority.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
58
packages/core/src/__tests__/store-prompt-generation.test.ts
Normal file
58
packages/core/src/__tests__/store-prompt-generation.test.ts
Normal 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");
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
2138
packages/core/src/__tests__/store-settings.test.ts
Normal file
2138
packages/core/src/__tests__/store-settings.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
92
packages/core/src/__tests__/store-test-helpers.ts
Normal file
92
packages/core/src/__tests__/store-test-helpers.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
117
packages/core/src/__tests__/store-token-usage.test.ts
Normal file
117
packages/core/src/__tests__/store-token-usage.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
132
packages/core/src/__tests__/store-watcher.test.ts
Normal file
132
packages/core/src/__tests__/store-watcher.test.ts
Normal 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
Reference in New Issue
Block a user