chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts

- Move all co-located *.test.* files into sibling __tests__/ directories so the
  layout is consistent across packages (159 renames + content-rewrite moves).
  Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
  path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
  skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
  accidentally committed in a merge and the stale pi.js was masking a real
  test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
  the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
  createFnAgent / promptWithFallback / describeModel from @fusion/engine
  instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
  the two plugin package.jsons that were missing it; exports describeModel
  from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
  ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
  .test.ts; switch three boundary-result toEqual assertions to toMatchObject
  so the new content/isError fields don't trip exact-match comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 18:45:10 -07:00
parent 5ca0df728f
commit bdcb048e20
232 changed files with 1311 additions and 26008 deletions

View File

@@ -0,0 +1,255 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseYamlFrontmatter } from "../agent-companies-parser.js";
import {
agentToCompaniesManifest,
exportAgentsToDirectory,
generateAgentMd,
generateCompanyMd,
slugify,
} from "../agent-companies-exporter.js";
import type { Agent } from "../types.js";
const tempDirs: string[] = [];
function createTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "agent-companies-exporter-test-"));
tempDirs.push(dir);
return dir;
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date().toISOString();
return {
id: overrides.id ?? "agent-1",
name: overrides.name ?? "CEO",
role: overrides.role ?? "executor",
state: overrides.state ?? "idle",
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
metadata: overrides.metadata ?? {},
...(overrides.title !== undefined ? { title: overrides.title } : {}),
...(overrides.icon !== undefined ? { icon: overrides.icon } : {}),
...(overrides.reportsTo !== undefined ? { reportsTo: overrides.reportsTo } : {}),
...(overrides.instructionsText !== undefined
? { instructionsText: overrides.instructionsText }
: {}),
};
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe("agent-companies-exporter", () => {
it("maps Agent fields to AgentCompanies manifest", () => {
const agent = makeAgent({
id: "agent-ceo",
name: "CEO",
title: "Chief Executive Officer",
icon: "crown",
role: "reviewer",
reportsTo: "agent-root",
instructionsText: "Lead strategy and review architecture.",
metadata: {
description: "Company lead",
skills: ["review", { name: "architecture" }],
},
});
const manifest = agentToCompaniesManifest(agent);
expect(manifest).toEqual({
name: "CEO",
title: "Chief Executive Officer",
icon: "crown",
role: "reviewer",
reportsTo: "agent-root",
skills: ["review", "architecture"],
description: "Company lead",
schema: "agentcompanies/v1",
instructionBody: "Lead strategy and review architecture.",
});
});
it("generates COMPANY.md with valid YAML frontmatter", () => {
const content = generateCompanyMd([makeAgent({ name: "Leadership" })], {
name: "Acme Agents",
description: "Autonomous engineering org",
slug: "acme-agents",
});
const parsed = parseYamlFrontmatter(content);
expect(parsed.frontmatter).toMatchObject({
name: "Acme Agents",
description: "Autonomous engineering org",
slug: "acme-agents",
schema: "agentcompanies/v1",
});
expect(parsed.body).toContain("Autonomous engineering org");
});
it("generates AGENTS.md with frontmatter and markdown body", () => {
const content = generateAgentMd(
makeAgent({
name: "Reviewer",
title: "Code Reviewer",
icon: "shield",
role: "reviewer",
instructionsText: "Always verify tests and edge-cases.",
metadata: {
description: "Ensures quality",
skills: ["qa"],
},
}),
);
const parsed = parseYamlFrontmatter(content);
expect(parsed.frontmatter).toMatchObject({
name: "Reviewer",
title: "Code Reviewer",
icon: "shield",
role: "reviewer",
reportsTo: null,
skills: ["qa"],
description: "Ensures quality",
schema: "agentcompanies/v1",
});
expect(parsed.body).toBe("Always verify tests and edge-cases.");
});
it("exports agents and skills to Agent Companies directory layout", async () => {
const outputDir = createTempDir();
const ceo = makeAgent({
id: "agent-ceo",
name: "CEO",
role: "executor",
metadata: {
description: "Company lead",
skills: ["strategy"],
},
instructionsText: "Lead the company.",
});
const reviewer = makeAgent({
id: "agent-reviewer",
name: "Code Reviewer",
role: "reviewer",
reportsTo: "agent-ceo",
metadata: {
description: "Reviews code",
skills: ["review"],
},
instructionsText: "Review every pull request.",
});
const result = await exportAgentsToDirectory([ceo, reviewer], outputDir, {
companyName: "Acme AI",
companySlug: "acme-ai",
});
expect(result.agentsExported).toBe(2);
expect(result.skillsExported).toBe(2);
expect(result.errors).toEqual([]);
const companyPath = join(outputDir, "COMPANY.md");
const reviewerPath = join(outputDir, "agents", "code-reviewer", "AGENTS.md");
const strategySkillPath = join(outputDir, "skills", "strategy", "SKILL.md");
expect(readFileSync(companyPath, "utf-8")).toContain("schema: agentcompanies/v1");
const reviewerManifest = parseYamlFrontmatter(readFileSync(reviewerPath, "utf-8"));
expect(reviewerManifest.frontmatter.reportsTo).toBe("../ceo/AGENTS.md");
expect(readFileSync(strategySkillPath, "utf-8")).toContain("kind: skill");
expect(result.filesWritten).toEqual(
expect.arrayContaining([companyPath, reviewerPath, strategySkillPath]),
);
});
it("slugifies names for directories", async () => {
const outputDir = createTempDir();
const agent = makeAgent({
id: "agent-qa",
name: "Lead QA / Ops!",
});
const result = await exportAgentsToDirectory([agent], outputDir);
expect(result.agentsExported).toBe(1);
expect(readFileSync(join(outputDir, "agents", "lead-qa-ops", "AGENTS.md"), "utf-8")).toContain(
"name: Lead QA / Ops!",
);
expect(slugify("Lead QA / Ops!")).toBe("lead-qa-ops");
});
it("handles optional fields when reportsTo, instructionsText, and skills are absent", async () => {
const outputDir = createTempDir();
const agent = makeAgent({
id: "agent-solo",
name: "Solo",
reportsTo: undefined,
instructionsText: undefined,
metadata: {},
});
const result = await exportAgentsToDirectory([agent], outputDir, { includeSkills: false });
expect(result.skillsExported).toBe(0);
const parsed = parseYamlFrontmatter(
readFileSync(join(outputDir, "agents", "solo", "AGENTS.md"), "utf-8"),
);
expect(parsed.frontmatter.reportsTo).toBeNull();
expect(parsed.frontmatter.skills).toEqual([]);
expect(parsed.body).toBe("");
});
it("collects errors for invalid agents and continues export", async () => {
const outputDir = createTempDir();
const valid = makeAgent({ id: "agent-valid", name: "Valid Agent" });
const invalid = makeAgent({ id: "agent-invalid", name: " " });
const result = await exportAgentsToDirectory([invalid, valid], outputDir);
expect(result.agentsExported).toBe(1);
expect(result.errors).toEqual([
{
agentId: "agent-invalid",
error: "Agent name is required for export",
},
]);
expect(readFileSync(join(outputDir, "agents", "valid-agent", "AGENTS.md"), "utf-8")).toContain(
"name: Valid Agent",
);
});
it("captures per-agent write errors", async () => {
const outputDir = createTempDir();
const conflictPath = join(outputDir, "agents", "ceo");
mkdirSync(dirname(conflictPath), { recursive: true });
writeFileSync(conflictPath, "not-a-directory", "utf-8");
const result = await exportAgentsToDirectory(
[
makeAgent({ id: "agent-ceo", name: "CEO" }),
makeAgent({ id: "agent-cto", name: "CTO" }),
],
outputDir,
);
expect(result.agentsExported).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.agentId).toBe("agent-ceo");
expect(readFileSync(join(outputDir, "agents", "cto", "AGENTS.md"), "utf-8")).toContain(
"name: CTO",
);
});
});

View File

@@ -0,0 +1,701 @@
import { execSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import zlib from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import {
AgentCompaniesParseError,
agentManifestToAgentCreateInput,
convertAgentCompanies,
prepareAgentCompaniesImport,
mapRoleToCapability,
parseAgentManifest,
parseCompanyArchive,
parseCompanyDirectory,
parseCompanyManifest,
parseProjectManifest,
parseSingleAgentManifest,
parseSkillManifest,
parseTaskManifest,
parseTeamManifest,
parseYamlFrontmatter,
} from "../agent-companies-parser.js";
const tempDirs: string[] = [];
function createTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "agent-companies-test-"));
tempDirs.push(dir);
return dir;
}
function writeTextFile(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, "utf-8");
}
// Keep ZIP fixtures fully deterministic and self-contained without relying on
// external `zip` binaries that may be unavailable in CI/worktree environments.
function createZipFromEntries(
archivePath: string,
entries: Array<{ path: string; content: string }>,
): void {
const localRecords: Buffer[] = [];
const centralDirectoryRecords: Buffer[] = [];
let localOffset = 0;
for (const entry of entries) {
const fileNameBytes = Buffer.from(entry.path, "utf-8");
const contentBytes = Buffer.from(entry.content, "utf-8");
const compressedBytes = zlib.deflateRawSync(contentBytes);
const crc32 = zlib.crc32(contentBytes) >>> 0;
const localHeader = Buffer.alloc(30 + fileNameBytes.length);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0, 6);
localHeader.writeUInt16LE(8, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0x5000, 12);
localHeader.writeUInt32LE(crc32, 14);
localHeader.writeUInt32LE(compressedBytes.length, 18);
localHeader.writeUInt32LE(contentBytes.length, 22);
localHeader.writeUInt16LE(fileNameBytes.length, 26);
localHeader.writeUInt16LE(0, 28);
fileNameBytes.copy(localHeader, 30);
localRecords.push(localHeader, compressedBytes);
const centralRecord = Buffer.alloc(46 + fileNameBytes.length);
centralRecord.writeUInt32LE(0x02014b50, 0);
centralRecord.writeUInt16LE(20, 4);
centralRecord.writeUInt16LE(20, 6);
centralRecord.writeUInt16LE(0, 8);
centralRecord.writeUInt16LE(8, 10);
centralRecord.writeUInt16LE(0, 12);
centralRecord.writeUInt16LE(0x5000, 14);
centralRecord.writeUInt32LE(crc32, 16);
centralRecord.writeUInt32LE(compressedBytes.length, 20);
centralRecord.writeUInt32LE(contentBytes.length, 24);
centralRecord.writeUInt16LE(fileNameBytes.length, 28);
centralRecord.writeUInt16LE(0, 30);
centralRecord.writeUInt16LE(0, 32);
centralRecord.writeUInt16LE(0, 34);
centralRecord.writeUInt16LE(0, 36);
centralRecord.writeUInt32LE(0, 38);
centralRecord.writeUInt32LE(localOffset, 42);
fileNameBytes.copy(centralRecord, 46);
centralDirectoryRecords.push(centralRecord);
localOffset += localHeader.length + compressedBytes.length;
}
const centralDirectoryOffset = localOffset;
const centralDirectoryBuffer = Buffer.concat(centralDirectoryRecords);
const endOfCentralDirectory = Buffer.alloc(22);
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0);
endOfCentralDirectory.writeUInt16LE(0, 4);
endOfCentralDirectory.writeUInt16LE(0, 6);
endOfCentralDirectory.writeUInt16LE(entries.length, 8);
endOfCentralDirectory.writeUInt16LE(entries.length, 10);
endOfCentralDirectory.writeUInt32LE(centralDirectoryBuffer.length, 12);
endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16);
endOfCentralDirectory.writeUInt16LE(0, 20);
const archiveBuffer = Buffer.concat([
...localRecords,
centralDirectoryBuffer,
endOfCentralDirectory,
]);
writeFileSync(archivePath, archiveBuffer);
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe("agent-companies-parser", () => {
describe("parseYamlFrontmatter", () => {
it("parses valid YAML frontmatter and body", () => {
const content = `---
name: CEO
skills:
- review
---
Lead code review.`;
const parsed = parseYamlFrontmatter(content);
expect(parsed.frontmatter.name).toBe("CEO");
expect(parsed.frontmatter.skills).toEqual(["review"]);
expect(parsed.body).toBe("Lead code review.");
});
it("throws when frontmatter is missing", () => {
expect(() => parseYamlFrontmatter("name: CEO")).toThrow(AgentCompaniesParseError);
expect(() => parseYamlFrontmatter("name: CEO")).toThrow("Missing YAML frontmatter");
});
it("throws when YAML is malformed", () => {
const content = `---
name: CEO
skills: [review
---
Body`;
expect(() => parseYamlFrontmatter(content)).toThrow("Malformed YAML frontmatter");
});
it("supports empty body", () => {
const parsed = parseYamlFrontmatter(`---
name: CEO
---`);
expect(parsed.body).toBe("");
});
it("parses multiline fields", () => {
const parsed = parseYamlFrontmatter(`---
name: CEO
description: |
First line
Second line
---
Body`);
expect(parsed.frontmatter.description).toBe("First line\nSecond line\n");
});
});
describe("individual manifests", () => {
it("parses full AGENTS.md", () => {
const manifest = parseAgentManifest(`---
name: CEO
title: Chief Executive Officer
reportsTo: null
skills:
- plan-ceo-review
- review
---
Agent instructions.`);
expect(manifest.name).toBe("CEO");
expect(manifest.title).toBe("Chief Executive Officer");
expect(manifest.reportsTo).toBeNull();
expect(manifest.skills).toEqual(["plan-ceo-review", "review"]);
expect(manifest.instructionBody).toBe("Agent instructions.");
});
it("parses minimal AGENTS.md", () => {
const manifest = parseAgentManifest(`---
name: Solo Agent
---`);
expect(manifest.name).toBe("Solo Agent");
expect(manifest.instructionBody).toBe("");
});
it("parses standalone AGENTS.md wrapper", () => {
const parsed = parseSingleAgentManifest(`---
name: Solo Agent
---
Be helpful.`);
expect(parsed.manifest.name).toBe("Solo Agent");
expect(parsed.manifest.instructionBody).toBe("Be helpful.");
});
it("parses COMPANY.md with schema and slug", () => {
const manifest = parseCompanyManifest(`---
name: Lean Dev Shop
description: Small engineering-focused AI company
slug: lean-dev-shop
schema: agentcompanies/v1
---`);
expect(manifest.schema).toBe("agentcompanies/v1");
expect(manifest.slug).toBe("lean-dev-shop");
});
it("parses TEAM.md with manager and includes", () => {
const manifest = parseTeamManifest(`---
name: Engineering
manager: ../cto/AGENTS.md
includes:
- ../platform/TEAM.md
---`);
expect(manifest.manager).toBe("../cto/AGENTS.md");
expect(manifest.includes).toEqual(["../platform/TEAM.md"]);
});
it("parses PROJECT.md", () => {
const manifest = parseProjectManifest(`---
name: Q2 Launch
slug: q2-launch
---`);
expect(manifest.slug).toBe("q2-launch");
});
it("parses TASK.md schedule", () => {
const manifest = parseTaskManifest(`---
name: Monday Review
assignee: ./agents/ceo/AGENTS.md
project: ./projects/q2-launch/PROJECT.md
schedule:
timezone: America/New_York
startsAt: "2026-04-14T09:00:00"
---`);
expect(manifest.assignee).toBe("./agents/ceo/AGENTS.md");
expect(manifest.schedule?.timezone).toBe("America/New_York");
});
it("parses SKILL.md with instruction body", () => {
const manifest = parseSkillManifest(`---
name: review
schema: agentcompanies/v1
kind: skill
---
# review
Add skill instructions here.`);
expect(manifest).toEqual({
name: "review",
schema: "agentcompanies/v1",
kind: "skill",
instructionBody: "# review\n\nAdd skill instructions here.",
});
});
});
describe("directory parsing", () => {
it("parses a full company directory", () => {
const root = createTempDir();
writeTextFile(
join(root, "COMPANY.md"),
`---
name: Lean Dev Shop
slug: lean-dev-shop
schema: agentcompanies/v1
---`,
);
writeTextFile(
join(root, "agents", "ceo", "AGENTS.md"),
`---
name: CEO
title: Chief Executive Officer
skills:
- review
---
Lead reviews.`,
);
writeTextFile(
join(root, "teams", "engineering", "TEAM.md"),
`---
name: Engineering
manager: ../ceo/AGENTS.md
---`,
);
writeTextFile(
join(root, "projects", "q2-launch", "PROJECT.md"),
`---
name: Q2 Launch
---`,
);
writeTextFile(
join(root, "tasks", "monday-review", "TASK.md"),
`---
name: Monday Review
---`,
);
const pkg = parseCompanyDirectory(root);
expect(pkg.company?.name).toBe("Lean Dev Shop");
expect(pkg.agents).toHaveLength(1);
expect(pkg.teams).toHaveLength(1);
expect(pkg.projects).toHaveLength(1);
expect(pkg.tasks).toHaveLength(1);
});
it("parses agents-only directory without COMPANY.md", () => {
const root = createTempDir();
writeTextFile(
join(root, "agents", "solo", "AGENTS.md"),
`---
name: Solo Agent
---`,
);
const pkg = parseCompanyDirectory(root);
expect(pkg.company).toBeUndefined();
expect(pkg.agents).toHaveLength(1);
expect(pkg.teams).toEqual([]);
});
it("parses skills from skills subdirectories", () => {
const root = createTempDir();
writeTextFile(
join(root, "skills", "review", "SKILL.md"),
`---
name: review
kind: skill
---
# review`,
);
writeTextFile(
join(root, "skills", "strategy", "SKILL.md"),
`---
name: strategy
kind: skill
---
# strategy`,
);
const pkg = parseCompanyDirectory(root);
expect(pkg.skills).toHaveLength(2);
expect(pkg.skills?.map((skill) => skill.name)).toEqual(["review", "strategy"]);
});
it("returns empty skills when skills directory is absent", () => {
const root = createTempDir();
writeTextFile(
join(root, "agents", "solo", "AGENTS.md"),
`---
name: Solo Agent
---`,
);
const pkg = parseCompanyDirectory(root);
expect(pkg.skills).toEqual([]);
});
it("parses empty directory", () => {
const root = createTempDir();
const pkg = parseCompanyDirectory(root);
expect(pkg).toEqual({
company: undefined,
agents: [],
teams: [],
projects: [],
tasks: [],
skills: [],
});
});
it("handles circular team includes without recursion issues", () => {
const root = createTempDir();
writeTextFile(
join(root, "teams", "a", "TEAM.md"),
`---
name: a
slug: a
includes:
- ../b/TEAM.md
---`,
);
writeTextFile(
join(root, "teams", "b", "TEAM.md"),
`---
name: b
slug: b
includes:
- ../a/TEAM.md
---`,
);
const pkg = parseCompanyDirectory(root);
expect(pkg.teams).toHaveLength(2);
});
});
describe("archive parsing", () => {
it("parses a .tgz archive", async () => {
const root = createTempDir();
const packageDir = join(root, "company-package");
writeTextFile(join(packageDir, "COMPANY.md"), `---
name: Archive Company
schema: agentcompanies/v1
---`);
writeTextFile(join(packageDir, "agents", "ceo", "AGENTS.md"), `---
name: Archive CEO
---`);
const archivePath = join(root, "company.tgz");
execSync(`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(root)} company-package`);
const pkg = await parseCompanyArchive(archivePath);
expect(pkg.company?.name).toBe("Archive Company");
expect(pkg.agents[0]?.name).toBe("Archive CEO");
});
it("throws descriptive error when tar extraction fails", async () => {
const root = createTempDir();
const archivePath = join(root, "corrupt.tgz");
writeFileSync(archivePath, Buffer.from("not-a-real-gzip"));
await expect(parseCompanyArchive(archivePath)).rejects.toMatchObject({
name: "AgentCompaniesParseError",
message: expect.stringContaining("Failed to parse Agent Companies archive"),
});
});
it("parses a .tar.gz archive with nested directory structure", async () => {
const root = createTempDir();
const topLevelDir = join(root, "outer-layer");
const packageDir = join(topLevelDir, "company-package");
writeTextFile(join(packageDir, "COMPANY.md"), `---
name: Nested Archive Company
schema: agentcompanies/v1
---`);
writeTextFile(join(packageDir, "agents", "ceo", "AGENTS.md"), `---
name: Nested Archive CEO
---`);
const archivePath = join(root, "nested-company.tgz");
execSync(`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(root)} outer-layer`);
const pkg = await parseCompanyArchive(archivePath);
expect(pkg.company?.name).toBe("Nested Archive Company");
expect(pkg.agents[0]?.name).toBe("Nested Archive CEO");
});
it("throws AgentCompaniesParseError for a non-existent .tar.gz file", async () => {
const archivePath = join(createTempDir(), "missing.tgz");
await expect(parseCompanyArchive(archivePath)).rejects.toBeInstanceOf(AgentCompaniesParseError);
});
it("parses a .zip archive", async () => {
const root = createTempDir();
const archivePath = join(root, "company.zip");
createZipFromEntries(archivePath, [
{ path: "zip-company/COMPANY.md", content: `---\nname: Zip Company\nschema: agentcompanies/v1\n---` },
{ path: "zip-company/agents/ceo/AGENTS.md", content: `---\nname: Zip CEO\n---` },
]);
const pkg = await parseCompanyArchive(archivePath);
expect(pkg.company?.name).toBe("Zip Company");
expect(pkg.agents).toHaveLength(1);
});
it("parses a .zip archive with COMPANY.md at root", async () => {
const root = createTempDir();
const archivePath = join(root, "flat.zip");
createZipFromEntries(archivePath, [
{ path: "COMPANY.md", content: `---\nname: Flat Zip Co\n---` },
]);
const pkg = await parseCompanyArchive(archivePath);
expect(pkg.company?.name).toBe("Flat Zip Co");
});
it("throws for unsupported archive extension", async () => {
const root = createTempDir();
const archivePath = join(root, "company.rar");
writeTextFile(archivePath, "not a real archive");
await expect(parseCompanyArchive(archivePath)).rejects.toThrow(
"Unsupported archive format",
);
});
});
describe("conversion", () => {
it("maps AgentManifest to AgentCreateInput", () => {
const input = agentManifestToAgentCreateInput({
name: "CEO",
title: "Chief Executive Officer",
instructionBody: "Lead strategy",
skills: ["review"],
reportsTo: null,
metadata: {
sources: [{ kind: "git", repo: "acme/repo" }],
},
});
expect(input).toEqual({
name: "CEO",
role: "custom",
title: "Chief Executive Officer",
instructionsText: "Lead strategy",
metadata: {
skills: ["review"],
sources: [{ kind: "git", repo: "acme/repo" }],
},
});
});
it("converts package agents with skipExisting", () => {
const { inputs, result } = convertAgentCompanies(
{
company: { name: "Example" },
agents: [{ name: "Existing" }, { name: "New Agent", title: "New" }],
teams: [],
projects: [],
tasks: [],
},
{ skipExisting: ["Existing"] },
);
expect(inputs).toHaveLength(1);
expect(inputs[0]?.name).toBe("New Agent");
expect(result).toEqual({
created: ["New Agent"],
skipped: ["Existing"],
errors: [],
});
});
it("prepares imports with manager-first ordering and deferred hierarchy refs", () => {
const { items, result } = prepareAgentCompaniesImport({
company: { name: "Example" },
agents: [
{ name: "IC", reportsTo: "../vp-eng/AGENTS.md" },
{ name: "CEO", slug: "ceo" },
{ name: "VP Eng", slug: "vp-eng", reportsTo: "ceo" },
],
teams: [],
projects: [],
tasks: [],
});
expect(items.map((item) => item.input.name)).toEqual(["CEO", "VP Eng", "IC"]);
expect(items[0]).not.toHaveProperty("reportsTo");
expect(items[1]?.reportsTo).toEqual({
raw: "ceo",
deferredManifestKey: "ceo",
});
expect(items[2]?.reportsTo).toEqual({
raw: "../vp-eng/AGENTS.md",
deferredManifestKey: "vp-eng",
});
expect(result.errors).toEqual([]);
});
it("resolves existing manager refs by slug, path, and agent id", () => {
const existingAgents = [
{
id: "agent-ceo01",
name: "Chief Executive Officer",
metadata: { agentCompaniesSlug: "ceo" },
},
];
const { items, result } = prepareAgentCompaniesImport(
{
company: { name: "Example" },
agents: [
{ name: "Ops Lead", reportsTo: "ceo" },
{ name: "QA Lead", reportsTo: "../ceo/AGENTS.md" },
{ name: "Staff Eng", reportsTo: "agent-ceo01" },
],
teams: [],
projects: [],
tasks: [],
},
{ existingAgents },
);
expect(items.map((item) => item.input.reportsTo)).toEqual([
"agent-ceo01",
"agent-ceo01",
"agent-ceo01",
]);
expect(result.errors).toEqual([]);
});
it("keeps unresolved internal refs out of the import plan", () => {
const { items, result } = prepareAgentCompaniesImport({
company: { name: "Example" },
agents: [{ name: "Worker", reportsTo: "unknown-manager" }],
teams: [],
projects: [],
tasks: [],
});
expect(items).toEqual([]);
expect(result).toEqual({
created: [],
skipped: [],
errors: [
{
name: "Worker",
error:
'Could not resolve reportsTo reference "unknown-manager" to an imported or existing Fusion agent',
},
],
});
});
it("stores the manifest slug in metadata for future hierarchy resolution", () => {
const input = agentManifestToAgentCreateInput({
name: "CEO",
slug: "ceo",
});
expect(input.metadata).toEqual({ agentCompaniesSlug: "ceo" });
});
it("defaults to custom role when no skills are present", () => {
const input = agentManifestToAgentCreateInput({ name: "Generalist" });
expect(input.role).toBe("custom");
});
it("maps manifest icon to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Bot",
icon: "🤖",
role: "executor",
});
expect(input).toEqual({
name: "Bot",
role: "executor",
icon: "🤖",
});
});
it("maps manifest reportsTo to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Worker",
reportsTo: "manager-001",
});
expect(input).toEqual({
name: "Worker",
role: "custom",
reportsTo: "manager-001",
});
});
it("maps manifest role to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Reviewer",
role: "reviewer",
});
expect(input).toEqual({
name: "Reviewer",
role: "reviewer",
});
});
});
describe("mapRoleToCapability", () => {
it("maps known roles and defaults unknowns to custom", () => {
expect(mapRoleToCapability("reviewer")).toBe("reviewer");
expect(mapRoleToCapability("unknown-role")).toBe("custom");
});
});
});

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import type {
AgentCompaniesFrontmatter,
AgentCompaniesImportResult,
AgentCompaniesKind,
AgentCompaniesPackage,
AgentCompaniesSchema,
AgentManifest,
CompanyManifest,
ProjectManifest,
SourceReference,
TaskManifest,
TeamManifest,
} from "../agent-companies-types.js";
describe("agent-companies-types", () => {
it("supports schema and kind literals", () => {
const schema: AgentCompaniesSchema = "agentcompanies/v1";
const kinds: AgentCompaniesKind[] = ["company", "team", "agent", "project", "task", "skill"];
expect(schema).toBe("agentcompanies/v1");
expect(kinds).toHaveLength(6);
});
it("supports shared frontmatter with source metadata", () => {
const source: SourceReference = {
kind: "git",
repo: "acme/agent-company",
path: "agents/ceo/AGENTS.md",
commit: "abc123",
hash: "sha256:def456",
url: "https://example.com/repo",
trackingRef: "main",
};
const frontmatter: AgentCompaniesFrontmatter = {
name: "Lean Dev Shop",
description: "Small engineering-focused AI company",
slug: "lean-dev-shop",
schema: "agentcompanies/v1",
kind: "company",
version: "1.0.0",
license: "MIT",
authors: ["Fusion Team"],
tags: ["ai", "engineering"],
metadata: {
sources: [source],
},
};
expect(frontmatter.metadata?.sources?.[0]?.repo).toBe("acme/agent-company");
});
it("supports company/team/agent/project/task manifests", () => {
const company: CompanyManifest = {
name: "Lean Dev Shop",
goals: ["Ship high-quality software"],
requirements: ["Use review workflow"],
};
const team: TeamManifest = {
name: "Engineering",
manager: "../cto/AGENTS.md",
includes: ["../platform/AGENTS.md"],
};
const agent: AgentManifest = {
name: "CEO",
title: "Chief Executive Officer",
reportsTo: null,
skills: ["plan-ceo-review", "review"],
instructionBody: "Lead strategy and review architecture.",
};
const project: ProjectManifest = {
name: "Q2 Launch",
slug: "q2-launch",
};
const task: TaskManifest = {
name: "Monday Review",
assignee: "./agents/ceo/AGENTS.md",
project: "./projects/q2-launch/PROJECT.md",
schedule: {
timezone: "America/New_York",
startsAt: "2026-04-14T09:00:00",
},
};
expect(company.goals).toHaveLength(1);
expect(team.includes).toEqual(["../platform/AGENTS.md"]);
expect(agent.reportsTo).toBeNull();
expect(project.slug).toBe("q2-launch");
expect(task.schedule?.timezone).toBe("America/New_York");
});
it("supports package and import result shapes", () => {
const pkg: AgentCompaniesPackage = {
company: { name: "Lean Dev Shop" },
agents: [{ name: "CEO" }],
teams: [{ name: "Engineering" }],
projects: [{ name: "Q2 Launch" }],
tasks: [{ name: "Monday Review" }],
};
const result: AgentCompaniesImportResult = {
created: ["CEO"],
skipped: ["CTO"],
errors: [{ name: "Reviewer", error: "invalid manifest" }],
};
expect(pkg.agents[0].name).toBe("CEO");
expect(result.errors[0]?.name).toBe("Reviewer");
});
});

View File

@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
computeAccessState,
isValidPermission,
normalizePermissions,
} from "../agent-permissions.js";
import { AGENT_PERMISSIONS } from "../types.js";
import type { Agent, AgentCapability, AgentPermission } from "../types.js";
function makeAgent(role: AgentCapability, permissions?: Record<string, boolean>): Agent {
return {
id: "agent-001",
name: "Test Agent",
role,
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
permissions,
};
}
describe("normalizePermissions", () => {
it("returns empty set for empty input", () => {
expect(normalizePermissions({})).toEqual(new Set());
});
it("returns only valid permission keys", () => {
const result = normalizePermissions({
"tasks:execute": true,
"foo:bar": true,
"budget:spend": true,
"agents:view": true,
});
expect(result).toEqual(new Set<AgentPermission>(["tasks:execute", "agents:view"]));
});
it("returns only granted permissions", () => {
const result = normalizePermissions({
"tasks:execute": true,
"tasks:assign": false,
"agents:view": false,
});
expect(result).toEqual(new Set<AgentPermission>(["tasks:execute"]));
});
it("returns all valid permissions when all are true", () => {
const allPermissions = Object.fromEntries(
AGENT_PERMISSIONS.map((permission) => [permission, true]),
);
const result = normalizePermissions(allPermissions);
expect(result).toEqual(new Set<AgentPermission>(AGENT_PERMISSIONS));
});
});
describe("isValidPermission", () => {
it("returns true for every entry in AGENT_PERMISSIONS", () => {
for (const permission of AGENT_PERMISSIONS) {
expect(isValidPermission(permission)).toBe(true);
}
});
it("returns false for invalid strings", () => {
expect(isValidPermission("budget:spend")).toBe(false);
expect(isValidPermission("invalid")).toBe(false);
expect(isValidPermission("")).toBe(false);
});
});
describe("computeAccessState", () => {
it("executor role gets execute by default and cannot assign tasks", () => {
const state = computeAccessState(makeAgent("executor"));
expect(state.canExecuteTasks).toBe(true);
expect(state.canAssignTasks).toBe(false);
expect(state.taskAssignSource).toBe("denied");
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
});
it("scheduler role gets assign by default", () => {
const state = computeAccessState(makeAgent("scheduler"));
expect(state.canAssignTasks).toBe(true);
expect(state.taskAssignSource).toBe("role_default");
expect(state.roleDefaultPermissions.has("tasks:assign")).toBe(true);
});
it("custom role has no defaults", () => {
const state = computeAccessState(makeAgent("custom"));
expect(state.canAssignTasks).toBe(false);
expect(state.canCreateAgents).toBe(false);
expect(state.canExecuteTasks).toBe(false);
expect(state.canReviewTasks).toBe(false);
expect(state.canMergeTasks).toBe(false);
expect(state.canDeleteAgents).toBe(false);
expect(state.canManageMissions).toBe(false);
expect(state.canSendMessages).toBe(false);
expect(state.resolvedPermissions.size).toBe(0);
});
it("explicit grant enables assignment and reports explicit_grant source", () => {
const state = computeAccessState(
makeAgent("executor", { "tasks:assign": true }),
);
expect(state.canAssignTasks).toBe(true);
expect(state.taskAssignSource).toBe("explicit_grant");
});
it("explicit false does not remove role defaults", () => {
const state = computeAccessState(
makeAgent("executor", { "tasks:execute": false }),
);
expect(state.canExecuteTasks).toBe(true);
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
expect(state.explicitPermissions.has("tasks:execute")).toBe(false);
});
it("separates explicit and role-default permissions in mixed cases", () => {
const state = computeAccessState(
makeAgent("engineer", {
"tasks:assign": true,
"tasks:merge": true,
"messages:send": true,
}),
);
expect(state.explicitPermissions).toEqual(
new Set<AgentPermission>(["tasks:assign", "tasks:merge", "messages:send"]),
);
expect(state.roleDefaultPermissions).toEqual(
new Set<AgentPermission>([
"tasks:execute",
"tasks:review",
"agents:view",
"messages:read",
"messages:send",
]),
);
expect(state.resolvedPermissions.has("tasks:assign")).toBe(true);
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
});
it("still gets role defaults when permissions field is undefined", () => {
const state = computeAccessState(makeAgent("reviewer", undefined));
expect(state.canReviewTasks).toBe(true);
expect(state.resolvedPermissions.has("tasks:review")).toBe(true);
});
it("ignores invalid permission keys", () => {
const state = computeAccessState(
makeAgent("custom", { "budget:spend": true, "tasks:execute": true }),
);
expect(state.explicitPermissions).toEqual(new Set<AgentPermission>(["tasks:execute"]));
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
expect(state.resolvedPermissions.has("budget:spend" as AgentPermission)).toBe(false);
});
it("resolved permissions are the union of role defaults and explicit grants", () => {
const state = computeAccessState(
makeAgent("executor", {
"tasks:assign": true,
"agents:create": true,
}),
);
const expected = new Set<AgentPermission>([
...state.roleDefaultPermissions,
...state.explicitPermissions,
]);
expect(state.resolvedPermissions).toEqual(expected);
});
});

View File

@@ -0,0 +1,407 @@
import { describe, it, expect } from "vitest";
import {
BUILTIN_AGENT_PROMPTS,
resolveAgentPrompt,
getAvailableTemplates,
getTemplatesForRole,
} from "../agent-prompts.js";
import type { AgentPromptsConfig, AgentPromptTemplate } from "../types.js";
// ---------------------------------------------------------------------------
// resolveAgentPrompt
// ---------------------------------------------------------------------------
describe("resolveAgentPrompt", () => {
it("returns the correct built-in prompt for executor when no config provided", () => {
const result = resolveAgentPrompt("executor");
expect(result).toBeTruthy();
expect(result).toContain("task execution agent");
});
it("returns the correct built-in prompt for triage when no config provided", () => {
const result = resolveAgentPrompt("triage");
expect(result).toBeTruthy();
expect(result).toContain("task specification agent");
});
it("returns the correct built-in prompt for reviewer when no config provided", () => {
const result = resolveAgentPrompt("reviewer");
expect(result).toBeTruthy();
expect(result).toContain("independent code and plan reviewer");
});
it("returns the correct built-in prompt for merger when no config provided", () => {
const result = resolveAgentPrompt("merger");
expect(result).toBeTruthy();
expect(result).toContain("merge agent");
});
it("returns empty string for role with no built-in default", () => {
const result = resolveAgentPrompt("scheduler");
expect(result).toBe("");
});
it("returns custom template when roleAssignments maps to a custom template ID", () => {
const config: AgentPromptsConfig = {
templates: [
{
id: "my-custom-executor",
name: "My Custom Executor",
description: "A custom executor",
role: "executor",
prompt: "You are a custom executor agent.",
},
],
roleAssignments: {
executor: "my-custom-executor",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toBe("You are a custom executor agent.");
});
it("returns built-in template when roleAssignments maps to a built-in template ID", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "senior-engineer",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toBeTruthy();
expect(result).toContain("senior engineering agent");
});
it("throws descriptive error when assigned template ID does not exist", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "nonexistent-template",
},
};
expect(() => resolveAgentPrompt("executor", config)).toThrow(
/Agent prompt template "nonexistent-template" not found/,
);
});
it("prioritizes custom templates over built-in when IDs collide", () => {
const config: AgentPromptsConfig = {
templates: [
{
id: "default-executor",
name: "Overridden Executor",
description: "Custom template that overrides the built-in",
role: "executor",
prompt: "This is the overridden executor prompt.",
},
],
roleAssignments: {
executor: "default-executor",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toBe("This is the overridden executor prompt.");
});
it("returns empty string when config has no roleAssignment for the role", () => {
const config: AgentPromptsConfig = {
templates: [],
};
// scheduler has no built-in default, and no assignment
const result = resolveAgentPrompt("scheduler", config);
expect(result).toBe("");
});
it("returns built-in default when config has empty roleAssignments", () => {
const config: AgentPromptsConfig = {
roleAssignments: {},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toContain("task execution agent");
});
it("built-in executor prompt requires resolving ALL lint and test failures including unrelated", () => {
const result = resolveAgentPrompt("executor");
// The stricter language must be present to prevent "unrelated failure" deferrals
expect(result).toContain("Resolve ALL lint failures and test failures");
expect(result).toContain("even if they appear unrelated or pre-existing");
expect(result).toContain("do not defer them to a separate task");
});
it("senior-engineer prompt requires resolving ALL lint and test failures including unrelated", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "senior-engineer",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toContain("Resolve ALL lint failures and test failures");
expect(result).toContain("even if they appear unrelated or pre-existing");
expect(result).toContain("do not defer them to a separate task");
});
it("built-in executor prompt includes worktree boundary guidance", () => {
const result = resolveAgentPrompt("executor");
expect(result).toContain("## Worktree Boundaries");
expect(result).toContain("isolated git worktree");
expect(result).toContain("inside the current worktree directory");
});
it("built-in executor prompt mentions memory exception", () => {
const result = resolveAgentPrompt("executor");
expect(result).toContain(".fusion/memory/");
});
it("built-in executor prompt mentions attachments exception", () => {
const result = resolveAgentPrompt("executor");
expect(result).toContain("attachments");
});
it("senior-engineer prompt includes worktree boundary guidance", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "senior-engineer",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toContain("## Worktree Boundaries");
expect(result).toContain("isolated git worktree");
expect(result).toContain("inside the current worktree directory");
});
it("senior-engineer prompt mentions memory exception", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "senior-engineer",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toContain(".fusion/memory/");
});
it("senior-engineer prompt mentions attachments exception", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "senior-engineer",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toContain("attachments");
});
// ── Task Document Tool Guidance ─────────────────────────────────────────
it("built-in executor prompt includes task_document_write guidance", () => {
const result = resolveAgentPrompt("executor");
expect(result).toContain("task_document_write");
expect(result).toContain("Task Documents");
expect(result).toContain("Documents tab");
});
it("built-in executor prompt includes task_document_read guidance", () => {
const result = resolveAgentPrompt("executor");
expect(result).toContain("task_document_read");
expect(result).toContain("task documents visible in the dashboard");
});
it("senior-engineer prompt includes task_document_write guidance", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "senior-engineer",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toContain("task_document_write");
expect(result).toContain("Task Documents");
});
it("senior-engineer prompt includes task_document_read guidance", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
executor: "senior-engineer",
},
};
const result = resolveAgentPrompt("executor", config);
expect(result).toContain("task_document_read");
});
it("built-in triage prompt includes task_document_write guidance for planning output", () => {
const result = resolveAgentPrompt("triage");
expect(result).toContain("task_document_write");
expect(result).toContain("planning");
});
it("concise-triage prompt includes task_document_write guidance", () => {
const config: AgentPromptsConfig = {
roleAssignments: {
triage: "concise-triage",
},
};
const result = resolveAgentPrompt("triage", config);
expect(result).toContain("task_document_write");
});
});
// ---------------------------------------------------------------------------
// getAvailableTemplates
// ---------------------------------------------------------------------------
describe("getAvailableTemplates", () => {
it("returns only built-in templates when no config provided", () => {
const templates = getAvailableTemplates();
expect(templates.length).toBe(BUILTIN_AGENT_PROMPTS.length);
// All should be built-in
expect(templates.every((t) => t.builtIn === true)).toBe(true);
});
it("returns only built-in templates when config has no templates", () => {
const templates = getAvailableTemplates({});
expect(templates.length).toBe(BUILTIN_AGENT_PROMPTS.length);
});
it("merges custom templates with built-in", () => {
const customTemplate: AgentPromptTemplate = {
id: "my-custom",
name: "My Custom",
description: "A custom template",
role: "executor",
prompt: "Custom prompt",
};
const templates = getAvailableTemplates({ templates: [customTemplate] });
expect(templates.length).toBe(BUILTIN_AGENT_PROMPTS.length + 1);
expect(templates.find((t) => t.id === "my-custom")).toEqual(customTemplate);
});
it("custom template overrides built-in by ID", () => {
const overrideTemplate: AgentPromptTemplate = {
id: "default-executor",
name: "Overridden",
description: "Overrides the built-in executor",
role: "executor",
prompt: "Overridden prompt",
};
const templates = getAvailableTemplates({ templates: [overrideTemplate] });
const executorTemplate = templates.find((t) => t.id === "default-executor");
expect(executorTemplate?.prompt).toBe("Overridden prompt");
// Should still have the same total count (replaced, not added)
expect(templates.length).toBe(BUILTIN_AGENT_PROMPTS.length);
});
});
// ---------------------------------------------------------------------------
// getTemplatesForRole
// ---------------------------------------------------------------------------
describe("getTemplatesForRole", () => {
it("returns executor templates", () => {
const templates = getTemplatesForRole("executor");
expect(templates.length).toBeGreaterThanOrEqual(1);
expect(templates.every((t) => t.role === "executor")).toBe(true);
});
it("returns triage templates", () => {
const templates = getTemplatesForRole("triage");
expect(templates.length).toBeGreaterThanOrEqual(1);
expect(templates.every((t) => t.role === "triage")).toBe(true);
});
it("returns reviewer templates", () => {
const templates = getTemplatesForRole("reviewer");
expect(templates.length).toBeGreaterThanOrEqual(1);
expect(templates.every((t) => t.role === "reviewer")).toBe(true);
});
it("returns merger templates", () => {
const templates = getTemplatesForRole("merger");
expect(templates.length).toBeGreaterThanOrEqual(1);
expect(templates.every((t) => t.role === "merger")).toBe(true);
});
it("includes custom templates for the role", () => {
const customTemplate: AgentPromptTemplate = {
id: "my-reviewer",
name: "My Reviewer",
description: "A custom reviewer",
role: "reviewer",
prompt: "Custom reviewer prompt",
};
const templates = getTemplatesForRole("reviewer", { templates: [customTemplate] });
const found = templates.find((t) => t.id === "my-reviewer");
expect(found).toBeDefined();
expect(found?.prompt).toBe("Custom reviewer prompt");
});
});
// ---------------------------------------------------------------------------
// Built-in template validation
// ---------------------------------------------------------------------------
describe("BUILTIN_AGENT_PROMPTS", () => {
it("covers all 4 core roles (executor, triage, reviewer, merger)", () => {
const roles = new Set(BUILTIN_AGENT_PROMPTS.map((t) => t.role));
expect(roles.has("executor")).toBe(true);
expect(roles.has("triage")).toBe(true);
expect(roles.has("reviewer")).toBe(true);
expect(roles.has("merger")).toBe(true);
});
it("has a default template for each core role", () => {
const coreRoles: Array<"executor" | "triage" | "reviewer" | "merger"> = [
"executor",
"triage",
"reviewer",
"merger",
];
for (const role of coreRoles) {
const defaultTemplate = BUILTIN_AGENT_PROMPTS.find(
(t) => t.id === `default-${role}`,
);
expect(defaultTemplate).toBeDefined();
expect(defaultTemplate?.role).toBe(role);
}
});
it("has additional role variants (senior-engineer, strict-reviewer, concise-triage)", () => {
const ids = new Set(BUILTIN_AGENT_PROMPTS.map((t) => t.id));
expect(ids.has("senior-engineer")).toBe(true);
expect(ids.has("strict-reviewer")).toBe(true);
expect(ids.has("concise-triage")).toBe(true);
});
it("all built-in templates have valid required fields", () => {
for (const template of BUILTIN_AGENT_PROMPTS) {
expect(template.id).toBeTruthy();
expect(typeof template.id).toBe("string");
expect(template.name).toBeTruthy();
expect(typeof template.name).toBe("string");
expect(template.description).toBeTruthy();
expect(typeof template.description).toBe("string");
expect(template.role).toBeTruthy();
expect(typeof template.role).toBe("string");
expect(template.prompt).toBeTruthy();
expect(typeof template.prompt).toBe("string");
expect(template.builtIn).toBe(true);
}
});
it("all template IDs are unique", () => {
const ids = BUILTIN_AGENT_PROMPTS.map((t) => t.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
summarizeTitle,
checkRateLimit,
getRateLimitResetTime,
validateDescription,
SUMMARIZE_SYSTEM_PROMPT,
MAX_DESCRIPTION_LENGTH,
MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH,
MAX_REQUESTS_PER_HOUR,
ValidationError,
RateLimitError,
AiServiceError,
__resetSummarizeState,
} from "../ai-summarize.js";
describe("ai-summarize", () => {
beforeEach(() => {
__resetSummarizeState();
});
// ── Constants ──────────────────────────────────────────────────────────────
describe("constants", () => {
it("should have correct system prompt", () => {
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("max 60 characters");
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("title summarization");
});
it("should have correct length limits", () => {
expect(MIN_DESCRIPTION_LENGTH).toBe(201);
expect(MAX_DESCRIPTION_LENGTH).toBe(2000);
expect(MAX_TITLE_LENGTH).toBe(60);
});
it("should have correct rate limit", () => {
expect(MAX_REQUESTS_PER_HOUR).toBe(10);
});
});
// ── Validation ─────────────────────────────────────────────────────────────
describe("validateDescription", () => {
it("should accept valid description length", () => {
const desc = "a".repeat(201);
expect(validateDescription(desc)).toBe(desc);
});
it("should throw for null description", () => {
expect(() => validateDescription(null)).toThrow(ValidationError);
expect(() => validateDescription(null)).toThrow("description is required");
});
it("should throw for undefined description", () => {
expect(() => validateDescription(undefined)).toThrow(ValidationError);
});
it("should throw for non-string description", () => {
expect(() => validateDescription(123)).toThrow(ValidationError);
expect(() => validateDescription(123)).toThrow("description must be a string");
});
it("should throw for description too short", () => {
const desc = "a".repeat(100);
expect(() => validateDescription(desc)).toThrow(ValidationError);
expect(() => validateDescription(desc)).toThrow("at least 201 characters");
});
it("should throw for description too long", () => {
const desc = "a".repeat(2001);
expect(() => validateDescription(desc)).toThrow(ValidationError);
expect(() => validateDescription(desc)).toThrow("not exceed 2000 characters");
});
it("should accept description at minimum boundary", () => {
const desc = "a".repeat(201);
expect(validateDescription(desc)).toBe(desc);
});
it("should accept description at maximum boundary", () => {
const desc = "a".repeat(2000);
expect(validateDescription(desc)).toBe(desc);
});
});
// ── Rate Limiting ──────────────────────────────────────────────────────────
describe("checkRateLimit", () => {
it("should allow first request from IP", () => {
expect(checkRateLimit("192.168.1.1")).toBe(true);
});
it("should track request count", () => {
const ip = "192.168.1.1";
for (let i = 0; i < 5; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
expect(checkRateLimit(ip)).toBe(true); // 6th request
});
it("should block after max requests", () => {
const ip = "192.168.1.1";
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
expect(checkRateLimit(ip)).toBe(false); // 11th request should be blocked
});
it("should track different IPs separately", () => {
const ip1 = "192.168.1.1";
const ip2 = "192.168.1.2";
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
expect(checkRateLimit(ip1)).toBe(true);
}
expect(checkRateLimit(ip1)).toBe(false);
// Different IP should still be allowed
expect(checkRateLimit(ip2)).toBe(true);
});
});
describe("getRateLimitResetTime", () => {
it("should return null for unknown IP", () => {
expect(getRateLimitResetTime("unknown")).toBeNull();
});
it("should return reset time after requests", () => {
const ip = "192.168.1.1";
checkRateLimit(ip);
const resetTime = getRateLimitResetTime(ip);
expect(resetTime).toBeInstanceOf(Date);
expect(resetTime!.getTime()).toBeGreaterThan(Date.now());
});
});
// ── summarizeTitle ─────────────────────────────────────────────────────────
describe("summarizeTitle", () => {
it("should return null for descriptions <= 200 characters", async () => {
const result = await summarizeTitle("Short description", "/tmp");
expect(result).toBeNull();
});
it("should throw AiServiceError when AI service cannot process request", async () => {
const longDesc = "a".repeat(201);
await expect(summarizeTitle(longDesc, "/tmp")).rejects.toThrow(AiServiceError);
await expect(summarizeTitle(longDesc, "/tmp")).rejects.toThrow(
/(AI engine not available|No model selected)/
);
});
it("should accept optional provider and modelId", async () => {
// Since engine isn't available in tests, this will throw
const longDesc = "a".repeat(201);
await expect(
summarizeTitle(longDesc, "/tmp", "anthropic", "claude-sonnet-4-5")
).rejects.toThrow(AiServiceError);
});
});
// ── Error Classes ───────────────────────────────────────────────────────────
describe("error classes", () => {
it("ValidationError should have correct name", () => {
const err = new ValidationError("test");
expect(err.name).toBe("ValidationError");
expect(err.message).toBe("test");
});
it("RateLimitError should have correct name and resetTime", () => {
const resetTime = new Date();
const err = new RateLimitError("rate limited", resetTime);
expect(err.name).toBe("RateLimitError");
expect(err.message).toBe("rate limited");
expect(err.resetTime).toBe(resetTime);
});
it("RateLimitError should allow null resetTime", () => {
const err = new RateLimitError("rate limited");
expect(err.name).toBe("RateLimitError");
expect(err.resetTime).toBeNull();
});
it("AiServiceError should have correct name", () => {
const err = new AiServiceError("ai failed");
expect(err.name).toBe("AiServiceError");
expect(err.message).toBe("ai failed");
});
});
// ── State Reset ───────────────────────────────────────────────────────────
describe("__resetSummarizeState", () => {
it("should clear all rate limit entries", () => {
const ip = "192.168.1.1";
for (let i = 0; i < 5; i++) {
checkRateLimit(ip);
}
expect(getRateLimitResetTime(ip)).not.toBeNull();
__resetSummarizeState();
expect(getRateLimitResetTime(ip)).toBeNull();
});
});
});

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getAppVersion, parseSemver } from "../app-version.js";
describe("getAppVersion", () => {
it("should return a non-empty string", () => {
const version = getAppVersion();
expect(typeof version).toBe("string");
expect(version.length).toBeGreaterThan(0);
});
it("should return a valid semver string", () => {
const version = getAppVersion();
// Matches basic semver format: X.Y.Z
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
it("should return the actual package version from package.json", () => {
const version = getAppVersion();
// Read the actual version from package.json for verification
// The test file is at packages/core/src/__tests__/app-version.test.ts
// Walk up from this file to find packages/core/package.json
const testFileDir = dirname(fileURLToPath(import.meta.url));
const coreDir = join(testFileDir, "..", "..");
const pkgPath = join(coreDir, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
expect(version).toBe(pkg.version);
});
it("should cache the result", () => {
// Clear cache by calling multiple times
const version1 = getAppVersion();
const version2 = getAppVersion();
expect(version1).toBe(version2);
// Verify cached version matches the actual package version
const testFileDir = dirname(fileURLToPath(import.meta.url));
const coreDir = join(testFileDir, "..", "..");
const pkgPath = join(coreDir, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
expect(version1).toBe(pkg.version);
});
});
describe("parseSemver", () => {
describe("valid semver versions", () => {
it("parses simple version", () => {
const result = parseSemver("1.2.3");
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
});
it("parses zero version", () => {
const result = parseSemver("0.0.0");
expect(result).toEqual({ major: 0, minor: 0, patch: 0 });
});
it("parses large version numbers", () => {
const result = parseSemver("10.20.30");
expect(result).toEqual({ major: 10, minor: 20, patch: 30 });
});
it("parses prerelease version", () => {
const result = parseSemver("1.2.3-beta.1");
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
});
it("parses prerelease with multiple segments", () => {
const result = parseSemver("1.2.3-alpha.beta.1");
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
});
it("parses version with build metadata", () => {
const result = parseSemver("1.2.3+build.123");
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
});
it("parses version with prerelease and build metadata", () => {
const result = parseSemver("1.2.3-beta.1+build.123");
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
});
});
describe("invalid semver versions", () => {
it("returns null for empty string", () => {
expect(parseSemver("")).toBeNull();
});
it("returns null for non-semver string", () => {
expect(parseSemver("not-semver")).toBeNull();
});
it("returns null for partial version", () => {
expect(parseSemver("1")).toBeNull();
expect(parseSemver("1.2")).toBeNull();
});
it("returns null for invalid major version", () => {
expect(parseSemver("abc.2.3")).toBeNull();
});
it("returns null for version with trailing characters", () => {
expect(parseSemver("1.2.3foo")).toBeNull();
expect(parseSemver("1.2.3 foo")).toBeNull();
});
it("returns null for version with v prefix", () => {
expect(parseSemver("v1.2.3")).toBeNull();
});
it("returns null for version with too many parts", () => {
expect(parseSemver("1.2.3.4")).toBeNull();
expect(parseSemver("1.2.3.4.5")).toBeNull();
});
it("returns null for invalid prerelease suffix", () => {
expect(parseSemver("1.2.3-")).toBeNull();
});
it("returns null for whitespace", () => {
expect(parseSemver(" 1.2.3")).toBeNull();
expect(parseSemver("1.2.3 ")).toBeNull();
expect(parseSemver("1.2.3\n")).toBeNull();
});
});
});

View File

@@ -0,0 +1,949 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AutomationStore } from "../automation-store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import type { ScheduledTask, AutomationRunResult, AutomationStep } from "../automation.js";
import { randomUUID } from "node:crypto";
/** Create a test automation step. */
function makeStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
return {
id: randomUUID(),
type: "command",
name: "Test step",
command: "echo hello",
...overrides,
};
}
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-automation-test-"));
}
describe("AutomationStore", () => {
let rootDir: string;
let store: AutomationStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new AutomationStore(rootDir);
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
describe("init", () => {
it("initializes database-backed store", async () => {
await expect(store.init()).resolves.toBeUndefined();
});
it("is idempotent", async () => {
await expect(store.init()).resolves.toBeUndefined();
await expect(store.init()).resolves.toBeUndefined();
});
});
// ── isValidCron ───────────────────────────────────────────────────
describe("isValidCron", () => {
it("accepts valid cron expressions", () => {
expect(AutomationStore.isValidCron("0 * * * *")).toBe(true);
expect(AutomationStore.isValidCron("*/5 * * * *")).toBe(true);
expect(AutomationStore.isValidCron("0 0 * * 1")).toBe(true);
expect(AutomationStore.isValidCron("0 9 1 * *")).toBe(true);
});
it("rejects invalid cron expressions", () => {
expect(AutomationStore.isValidCron("not a cron")).toBe(false);
expect(AutomationStore.isValidCron("60 * * * *")).toBe(false);
expect(AutomationStore.isValidCron("0 25 * * *")).toBe(false);
});
});
// ── computeNextRun ────────────────────────────────────────────────
describe("computeNextRun", () => {
it("returns a future ISO timestamp", () => {
const fromDate = new Date("2026-01-01T00:00:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getTime()).toBeGreaterThan(fromDate.getTime());
});
it("computes correct next run for hourly", () => {
const fromDate = new Date("2026-01-01T12:30:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getUTCHours()).toBe(13);
expect(new Date(next).getUTCMinutes()).toBe(0);
});
it("computes monthly runs against UTC instead of local machine time", () => {
const fromDate = new Date("2026-04-15T00:00:00Z");
const next = store.computeNextRun("0 0 1 * *", fromDate);
expect(next).toBe("2026-05-01T00:00:00.000Z");
});
});
// ── createSchedule ────────────────────────────────────────────────
describe("createSchedule", () => {
it("creates a schedule with preset type", async () => {
const schedule = await store.createSchedule({
name: "Hourly check",
command: "echo hello",
scheduleType: "hourly",
});
expect(schedule.id).toBeTruthy();
expect(schedule.name).toBe("Hourly check");
expect(schedule.command).toBe("echo hello");
expect(schedule.scheduleType).toBe("hourly");
expect(schedule.cronExpression).toBe("0 * * * *");
expect(schedule.enabled).toBe(true);
expect(schedule.runCount).toBe(0);
expect(schedule.runHistory).toEqual([]);
expect(schedule.nextRunAt).toBeTruthy();
expect(schedule.createdAt).toBeTruthy();
expect(schedule.updatedAt).toBeTruthy();
});
it("creates a schedule with custom cron", async () => {
const schedule = await store.createSchedule({
name: "Every 5 min",
command: "ls",
scheduleType: "custom",
cronExpression: "*/5 * * * *",
});
expect(schedule.cronExpression).toBe("*/5 * * * *");
expect(schedule.scheduleType).toBe("custom");
});
it("creates disabled schedule without nextRunAt", async () => {
const schedule = await store.createSchedule({
name: "Disabled",
command: "echo",
scheduleType: "daily",
enabled: false,
});
expect(schedule.enabled).toBe(false);
expect(schedule.nextRunAt).toBeUndefined();
});
it("rejects empty name", async () => {
await expect(
store.createSchedule({ name: "", command: "echo", scheduleType: "hourly" }),
).rejects.toThrow("Name is required");
});
it("rejects empty command when no steps are provided", async () => {
await expect(
store.createSchedule({ name: "Test", command: "", scheduleType: "hourly" }),
).rejects.toThrow("Command is required");
});
it("allows empty command when steps are provided", async () => {
const step = makeStep();
const schedule = await store.createSchedule({
name: "Steps only",
command: "",
scheduleType: "hourly",
steps: [step],
});
expect(schedule.steps).toHaveLength(1);
expect(schedule.steps![0].id).toBe(step.id);
expect(schedule.command).toBe("");
});
it("rejects custom type without cron expression", async () => {
await expect(
store.createSchedule({ name: "Test", command: "echo", scheduleType: "custom" }),
).rejects.toThrow("Cron expression is required");
});
it("rejects invalid cron expression", async () => {
await expect(
store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "custom",
cronExpression: "bad cron",
}),
).rejects.toThrow("Invalid cron expression");
});
it("persists schedule to database", async () => {
const schedule = await store.createSchedule({
name: "Persist test",
command: "echo persist",
scheduleType: "weekly",
});
const secondStore = new AutomationStore(rootDir);
await secondStore.init();
const reloaded = await secondStore.getSchedule(schedule.id);
expect(reloaded.id).toBe(schedule.id);
expect(reloaded.name).toBe("Persist test");
expect(reloaded.cronExpression).toBe("0 0 * * 1");
});
it("emits schedule:created event", async () => {
const listener = vi.fn();
store.on("schedule:created", listener);
const schedule = await store.createSchedule({
name: "Event test",
command: "echo event",
scheduleType: "hourly",
});
expect(listener).toHaveBeenCalledWith(schedule);
});
it("stores optional timeoutMs", async () => {
const schedule = await store.createSchedule({
name: "Timeout test",
command: "echo",
scheduleType: "hourly",
timeoutMs: 60000,
});
expect(schedule.timeoutMs).toBe(60000);
});
});
// ── getSchedule ───────────────────────────────────────────────────
describe("getSchedule", () => {
it("reads a schedule by id", async () => {
const created = await store.createSchedule({
name: "Get test",
command: "echo get",
scheduleType: "daily",
});
const fetched = await store.getSchedule(created.id);
expect(fetched.id).toBe(created.id);
expect(fetched.name).toBe("Get test");
});
it("throws ENOENT for missing schedule", async () => {
await expect(store.getSchedule("nonexistent")).rejects.toThrow("not found");
});
});
// ── listSchedules ─────────────────────────────────────────────────
describe("listSchedules", () => {
it("returns empty array when no schedules", async () => {
const list = await store.listSchedules();
expect(list).toEqual([]);
});
it("returns all schedules sorted by createdAt", async () => {
await store.createSchedule({ name: "A", command: "echo a", scheduleType: "hourly" });
// Ensure different timestamps
await new Promise((r) => setTimeout(r, 5));
await store.createSchedule({ name: "B", command: "echo b", scheduleType: "daily" });
const list = await store.listSchedules();
expect(list).toHaveLength(2);
expect(list[0].name).toBe("A");
expect(list[1].name).toBe("B");
});
});
// ── updateSchedule ────────────────────────────────────────────────
describe("updateSchedule", () => {
it("updates name and command", async () => {
const schedule = await store.createSchedule({
name: "Original",
command: "echo original",
scheduleType: "hourly",
});
// Small delay to ensure different timestamp
await new Promise((r) => setTimeout(r, 5));
const updated = await store.updateSchedule(schedule.id, {
name: "Updated",
command: "echo updated",
});
expect(updated.name).toBe("Updated");
expect(updated.command).toBe("echo updated");
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(schedule.updatedAt).getTime(),
);
});
it("updates schedule type from preset to custom", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
const updated = await store.updateSchedule(schedule.id, {
scheduleType: "custom",
cronExpression: "*/10 * * * *",
});
expect(updated.scheduleType).toBe("custom");
expect(updated.cronExpression).toBe("*/10 * * * *");
});
it("updates enabled state", async () => {
const schedule = await store.createSchedule({
name: "Toggle",
command: "echo",
scheduleType: "hourly",
});
const disabled = await store.updateSchedule(schedule.id, { enabled: false });
expect(disabled.enabled).toBe(false);
expect(disabled.nextRunAt).toBeUndefined();
const reenabled = await store.updateSchedule(schedule.id, { enabled: true });
expect(reenabled.enabled).toBe(true);
expect(reenabled.nextRunAt).toBeTruthy();
});
it("rejects empty name", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
await expect(
store.updateSchedule(schedule.id, { name: " " }),
).rejects.toThrow("Name cannot be empty");
});
it("rejects invalid cron on custom type", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
await expect(
store.updateSchedule(schedule.id, {
scheduleType: "custom",
cronExpression: "bad cron",
}),
).rejects.toThrow("Invalid cron expression");
});
it("emits schedule:updated event", async () => {
const schedule = await store.createSchedule({
name: "Event test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:updated", listener);
await store.updateSchedule(schedule.id, { name: "Updated" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── deleteSchedule ────────────────────────────────────────────────
describe("deleteSchedule", () => {
it("deletes a schedule", async () => {
const schedule = await store.createSchedule({
name: "Delete me",
command: "echo",
scheduleType: "hourly",
});
const deleted = await store.deleteSchedule(schedule.id);
expect(deleted.id).toBe(schedule.id);
await expect(store.getSchedule(schedule.id)).rejects.toThrow("not found");
});
it("throws for missing schedule", async () => {
await expect(store.deleteSchedule("nonexistent")).rejects.toThrow("not found");
});
it("emits schedule:deleted event", async () => {
const schedule = await store.createSchedule({
name: "Delete test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:deleted", listener);
await store.deleteSchedule(schedule.id);
expect(listener).toHaveBeenCalledWith(schedule);
});
});
// ── recordRun ─────────────────────────────────────────────────────
describe("recordRun", () => {
it("records a successful run", async () => {
const schedule = await store.createSchedule({
name: "Run test",
command: "echo hello",
scheduleType: "hourly",
});
const result: AutomationRunResult = {
success: true,
output: "hello\n",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(schedule.id, result);
expect(updated.lastRunAt).toBe(result.startedAt);
expect(updated.lastRunResult).toEqual(result);
expect(updated.runCount).toBe(1);
expect(updated.runHistory).toHaveLength(1);
expect(updated.runHistory[0]).toEqual(result);
expect(updated.nextRunAt).toBeTruthy();
});
it("records a failed run", async () => {
const schedule = await store.createSchedule({
name: "Fail test",
command: "false",
scheduleType: "hourly",
});
const result: AutomationRunResult = {
success: false,
output: "",
error: "Command failed with exit code 1",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(schedule.id, result);
expect(updated.lastRunResult?.success).toBe(false);
expect(updated.lastRunResult?.error).toContain("exit code 1");
expect(updated.runCount).toBe(1);
});
it("caps run history at MAX_RUN_HISTORY", async () => {
const schedule = await store.createSchedule({
name: "History test",
command: "echo",
scheduleType: "hourly",
});
for (let i = 0; i < 55; i++) {
await store.recordRun(schedule.id, {
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
}
const updated = await store.getSchedule(schedule.id);
expect(updated.runHistory.length).toBeLessThanOrEqual(50);
expect(updated.runCount).toBe(55);
});
it("emits schedule:run event", async () => {
const schedule = await store.createSchedule({
name: "Event test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:run", listener);
const result: AutomationRunResult = {
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
await store.recordRun(schedule.id, result);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].result).toEqual(result);
});
});
// ── getDueSchedules ───────────────────────────────────────────────
describe("getDueSchedules", () => {
it("returns schedules that are due", async () => {
const schedule = await store.createSchedule({
name: "Due test",
command: "echo",
scheduleType: "hourly",
});
// Record a run result to force nextRunAt to be recomputed
// Then use recordRun which sets nextRunAt properly
const pastDate = new Date(Date.now() - 60000).toISOString();
await store.recordRun(schedule.id, {
success: true,
output: "ok",
startedAt: pastDate,
completedAt: pastDate,
});
// Now manually set nextRunAt in the past (the store's internal DB is shared)
// We need to access the DB through the store — let's use a workaround
// by using recordRun which already recomputes nextRunAt. Instead,
// test by creating a schedule whose nextRunAt is already in the past.
// The simplest way is: the schedule was just created with nextRunAt
// in the future. We can't easily make it past via public API.
// Let's just test that getDueSchedules works with disabled/enabled correctly.
// For the actual due test, verify the schedule is NOT due (nextRunAt is in the future)
const due = await store.getDueSchedules("project");
// The schedule's nextRunAt is in the future after recordRun, so it shouldn't be due
// Instead, let's verify it returns enabled schedules only
expect(Array.isArray(due)).toBe(true);
// The schedule has nextRunAt in the future, so it should not be returned
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
it("excludes disabled schedules", async () => {
const schedule = await store.createSchedule({
name: "Disabled test",
command: "echo",
scheduleType: "hourly",
enabled: false,
});
const due = await store.getDueSchedules("project");
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
it("excludes schedules with future nextRunAt", async () => {
const schedule = await store.createSchedule({
name: "Future test",
command: "echo",
scheduleType: "hourly",
});
// nextRunAt is in the future by default
const due = await store.getDueSchedules("project");
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
});
// ── Steps persistence ─────────────────────────────────────────────
describe("steps", () => {
it("creates schedule with steps and persists them", async () => {
const steps: AutomationStep[] = [
makeStep({ name: "Step A", command: "echo a" }),
makeStep({ name: "Step B", type: "ai-prompt", prompt: "Summarize", command: undefined }),
];
const schedule = await store.createSchedule({
name: "Multi-step",
command: "",
scheduleType: "daily",
steps,
});
expect(schedule.steps).toHaveLength(2);
expect(schedule.steps![0].name).toBe("Step A");
expect(schedule.steps![1].type).toBe("ai-prompt");
// Verify round-trip persistence
const fetched = await store.getSchedule(schedule.id);
expect(fetched.steps).toHaveLength(2);
expect(fetched.steps![0].id).toBe(steps[0].id);
expect(fetched.steps![1].prompt).toBe("Summarize");
});
it("creates schedule without steps (legacy mode)", async () => {
const schedule = await store.createSchedule({
name: "Legacy",
command: "echo hello",
scheduleType: "hourly",
});
expect(schedule.steps).toBeUndefined();
});
it("updates steps on existing schedule", async () => {
const schedule = await store.createSchedule({
name: "Updateable",
command: "echo old",
scheduleType: "hourly",
});
expect(schedule.steps).toBeUndefined();
const steps = [makeStep({ name: "New step" })];
const updated = await store.updateSchedule(schedule.id, { steps });
expect(updated.steps).toHaveLength(1);
expect(updated.steps![0].name).toBe("New step");
});
it("clears steps when updating with empty array", async () => {
const schedule = await store.createSchedule({
name: "Clear steps",
command: "echo hello",
scheduleType: "hourly",
steps: [makeStep()],
});
expect(schedule.steps).toHaveLength(1);
const updated = await store.updateSchedule(schedule.id, { steps: [] });
expect(updated.steps).toBeUndefined();
});
it("preserves step model fields through round-trip", async () => {
const step = makeStep({
type: "ai-prompt",
name: "AI Step",
prompt: "Analyze this",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
timeoutMs: 60000,
continueOnFailure: true,
command: undefined,
});
const schedule = await store.createSchedule({
name: "AI schedule",
command: "",
scheduleType: "daily",
steps: [step],
});
const fetched = await store.getSchedule(schedule.id);
const fetchedStep = fetched.steps![0];
expect(fetchedStep.type).toBe("ai-prompt");
expect(fetchedStep.prompt).toBe("Analyze this");
expect(fetchedStep.modelProvider).toBe("anthropic");
expect(fetchedStep.modelId).toBe("claude-sonnet-4-5");
expect(fetchedStep.timeoutMs).toBe(60000);
expect(fetchedStep.continueOnFailure).toBe(true);
});
});
// ── reorderSteps ──────────────────────────────────────────────────
describe("reorderSteps", () => {
it("reorders steps by ID array", async () => {
const stepA = makeStep({ name: "A" });
const stepB = makeStep({ name: "B" });
const stepC = makeStep({ name: "C" });
const schedule = await store.createSchedule({
name: "Reorder test",
command: "",
scheduleType: "daily",
steps: [stepA, stepB, stepC],
});
const reordered = await store.reorderSteps(
schedule.id,
[stepC.id, stepA.id, stepB.id],
);
expect(reordered.steps![0].name).toBe("C");
expect(reordered.steps![1].name).toBe("A");
expect(reordered.steps![2].name).toBe("B");
// Verify persisted
const fetched = await store.getSchedule(schedule.id);
expect(fetched.steps![0].name).toBe("C");
});
it("throws when schedule has no steps", async () => {
const schedule = await store.createSchedule({
name: "No steps",
command: "echo",
scheduleType: "hourly",
});
await expect(
store.reorderSteps(schedule.id, []),
).rejects.toThrow("no steps to reorder");
});
it("throws on step ID count mismatch", async () => {
const stepA = makeStep({ name: "A" });
const stepB = makeStep({ name: "B" });
const schedule = await store.createSchedule({
name: "Mismatch test",
command: "",
scheduleType: "daily",
steps: [stepA, stepB],
});
await expect(
store.reorderSteps(schedule.id, [stepA.id]),
).rejects.toThrow("count mismatch");
});
it("throws on unknown step ID", async () => {
const stepA = makeStep({ name: "A" });
const stepB = makeStep({ name: "B" });
const schedule = await store.createSchedule({
name: "Unknown ID test",
command: "",
scheduleType: "daily",
steps: [stepA, stepB],
});
await expect(
store.reorderSteps(schedule.id, [stepA.id, "nonexistent"]),
).rejects.toThrow('Unknown step ID: "nonexistent"');
});
it("emits schedule:updated event", async () => {
const stepA = makeStep({ name: "A" });
const stepB = makeStep({ name: "B" });
const schedule = await store.createSchedule({
name: "Event test",
command: "",
scheduleType: "daily",
steps: [stepA, stepB],
});
const listener = vi.fn();
store.on("schedule:updated", listener);
await store.reorderSteps(schedule.id, [stepB.id, stepA.id]);
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── Concurrent write safety ───────────────────────────────────────
describe("concurrency", () => {
it("handles concurrent updates safely", async () => {
const schedule = await store.createSchedule({
name: "Concurrent",
command: "echo",
scheduleType: "hourly",
});
// Fire multiple concurrent updates
const updates = Array.from({ length: 10 }, (_, i) =>
store.recordRun(schedule.id, {
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
}),
);
await Promise.all(updates);
const final = await store.getSchedule(schedule.id);
expect(final.runCount).toBe(10);
expect(final.runHistory).toHaveLength(10);
});
});
// ── Scope-aware scheduling ─────────────────────────────────────────
describe("scope-aware scheduling", () => {
it("createSchedule without scope defaults to 'project'", async () => {
const schedule = await store.createSchedule({
name: "Default scope",
command: "echo default",
scheduleType: "hourly",
});
expect(schedule.scope).toBe("project");
// Verify round-trip persistence
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("project");
});
it("createSchedule with scope='global' persists correctly", async () => {
const schedule = await store.createSchedule({
name: "Global scope",
command: "echo global",
scheduleType: "hourly",
scope: "global",
});
expect(schedule.scope).toBe("global");
// Verify round-trip persistence
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("global");
});
it("listSchedules returns both global and project scopes", async () => {
const global = await store.createSchedule({
name: "Global",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
const project = await store.createSchedule({
name: "Project",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
const list = await store.listSchedules();
expect(list).toHaveLength(2);
const globalFound = list.find((s) => s.id === global.id);
const projectFound = list.find((s) => s.id === project.id);
expect(globalFound?.scope).toBe("global");
expect(projectFound?.scope).toBe("project");
});
it("getDueSchedules filters by scope - global only", async () => {
const global = await store.createSchedule({
name: "Global due",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
const project = await store.createSchedule({
name: "Project due",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueSchedules("global");
expect(globalDue.some((s) => s.id === global.id)).toBe(true);
expect(globalDue.some((s) => s.id === project.id)).toBe(false);
const projectDue = await store.getDueSchedules("project");
expect(projectDue.some((s) => s.id === project.id)).toBe(true);
expect(projectDue.some((s) => s.id === global.id)).toBe(false);
});
it("getDueSchedulesAllScopes returns schedules from both scopes", async () => {
const global = await store.createSchedule({
name: "Global due",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
const project = await store.createSchedule({
name: "Project due",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const allDue = await store.getDueSchedulesAllScopes();
expect(allDue.some((s) => s.id === global.id)).toBe(true);
expect(allDue.some((s) => s.id === project.id)).toBe(true);
});
it("getDueSchedules does not leak scopes - global not in project", async () => {
const global = await store.createSchedule({
name: "Global only",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
const projectDue = await store.getDueSchedules("project");
expect(projectDue.some((s) => s.id === global.id)).toBe(false);
});
it("getDueSchedules does not leak scopes - project not in global", async () => {
const project = await store.createSchedule({
name: "Project only",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE automations SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueSchedules("global");
expect(globalDue.some((s) => s.id === project.id)).toBe(false);
});
it("recordRun preserves scope", async () => {
const schedule = await store.createSchedule({
name: "Scope preservation",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
await store.recordRun(schedule.id, {
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("global");
});
it("updateSchedule does not change scope when not specified", async () => {
const schedule = await store.createSchedule({
name: "Original",
command: "echo",
scheduleType: "hourly",
scope: "global",
});
await store.updateSchedule(schedule.id, { name: "Updated" });
const fetched = await store.getSchedule(schedule.id);
expect(fetched.scope).toBe("global");
expect(fetched.name).toBe("Updated");
});
it("updateSchedule does not change scope when scope is specified (scope is immutable after creation)", async () => {
// Note: ScheduledTaskUpdateInput includes scope, but updateSchedule implementation
// does not handle it. Scope is effectively immutable after creation.
const schedule = await store.createSchedule({
name: "Scope immutable",
command: "echo",
scheduleType: "hourly",
scope: "project",
});
await store.updateSchedule(schedule.id, { name: "Updated", scope: "global" });
const fetched = await store.getSchedule(schedule.id);
// Scope remains unchanged because updateSchedule doesn't handle scope updates
expect(fetched.scope).toBe("project");
expect(fetched.name).toBe("Updated");
});
});
});

View File

@@ -0,0 +1,340 @@
import { describe, expect, it } from "vitest";
import { CronExpressionParser } from "cron-parser";
import {
AUTOMATION_PRESETS,
MAX_RUN_HISTORY,
type AutomationRunResult,
type AutomationStep,
type AutomationStepResult,
type ScheduleType,
type ScheduledTask,
type ScheduledTaskCreateInput,
} from "../automation.js";
const expectedPresetMap = {
hourly: "0 * * * *",
daily: "0 0 * * *",
weekly: "0 0 * * 1",
monthly: "0 0 1 * *",
every15Minutes: "*/15 * * * *",
every30Minutes: "*/30 * * * *",
every2Hours: "0 */2 * * *",
every6Hours: "0 */6 * * *",
every12Hours: "0 */12 * * *",
weekdays: "0 9 * * 1-5",
} as const;
const expectedPresetKeys = Object.keys(expectedPresetMap) as Array<Exclude<ScheduleType, "custom">>;
const allScheduleTypesRecord: Record<ScheduleType, true> = {
hourly: true,
daily: true,
weekly: true,
monthly: true,
custom: true,
every15Minutes: true,
every30Minutes: true,
every2Hours: true,
every6Hours: true,
every12Hours: true,
weekdays: true,
};
const allScheduleTypes = Object.keys(allScheduleTypesRecord) as ScheduleType[];
const CRON_TIMEZONE = "UTC";
function cronDateToDate(value: { toISOString(): string | null; getTime(): number }): Date {
const iso = value.toISOString();
return iso ? new Date(iso) : new Date(value.getTime());
}
function parseNextRun(cronExpression: string, currentDate?: Date): Date {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: currentDate ?? new Date(),
tz: CRON_TIMEZONE,
});
return cronDateToDate(interval.next());
}
function createPresetInput(scheduleType: Exclude<ScheduleType, "custom">): ScheduledTaskCreateInput {
return {
name: `Preset ${scheduleType}`,
command: "echo test",
scheduleType,
};
}
function isValidCreateInput(input: ScheduledTaskCreateInput): boolean {
if (!input.name.trim() || !input.command.trim()) {
return false;
}
if (!allScheduleTypes.includes(input.scheduleType)) {
return false;
}
if (input.scheduleType === "custom") {
return Boolean(input.cronExpression?.trim());
}
return true;
}
describe("AUTOMATION_PRESETS", () => {
it("contains every preset key except custom", () => {
const presetKeys = Object.keys(AUTOMATION_PRESETS).sort();
expect(presetKeys).toEqual([...expectedPresetKeys].sort());
});
it("contains valid 5-field cron expressions", () => {
for (const [scheduleType, cronExpression] of Object.entries(AUTOMATION_PRESETS)) {
expect(cronExpression).toMatch(/^\S+ \S+ \S+ \S+ \S+$/);
expect(() => CronExpressionParser.parse(cronExpression)).not.toThrow();
expect(scheduleType).toBeTruthy();
}
});
it("maps each preset key to the expected cron expression", () => {
expect(AUTOMATION_PRESETS).toEqual(expectedPresetMap);
});
it("does not include a custom preset", () => {
expect((AUTOMATION_PRESETS as Record<string, string | undefined>).custom).toBeUndefined();
});
});
describe("ScheduleType", () => {
it("treats all preset keys as valid ScheduleType values", () => {
const presetKeys = Object.keys(AUTOMATION_PRESETS) as Array<Exclude<ScheduleType, "custom">>;
expect(presetKeys.every((key) => allScheduleTypes.includes(key))).toBe(true);
});
it("includes custom in ScheduleType but not in AUTOMATION_PRESETS", () => {
expect(allScheduleTypes).toContain("custom");
expect(Object.prototype.hasOwnProperty.call(AUTOMATION_PRESETS, "custom")).toBe(false);
});
});
describe("MAX_RUN_HISTORY", () => {
it("is set to 50", () => {
expect(MAX_RUN_HISTORY).toBe(50);
});
it("is a positive integer", () => {
expect(Number.isInteger(MAX_RUN_HISTORY) && MAX_RUN_HISTORY > 0).toBe(true);
});
it("is a finite number", () => {
expect(Number.isFinite(MAX_RUN_HISTORY)).toBe(true);
});
});
describe("Interface contracts with AutomationStore", () => {
it("accepts every non-custom preset scheduleType in ScheduledTaskCreateInput", () => {
const inputs = expectedPresetKeys.map((scheduleType) => createPresetInput(scheduleType));
expect(inputs).toHaveLength(expectedPresetKeys.length);
expect(inputs.every(isValidCreateInput)).toBe(true);
});
it("supports custom ScheduledTaskCreateInput with explicit cronExpression", () => {
const customInput: ScheduledTaskCreateInput = {
name: "Custom schedule",
command: "echo custom",
scheduleType: "custom",
cronExpression: "*/10 * * * *",
};
expect(customInput.scheduleType).toBe("custom");
expect(customInput.cronExpression).toBe("*/10 * * * *");
expect(isValidCreateInput(customInput)).toBe(true);
});
it("supports AutomationStep command shape", () => {
const commandStep: AutomationStep = {
id: "step-command-1",
type: "command",
name: "Run command",
command: "echo hello",
};
expect(commandStep).toMatchObject({
id: "step-command-1",
type: "command",
name: "Run command",
command: "echo hello",
});
});
it("supports AutomationStep ai-prompt shape", () => {
const aiPromptStep: AutomationStep = {
id: "step-ai-1",
type: "ai-prompt",
name: "Analyze output",
prompt: "Summarize the latest run output",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
expect(aiPromptStep).toMatchObject({
id: "step-ai-1",
type: "ai-prompt",
name: "Analyze output",
prompt: "Summarize the latest run output",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
it("supports successful AutomationRunResult shape", () => {
const runResult: AutomationRunResult = {
success: true,
output: "ok",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:01.000Z",
};
expect(runResult).toMatchObject({
success: true,
output: "ok",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:01.000Z",
});
});
it("supports failed AutomationRunResult shape with error", () => {
const runResult: AutomationRunResult = {
success: false,
output: "",
error: "Command failed",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:01.000Z",
};
expect(runResult.success).toBe(false);
expect(runResult.error).toBe("Command failed");
});
it("supports AutomationStepResult required shape", () => {
const stepResult: AutomationStepResult = {
stepId: "step-1",
stepName: "Run command",
stepIndex: 0,
success: true,
output: "done",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:01.000Z",
};
expect(stepResult).toMatchObject({
stepId: "step-1",
stepName: "Run command",
stepIndex: 0,
success: true,
output: "done",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:01.000Z",
});
});
it("supports full ScheduledTask shape", () => {
const fullTask: ScheduledTask = {
id: "schedule-1",
name: "Nightly build",
scheduleType: "daily",
cronExpression: "0 0 * * *",
command: "pnpm build",
enabled: true,
runCount: 3,
runHistory: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
description: "Run nightly build",
lastRunAt: "2026-01-02T00:00:00.000Z",
lastRunResult: {
success: true,
output: "ok",
startedAt: "2026-01-02T00:00:00.000Z",
completedAt: "2026-01-02T00:01:00.000Z",
},
nextRunAt: "2026-01-03T00:00:00.000Z",
timeoutMs: 300000,
steps: [
{
id: "step-1",
type: "command",
name: "Build",
command: "pnpm build",
},
],
currentStepIndex: 0,
};
expect(fullTask).toMatchObject({
id: "schedule-1",
name: "Nightly build",
scheduleType: "daily",
cronExpression: "0 0 * * *",
command: "pnpm build",
enabled: true,
runCount: 3,
runHistory: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
});
});
describe("Preset cron expression edge cases", () => {
it("ensures weekdays preset never schedules Saturday or Sunday in the next 7 runs", () => {
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.weekdays, {
currentDate: new Date("2026-04-06T00:00:00.000Z"), // Monday
tz: CRON_TIMEZONE,
});
const days = Array.from({ length: 7 }, () => cronDateToDate(interval.next()).getUTCDay());
expect(days.every((day) => day !== 0 && day !== 6)).toBe(true);
});
it("ensures monthly preset runs on day 1", () => {
const nextRun = parseNextRun(AUTOMATION_PRESETS.monthly, new Date("2026-04-15T00:00:00.000Z"));
expect(nextRun.getUTCDate()).toBe(1);
});
it("ensures every15Minutes preset advances in 15 minute intervals", () => {
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every15Minutes, {
currentDate: new Date("2026-01-01T00:00:00.000Z"),
tz: CRON_TIMEZONE,
});
const first = cronDateToDate(interval.next());
const second = cronDateToDate(interval.next());
const third = cronDateToDate(interval.next());
expect(second.getTime() - first.getTime()).toBe(15 * 60 * 1000);
expect(third.getTime() - second.getTime()).toBe(15 * 60 * 1000);
});
it("ensures every2Hours preset advances in 2 hour intervals", () => {
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every2Hours, {
currentDate: new Date("2026-01-01T00:00:00.000Z"),
tz: CRON_TIMEZONE,
});
const first = cronDateToDate(interval.next());
const second = cronDateToDate(interval.next());
const third = cronDateToDate(interval.next());
expect(second.getTime() - first.getTime()).toBe(2 * 60 * 60 * 1000);
expect(third.getTime() - second.getTime()).toBe(2 * 60 * 60 * 1000);
});
it("ensures each preset computes a next run in the future", () => {
const now = new Date();
for (const cronExpression of Object.values(AUTOMATION_PRESETS)) {
const nextRun = parseNextRun(cronExpression, now);
expect(nextRun.getTime()).toBeGreaterThan(now.getTime());
}
});
});

View File

@@ -0,0 +1,643 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rm, mkdir, writeFile, readdir } from "node:fs/promises";
import {
BackupManager,
createBackupManager,
generateBackupFilename,
validateBackupSchedule,
validateBackupRetention,
validateBackupDir,
runBackupCommand,
syncBackupRoutine,
} from "../backup.js";
import { RoutineStore } from "../routine-store.js";
import type { ProjectSettings } from "../types.js";
describe("BackupManager", () => {
let tempDir: string;
let fusionDir: string;
let backupManager: BackupManager;
beforeEach(async () => {
// Use fake timers for deterministic timestamp control
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
// Create a dummy database file
writeFileSync(join(fusionDir, "fusion.db"), "dummy database content");
backupManager = new BackupManager(fusionDir);
});
afterEach(async () => {
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
describe("createBackup", () => {
it("should create a backup file with correct name pattern", async () => {
const backup = await backupManager.createBackup();
expect(backup.filename).toMatch(/^fusion-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
expect(existsSync(backup.path)).toBe(true);
});
it("should copy database content correctly", async () => {
const backup = await backupManager.createBackup();
const originalContent = readFileSync(join(fusionDir, "fusion.db"), "utf-8");
const backupContent = readFileSync(backup.path, "utf-8");
expect(backupContent).toBe(originalContent);
});
it("should return correct backup info", async () => {
const backup = await backupManager.createBackup();
expect(backup.filename).toBeDefined();
expect(backup.createdAt).toBeDefined();
expect(backup.size).toBeGreaterThan(0);
expect(backup.path).toContain(backup.filename);
});
it("should create backup directory if it does not exist", async () => {
const customBackupDir = "custom-backups";
const manager = new BackupManager(fusionDir, { backupDir: customBackupDir });
const customBackupPath = join(tempDir, customBackupDir);
expect(existsSync(customBackupPath)).toBe(false);
await manager.createBackup();
expect(existsSync(customBackupPath)).toBe(true);
});
});
describe("listBackups", () => {
it("should return empty array when no backups exist", async () => {
const backups = await backupManager.listBackups();
expect(backups).toEqual([]);
});
it("should return sorted array newest-first", async () => {
// Create multiple backups by advancing system time deterministically
const backup1 = await backupManager.createBackup();
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
const backup2 = await backupManager.createBackup();
vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
const backup3 = await backupManager.createBackup();
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(3);
// Verify sorted by createdAt descending (newest first)
expect(backups[0].createdAt >= backups[1].createdAt).toBe(true);
expect(backups[1].createdAt >= backups[2].createdAt).toBe(true);
// Verify correct ordering by filename
expect(backups[0].filename).toBe(backup3.filename);
expect(backups[1].filename).toBe(backup2.filename);
expect(backups[2].filename).toBe(backup1.filename);
});
it("should only list files matching backup pattern", async () => {
await backupManager.createBackup();
// Create some non-backup files
const backupDir = join(tempDir, ".fusion/backups");
await writeFile(join(backupDir, "not-a-backup.txt"), "content");
await writeFile(join(backupDir, "random.db"), "content");
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(1);
expect(backups[0].filename).toMatch(/^fusion-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
});
it("should return correct file sizes", async () => {
const backup = await backupManager.createBackup();
const backups = await backupManager.listBackups();
expect(backups[0].size).toBe(backup.size);
});
it("should list legacy kb-* backups alongside new fusion-* backups", async () => {
// Create a new-style backup
await backupManager.createBackup();
// Create a legacy-style backup file manually
const backupDir = join(tempDir, ".fusion/backups");
await writeFile(join(backupDir, "kb-2025-12-31-120000.db"), "legacy backup content");
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(2);
const filenames = backups.map((b) => b.filename);
expect(filenames).toContain("kb-2025-12-31-120000.db");
expect(filenames.some((f) => f.startsWith("fusion-"))).toBe(true);
});
it("should parse timestamps from legacy kb-* filenames", async () => {
const backupDir = join(tempDir, ".fusion/backups");
await mkdir(backupDir, { recursive: true });
await writeFile(join(backupDir, "kb-2025-06-15-083000.db"), "legacy");
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(1);
expect(backups[0].createdAt).toBe("2025-06-15T08:30:00Z");
});
it("should parse timestamps from legacy kb-pre-restore filenames", async () => {
const backupDir = join(tempDir, ".fusion/backups");
await mkdir(backupDir, { recursive: true });
await writeFile(join(backupDir, "kb-pre-restore-2025-06-15-083000.db"), "legacy pre-restore");
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(1);
expect(backups[0].filename).toBe("kb-pre-restore-2025-06-15-083000.db");
expect(backups[0].createdAt).toBe("2025-06-15T08:30:00Z");
});
it("should list only legacy kb-* backups when no fusion-* exist", async () => {
const backupDir = join(tempDir, ".fusion/backups");
await mkdir(backupDir, { recursive: true });
await writeFile(join(backupDir, "kb-2025-01-01-000000.db"), "legacy1");
await writeFile(join(backupDir, "kb-2025-01-02-000000.db"), "legacy2");
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(2);
expect(backups.every((b) => b.filename.startsWith("kb-"))).toBe(true);
});
});
describe("cleanupOldBackups", () => {
it("should not delete when backup count is within retention", async () => {
// Create 3 backups with retention of 7 by advancing time
for (let i = 0; i < 3; i++) {
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
await backupManager.createBackup();
}
const deleted = await backupManager.cleanupOldBackups();
expect(deleted).toBe(0);
const backups = await backupManager.listBackups();
expect(backups).toHaveLength(3);
});
it("should delete oldest backups exceeding retention", async () => {
const manager = new BackupManager(fusionDir, { retention: 2 });
// Create 4 backups by advancing time deterministically
for (let i = 0; i < 4; i++) {
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
await manager.createBackup();
}
const deleted = await manager.cleanupOldBackups();
expect(deleted).toBe(2); // 4 - 2 = 2 deleted
const backups = await manager.listBackups();
expect(backups).toHaveLength(2);
});
it("should keep the newest backups after cleanup", async () => {
const manager = new BackupManager(fusionDir, { retention: 2 });
// Create 4 backups and record their names by advancing time
const backupNames: string[] = [];
for (let i = 0; i < 4; i++) {
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
const backup = await manager.createBackup();
backupNames.push(backup.filename);
}
await manager.cleanupOldBackups();
const backups = await manager.listBackups();
const remainingNames = backups.map((b) => b.filename);
// Should keep the 2 newest (last 2 in the array)
expect(remainingNames).toContain(backupNames[2]);
expect(remainingNames).toContain(backupNames[3]);
expect(remainingNames).not.toContain(backupNames[0]);
expect(remainingNames).not.toContain(backupNames[1]);
});
});
describe("restoreBackup", () => {
it("should restore backup to main database location", async () => {
const backup = await backupManager.createBackup();
// Modify the original database
await writeFile(join(fusionDir, "fusion.db"), "modified content");
// Restore the backup
await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: false });
// Verify the restore
const restoredContent = readFileSync(join(fusionDir, "fusion.db"), "utf-8");
expect(restoredContent).toBe("dummy database content");
});
it("should throw when backup file does not exist", async () => {
await expect(
backupManager.restoreBackup("nonexistent-backup.db", { createPreRestoreBackup: false })
).rejects.toThrow("Backup file not found");
});
it("should create pre-restore backup by default", async () => {
const backup = await backupManager.createBackup();
// Advance time to ensure different timestamp for pre-restore backup
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
// Restore with default options (should create pre-restore backup)
await backupManager.restoreBackup(backup.filename);
// Check for pre-restore backup
const backups = await backupManager.listBackups();
const preRestoreBackup = backups.find((b) => b.filename.includes("pre-restore"));
expect(preRestoreBackup).toBeDefined();
expect(preRestoreBackup!.filename).toMatch(/^fusion-pre-restore-/);
});
});
});
describe("generateBackupFilename", () => {
it("should generate filename with correct pattern", () => {
const filename = generateBackupFilename();
expect(filename).toMatch(/^fusion-\d{4}-\d{2}-\d{2}-\d{6}\.db$/);
});
it("should generate unique filenames for different timestamps", () => {
// Use fake timers for deterministic time control
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const filename1 = generateBackupFilename();
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
const filename2 = generateBackupFilename();
expect(filename1).not.toBe(filename2);
vi.useRealTimers();
});
});
describe("validateBackupSchedule", () => {
it("should return true for valid cron expressions", () => {
expect(validateBackupSchedule("0 2 * * *")).toBe(true); // Daily at 2 AM
expect(validateBackupSchedule("0 * * * *")).toBe(true); // Hourly
expect(validateBackupSchedule("*/15 * * * *")).toBe(true); // Every 15 minutes
expect(validateBackupSchedule("0 0 * * 0")).toBe(true); // Weekly on Sunday
});
it("should return false for invalid cron expressions", () => {
expect(validateBackupSchedule("invalid")).toBe(false);
expect(validateBackupSchedule("")).toBe(false);
expect(validateBackupSchedule(" ")).toBe(false);
expect(validateBackupSchedule("* *")).toBe(false); // Too few fields
expect(validateBackupSchedule("99 99 99 99 99")).toBe(false); // Out of range
});
});
describe("validateBackupRetention", () => {
it("should return true for valid retention values", () => {
expect(validateBackupRetention(1)).toBe(true);
expect(validateBackupRetention(7)).toBe(true);
expect(validateBackupRetention(100)).toBe(true);
});
it("should return false for invalid retention values", () => {
expect(validateBackupRetention(0)).toBe(false);
expect(validateBackupRetention(-1)).toBe(false);
expect(validateBackupRetention(101)).toBe(false);
expect(validateBackupRetention(1.5)).toBe(false); // Not an integer
expect(validateBackupRetention(NaN)).toBe(false);
});
});
describe("validateBackupDir", () => {
it("should return true for valid relative paths", () => {
expect(validateBackupDir(".fusion/backups")).toBe(true);
expect(validateBackupDir("backups")).toBe(true);
expect(validateBackupDir("data/backups/kb")).toBe(true);
});
it("should return false for absolute paths", () => {
expect(validateBackupDir("/absolute/path")).toBe(false);
expect(validateBackupDir("/home/user/backups")).toBe(false);
});
it("should return false for paths with parent traversal", () => {
expect(validateBackupDir("../backups")).toBe(false);
expect(validateBackupDir(".fusion/../backups")).toBe(false);
expect(validateBackupDir("data/../../backups")).toBe(false);
});
it("should return false for Windows absolute paths", () => {
expect(validateBackupDir("C:\\backups")).toBe(false);
expect(validateBackupDir("D:\\data\\backups")).toBe(false);
});
});
describe("createBackupManager", () => {
it("should create manager with default options when no settings provided", () => {
const manager = createBackupManager("/tmp/.fusion");
expect(manager).toBeInstanceOf(BackupManager);
});
it("should use settings when provided", async () => {
// Use fake timers for deterministic time control
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: "custom/backups",
autoBackupRetention: 2,
};
const manager = createBackupManager(fusionDir, settings);
// Create 4 backups by advancing time
for (let i = 0; i < 4; i++) {
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
await manager.createBackup();
}
// Cleanup should leave only 2
const deleted = await manager.cleanupOldBackups();
expect(deleted).toBe(2);
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
it("should canonicalize legacy .kb/backups to .fusion/backups in settings", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: ".kb/backups", // Legacy value
};
const manager = createBackupManager(fusionDir, settings);
// Use fake timers and create a backup
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const backup = await manager.createBackup();
// Verify the backup was created in the canonical .fusion/backups directory
expect(backup.path).toContain(".fusion/backups");
expect(backup.path).not.toContain(".kb/backups");
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
it("should preserve non-legacy custom .kb/* directories", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
const fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "test");
const settings: Partial<ProjectSettings> = {
autoBackupDir: ".kb/my-custom-backups", // Custom path, not the legacy default
};
const manager = createBackupManager(fusionDir, settings);
// Use fake timers and create a backup
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const backup = await manager.createBackup();
// Verify the backup was created in the custom .kb/my-custom-backups directory
expect(backup.path).toContain(".kb/my-custom-backups");
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
});
describe("syncBackupRoutine", () => {
let tempDir: string;
let routineStore: RoutineStore;
const baseSettings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
};
beforeEach(async () => {
vi.useRealTimers();
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-routine-test-"));
routineStore = new RoutineStore(tempDir);
await routineStore.init();
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it("creates a command-backed routine for automatic database backups", async () => {
const routine = await syncBackupRoutine(routineStore, {
...baseSettings,
autoBackupEnabled: true,
autoBackupSchedule: "0 3 * * *",
});
expect(routine).toBeDefined();
expect(routine?.name).toBe("Database Backup");
expect(routine?.trigger).toEqual({ type: "cron", cronExpression: "0 3 * * *" });
expect(routine?.command).toBe("npx runfusion.ai backup --create");
expect(routine?.agentId).toBe("");
expect(routine?.scope).toBe("project");
});
it("updates the existing backup routine when settings change", async () => {
await syncBackupRoutine(routineStore, {
...baseSettings,
autoBackupEnabled: true,
autoBackupSchedule: "0 2 * * *",
});
const updated = await syncBackupRoutine(routineStore, {
...baseSettings,
autoBackupEnabled: true,
autoBackupSchedule: "30 4 * * *",
});
const routines = await routineStore.listRoutines();
expect(routines).toHaveLength(1);
expect(updated?.trigger).toEqual({ type: "cron", cronExpression: "30 4 * * *" });
expect(updated?.command).toBe("npx runfusion.ai backup --create");
expect(updated?.enabled).toBe(true);
});
it("deletes the backup routine when automatic backups are disabled", async () => {
await syncBackupRoutine(routineStore, {
...baseSettings,
autoBackupEnabled: true,
autoBackupSchedule: "0 2 * * *",
});
await syncBackupRoutine(routineStore, {
...baseSettings,
autoBackupEnabled: false,
});
expect(await routineStore.listRoutines()).toEqual([]);
});
it("rejects invalid backup schedules before creating a routine", async () => {
await expect(syncBackupRoutine(routineStore, {
...baseSettings,
autoBackupEnabled: true,
autoBackupSchedule: "bad-cron",
})).rejects.toThrow("Invalid backup schedule");
expect(await routineStore.listRoutines()).toEqual([]);
});
});
describe("runBackupCommand", () => {
let tempDir: string;
let fusionDir: string;
beforeEach(async () => {
// Use fake timers for deterministic timestamp control
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
fusionDir = join(tempDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
writeFileSync(join(fusionDir, "fusion.db"), "dummy database content");
});
afterEach(async () => {
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
it("should create backup regardless of autoBackupEnabled setting", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: false, // Disabled, but should still work when called manually
};
const result = await runBackupCommand(fusionDir, settings);
// Should succeed even when autoBackupEnabled is false
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
});
it("should create backup when enabled", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
autoBackupRetention: 7,
};
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
expect(result.output).toContain("Backup created");
});
it("should return failure for invalid schedule", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
autoBackupSchedule: "invalid-cron",
};
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(false);
expect(result.output).toContain("Invalid backup schedule");
});
it("should cleanup old backups after creation", async () => {
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
autoBackupRetention: 2,
};
// Create 3 backups first (manually to test cleanup) by advancing time
const manager = createBackupManager(fusionDir, settings);
for (let i = 0; i < 3; i++) {
vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`));
await manager.createBackup();
}
// Now run backup command
vi.setSystemTime(new Date("2026-01-01T00:00:03.000Z"));
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(true);
expect(result.deletedCount).toBeGreaterThanOrEqual(1);
});
it("should return failure when database file is missing", async () => {
// Remove the database
await rm(join(fusionDir, "fusion.db"));
const settings: ProjectSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
autoBackupEnabled: true,
};
const result = await runBackupCommand(fusionDir, settings);
expect(result.success).toBe(false);
expect(result.output).toContain("failed");
});
});

View File

@@ -0,0 +1,278 @@
import { describe, it, expect } from "vitest";
import { canTransition, getValidTransitions, resolveDependencyOrder } from "../board.js";
import { VALID_TRANSITIONS, type Task, type Column } from "../types.js";
/**
* Board logic tests
*
* Tests for column transition validation and dependency resolution.
*/
describe("board", () => {
describe("canTransition", () => {
it("returns true for all valid transitions defined in VALID_TRANSITIONS", () => {
for (const [from, validTos] of Object.entries(VALID_TRANSITIONS)) {
for (const to of validTos) {
expect(canTransition(from as Column, to)).toBe(true);
}
}
});
it("returns false for invalid transitions", () => {
const allColumns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
for (const from of allColumns) {
for (const to of allColumns) {
const isValid = VALID_TRANSITIONS[from].includes(to);
if (!isValid) {
expect(canTransition(from, to)).toBe(false);
}
}
}
});
it("returns false for some invalid backwards transitions", () => {
// done cannot go back to in-review directly
expect(canTransition("done", "in-review")).toBe(false);
// archived cannot go directly back to in-progress
expect(canTransition("archived", "in-progress")).toBe(false);
// triage cannot go backwards at all (no transitions before it)
expect(canTransition("triage", "done")).toBe(false);
expect(canTransition("triage", "archived")).toBe(false);
});
it("returns false for skipping columns", () => {
// triage cannot skip to in-progress
expect(canTransition("triage", "in-progress")).toBe(false);
// todo cannot skip to in-review
expect(canTransition("todo", "in-review")).toBe(false);
// Note: in-progress can transition to done for mission validation tasks
// so we don't test that case here
});
});
describe("getValidTransitions", () => {
it("returns correct arrays for each column", () => {
for (const [column, expected] of Object.entries(VALID_TRANSITIONS)) {
expect(getValidTransitions(column as Column)).toEqual(expected);
}
});
it("returns a copy of the array (modifications don't affect original)", () => {
const transitions = getValidTransitions("todo");
transitions.push("archived" as Column);
// Original should be unchanged
expect(getValidTransitions("todo")).not.toContain("archived");
});
it("returns correct transitions for triage", () => {
expect(getValidTransitions("triage")).toEqual(["todo"]);
});
it("returns correct transitions for todo", () => {
expect(getValidTransitions("todo")).toEqual(["in-progress", "triage"]);
});
it("returns correct transitions for in-progress", () => {
expect(getValidTransitions("in-progress")).toEqual(["in-review", "todo", "triage", "done"]);
});
it("returns correct transitions for in-review", () => {
expect(getValidTransitions("in-review")).toEqual(["done", "in-progress", "todo"]);
});
it("returns correct transitions for done", () => {
expect(getValidTransitions("done")).toEqual(["todo", "triage", "archived"]);
});
it("returns correct transitions for archived", () => {
expect(getValidTransitions("archived")).toEqual(["done"]);
});
});
describe("resolveDependencyOrder", () => {
function createTask(id: string, dependencies: string[] = []): Task {
return {
id,
description: `Task ${id}`,
column: "todo",
dependencies,
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
it("returns empty array for empty task array", () => {
expect(resolveDependencyOrder([])).toEqual([]);
});
it("returns single task ID when no dependencies", () => {
const task = createTask("FN-001");
expect(resolveDependencyOrder([task])).toEqual(["FN-001"]);
});
it("handles linear dependencies (A → B → C)", () => {
// C depends on B, B depends on A
const taskC = createTask("FN-003", ["FN-002"]);
const taskB = createTask("FN-002", ["FN-001"]);
const taskA = createTask("FN-001");
const order = resolveDependencyOrder([taskC, taskB, taskA]);
// A should come before B, B should come before C
const indexA = order.indexOf("FN-001");
const indexB = order.indexOf("FN-002");
const indexC = order.indexOf("FN-003");
expect(indexA).toBeLessThan(indexB);
expect(indexB).toBeLessThan(indexC);
});
it("handles diamond dependencies (A → B, A → C, B → D, C → D)", () => {
// A
// / \
// B C
// \ /
// D
const taskA = createTask("FN-A");
const taskB = createTask("FN-B", ["FN-A"]);
const taskC = createTask("FN-C", ["FN-A"]);
const taskD = createTask("FN-D", ["FN-B", "FN-C"]);
const order = resolveDependencyOrder([taskD, taskC, taskB, taskA]);
const indexA = order.indexOf("FN-A");
const indexB = order.indexOf("FN-B");
const indexC = order.indexOf("FN-C");
const indexD = order.indexOf("FN-D");
// A should be first
expect(indexA).toBeLessThan(indexB);
expect(indexA).toBeLessThan(indexC);
// Both B and C should come before D
expect(indexB).toBeLessThan(indexD);
expect(indexC).toBeLessThan(indexD);
});
it("handles disconnected components (independent tasks)", () => {
const taskA = createTask("FN-A");
const taskB = createTask("FN-B");
const taskC = createTask("FN-C");
const order = resolveDependencyOrder([taskB, taskC, taskA]);
// All tasks should be in the output
expect(order).toContain("FN-A");
expect(order).toContain("FN-B");
expect(order).toContain("FN-C");
expect(order).toHaveLength(3);
});
it("handles circular dependencies gracefully (should not infinite loop)", () => {
// A → B → C → A (circular)
const taskA = createTask("FN-A", ["FN-C"]);
const taskB = createTask("FN-B", ["FN-A"]);
const taskC = createTask("FN-C", ["FN-B"]);
// Should complete without hanging
const order = resolveDependencyOrder([taskA, taskB, taskC]);
// All tasks should be in the output (order is not strictly defined for circular)
expect(order).toContain("FN-A");
expect(order).toContain("FN-B");
expect(order).toContain("FN-C");
expect(order).toHaveLength(3);
});
it("handles self-referential dependencies gracefully", () => {
const taskA = createTask("FN-A", ["FN-A"]);
const taskB = createTask("FN-B");
// Should complete without infinite recursion
const order = resolveDependencyOrder([taskA, taskB]);
expect(order).toContain("FN-A");
expect(order).toContain("FN-B");
expect(order).toHaveLength(2);
});
it("handles partial ordering correctly", () => {
// A depends on B, C and D are independent
const taskA = createTask("FN-A", ["FN-B"]);
const taskB = createTask("FN-B");
const taskC = createTask("FN-C");
const taskD = createTask("FN-D");
const order = resolveDependencyOrder([taskA, taskB, taskC, taskD]);
// B must come before A
expect(order.indexOf("FN-B")).toBeLessThan(order.indexOf("FN-A"));
// All tasks should be present
expect(order).toHaveLength(4);
});
it("handles empty dependencies array correctly", () => {
const taskA = createTask("FN-A", []);
const taskB = createTask("FN-B", []);
const order = resolveDependencyOrder([taskA, taskB]);
expect(order).toContain("FN-A");
expect(order).toContain("FN-B");
expect(order).toHaveLength(2);
});
it("handles complex dependency graph", () => {
// E depends on D
// D depends on B and C
// B depends on A
// C depends on A
// A has no deps
const taskA = createTask("FN-A");
const taskB = createTask("FN-B", ["FN-A"]);
const taskC = createTask("FN-C", ["FN-A"]);
const taskD = createTask("FN-D", ["FN-B", "FN-C"]);
const taskE = createTask("KB-E", ["FN-D"]);
const order = resolveDependencyOrder([taskE, taskD, taskC, taskB, taskA]);
// Validate partial ordering constraints
expect(order.indexOf("FN-A")).toBeLessThan(order.indexOf("FN-B"));
expect(order.indexOf("FN-A")).toBeLessThan(order.indexOf("FN-C"));
expect(order.indexOf("FN-B")).toBeLessThan(order.indexOf("FN-D"));
expect(order.indexOf("FN-C")).toBeLessThan(order.indexOf("FN-D"));
expect(order.indexOf("FN-D")).toBeLessThan(order.indexOf("KB-E"));
expect(order).toHaveLength(5);
});
it("preserves all tasks from input (no tasks dropped)", () => {
const tasks = Array.from({ length: 10 }, (_, i) =>
createTask(`KB-${String(i + 1).padStart(3, "0")}`)
);
const order = resolveDependencyOrder(tasks);
expect(order).toHaveLength(10);
for (const task of tasks) {
expect(order).toContain(task.id);
}
});
it("returns deterministic order for same input", () => {
const taskA = createTask("FN-A");
const taskB = createTask("FN-B", ["FN-A"]);
const taskC = createTask("FN-C", ["FN-A"]);
const order1 = resolveDependencyOrder([taskA, taskB, taskC]);
const order2 = resolveDependencyOrder([taskA, taskB, taskC]);
expect(order1).toEqual(order2);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,545 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CentralDatabase, createCentralDatabase, toJson, fromJson } from "../central-db.js";
describe("CentralDatabase", () => {
let tempDir: string;
let db: CentralDatabase;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "kb-central-test-"));
db = createCentralDatabase(tempDir);
});
afterEach(() => {
db.close();
rmSync(tempDir, { recursive: true, force: true });
});
describe("initialization", () => {
it("should create database at the specified path", () => {
db.init();
const dbPath = db.getPath();
expect(dbPath).toBe(join(tempDir, "fusion-central.db"));
// Verify file exists
const stats = statSync(dbPath);
expect(stats.isFile()).toBe(true);
});
it("should create the global directory if it doesn't exist", () => {
const newTempDir = join(tmpdir(), `kb-central-test-${Date.now()}`);
const newDb = createCentralDatabase(newTempDir);
newDb.init();
expect(statSync(newTempDir).isDirectory()).toBe(true);
newDb.close();
rmSync(newTempDir, { recursive: true, force: true });
});
it("should initialize schema version", () => {
db.init();
expect(db.getSchemaVersion()).toBe(5);
});
it("should seed lastModified on init", () => {
db.init();
const lastModified = db.getLastModified();
expect(lastModified).toBeGreaterThan(0);
});
it("should seed globalConcurrency default row", () => {
db.init();
const row = db.prepare("SELECT * FROM globalConcurrency WHERE id = 1").get() as {
id: number;
globalMaxConcurrent: number;
currentlyActive: number;
queuedCount: number;
} | undefined;
expect(row).toBeDefined();
expect(row?.globalMaxConcurrent).toBe(4);
expect(row?.currentlyActive).toBe(0);
expect(row?.queuedCount).toBe(0);
});
it("should apply nodes defaults when optional values are omitted", () => {
db.init();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("node_test", "local-test", "local", now, now);
const row = db.prepare("SELECT status, maxConcurrent FROM nodes WHERE id = ?").get("node_test") as
| {
status: string;
maxConcurrent: number;
}
| undefined;
expect(row).toBeDefined();
expect(row?.status).toBe("offline");
expect(row?.maxConcurrent).toBe(2);
});
it("should create all required tables", () => {
db.init();
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("projects");
expect(tableNames).toContain("projectHealth");
expect(tableNames).toContain("centralActivityLog");
expect(tableNames).toContain("globalConcurrency");
expect(tableNames).toContain("nodes");
expect(tableNames).toContain("peerNodes");
expect(tableNames).toContain("__meta");
});
it("should include nodeId column on projects table", () => {
db.init();
const columns = db.prepare("PRAGMA table_info(projects)").all() as Array<{
name: string;
}>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("nodeId");
});
it("should include systemMetrics and knownPeers columns on nodes table", () => {
db.init();
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{
name: string;
}>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("systemMetrics");
expect(columnNames).toContain("knownPeers");
});
it("should include versionInfo and pluginVersions columns on nodes table", () => {
db.init();
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{
name: string;
}>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("versionInfo");
expect(columnNames).toContain("pluginVersions");
});
it("should create peerNodes table with expected columns", () => {
db.init();
const columns = db.prepare("PRAGMA table_info(peerNodes)").all() as Array<{
name: string;
}>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toEqual(
expect.arrayContaining([
"id",
"nodeId",
"peerNodeId",
"name",
"url",
"status",
"lastSeen",
"connectedAt",
]),
);
});
it("should create required indexes", () => {
db.init();
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name")
.all() as Array<{ name: string }>;
const indexNames = indexes.map((i) => i.name);
expect(indexNames).toContain("idxProjectsPath");
expect(indexNames).toContain("idxProjectsStatus");
expect(indexNames).toContain("idxActivityLogTimestamp");
expect(indexNames).toContain("idxActivityLogType");
expect(indexNames).toContain("idxActivityLogProjectId");
expect(indexNames).toContain("idxNodesStatus");
expect(indexNames).toContain("idxNodesType");
expect(indexNames).toContain("idxPeerNodesNodeId");
});
});
describe("schema migrations", () => {
it("should migrate from v2 to v3 with mesh node columns and peer table", () => {
const now = new Date().toISOString();
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
isolationMode TEXT NOT NULL DEFAULT 'in-process',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
lastActivityAt TEXT,
nodeId TEXT,
settings TEXT
);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
value TEXT
);
`);
db.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '2')").run();
db.prepare("INSERT INTO __meta (key, value) VALUES ('lastModified', ?)").run(String(Date.now()));
db.prepare(
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("node_legacy", "legacy", "local", now, now);
db.init();
expect(db.getSchemaVersion()).toBe(5);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
expect(nodeColumnNames).toContain("systemMetrics");
expect(nodeColumnNames).toContain("knownPeers");
const peerTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='peerNodes'")
.get() as { name: string } | undefined;
expect(peerTable?.name).toBe("peerNodes");
const peerIndexes = db
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='peerNodes'")
.all() as Array<{ name: string }>;
expect(peerIndexes.map((index) => index.name)).toContain("idxPeerNodesNodeId");
});
it("should migrate from v3 to v4 with version tracking columns", () => {
const now = new Date().toISOString();
// Create v3 schema manually
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
isolationMode TEXT NOT NULL DEFAULT 'in-process',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
lastActivityAt TEXT,
nodeId TEXT,
settings TEXT
);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
systemMetrics TEXT,
knownPeers TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
value TEXT
);
`);
db.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '3')").run();
db.prepare("INSERT INTO __meta (key, value) VALUES ('lastModified', ?)").run(String(Date.now()));
db.prepare(
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("node_v3", "v3-node", "local", now, now);
db.init();
expect(db.getSchemaVersion()).toBe(5);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
expect(nodeColumnNames).toContain("versionInfo");
expect(nodeColumnNames).toContain("pluginVersions");
// Verify nullable columns - can insert node without them
const row = db.prepare("SELECT versionInfo, pluginVersions FROM nodes WHERE id = ?").get("node_v3") as {
versionInfo: string | null;
pluginVersions: string | null;
} | undefined;
expect(row).toBeDefined();
expect(row?.versionInfo).toBeNull();
expect(row?.pluginVersions).toBeNull();
});
});
describe("transactions", () => {
beforeEach(() => {
db.init();
});
it("should support basic transactions", () => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_1",
"Test Project",
"/test/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
});
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_1") as { id: string; name: string } | undefined;
expect(row).toBeDefined();
expect(row?.name).toBe("Test Project");
});
it("should rollback on error", () => {
expect(() => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_2",
"Test Project",
"/test/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
throw new Error("Intentional error");
});
}).toThrow("Intentional error");
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_2") as { id: string } | undefined;
expect(row).toBeUndefined();
});
it("should support nested transactions via savepoints", () => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_outer",
"Outer Project",
"/outer/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_inner",
"Inner Project",
"/inner/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
});
});
const outerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_outer") as { id: string } | undefined;
const innerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_inner") as { id: string } | undefined;
expect(outerRow).toBeDefined();
expect(innerRow).toBeDefined();
});
it("should rollback nested transaction without affecting outer", () => {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_outer_2",
"Outer Project",
"/outer/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
// Inner transaction throws but is caught
try {
db.transaction(() => {
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_inner_2",
"Inner Project",
"/inner/path",
"active",
"in-process",
new Date().toISOString(),
new Date().toISOString()
);
throw new Error("Inner error");
});
} catch {
// Ignore inner error
}
});
const outerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_outer_2") as { id: string } | undefined;
const innerRow = db.prepare("SELECT * FROM projects WHERE id = ?").get("proj_inner_2") as { id: string } | undefined;
expect(outerRow).toBeDefined();
expect(innerRow).toBeUndefined();
});
});
describe("lastModified tracking", () => {
beforeEach(() => {
db.init();
});
it("should bump lastModified", () => {
const before = db.getLastModified();
// Small delay to ensure different timestamp
const start = Date.now();
while (Date.now() < start + 2) { /* spin */ }
db.bumpLastModified();
const after = db.getLastModified();
expect(after).toBeGreaterThan(before);
});
it("should guarantee monotonic increase", () => {
db.bumpLastModified();
const first = db.getLastModified();
db.bumpLastModified();
const second = db.getLastModified();
expect(second).toBeGreaterThan(first);
});
});
describe("foreign key constraints", () => {
beforeEach(() => {
db.init();
});
it("should enforce foreign key constraints", () => {
// Try to insert health record for non-existent project
expect(() => {
db.prepare("INSERT INTO projectHealth (projectId, status, updatedAt) VALUES (?, ?, ?)").run(
"nonexistent",
"active",
new Date().toISOString()
);
}).toThrow();
});
it("should cascade delete project health on project deletion", () => {
const now = new Date().toISOString();
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_cascade",
"Cascade Test",
"/cascade/path",
"active",
"in-process",
now,
now
);
db.prepare("INSERT INTO projectHealth (projectId, status, updatedAt) VALUES (?, ?, ?)").run(
"proj_cascade",
"active",
now
);
// Verify health record exists
const healthBefore = db.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get("proj_cascade") as { projectId: string } | undefined;
expect(healthBefore).toBeDefined();
// Delete project
db.prepare("DELETE FROM projects WHERE id = ?").run("proj_cascade");
// Health record should be gone (cascade delete)
const healthAfter = db.prepare("SELECT * FROM projectHealth WHERE projectId = ?").get("proj_cascade") as { projectId: string } | undefined;
expect(healthAfter).toBeUndefined();
});
it("should cascade delete activity log entries on project deletion", () => {
const now = new Date().toISOString();
db.prepare("INSERT INTO projects (id, name, path, status, isolationMode, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
"proj_activity",
"Activity Test",
"/activity/path",
"active",
"in-process",
now,
now
);
db.prepare("INSERT INTO centralActivityLog (id, timestamp, type, projectId, projectName, details) VALUES (?, ?, ?, ?, ?, ?)").run(
"log_1",
now,
"task:created",
"proj_activity",
"Activity Test",
"Test activity"
);
// Verify log entry exists
const logBefore = db.prepare("SELECT * FROM centralActivityLog WHERE id = ?").get("log_1") as { id: string } | undefined;
expect(logBefore).toBeDefined();
// Delete project
db.prepare("DELETE FROM projects WHERE id = ?").run("proj_activity");
// Log entry should be gone (cascade delete)
const logAfter = db.prepare("SELECT * FROM centralActivityLog WHERE id = ?").get("log_1") as { id: string } | undefined;
expect(logAfter).toBeUndefined();
});
});
describe("JSON helpers", () => {
it("should stringify arrays for JSON columns", () => {
const arr = ["a", "b", "c"];
expect(toJson(arr)).toBe('["a","b","c"]');
});
it("should return '[]' for null/undefined", () => {
expect(toJson(null)).toBe("[]");
expect(toJson(undefined)).toBe("[]");
});
it("should parse JSON columns correctly", () => {
const json = '{"key": "value", "num": 42}';
const parsed = fromJson<{ key: string; num: number }>(json);
expect(parsed).toEqual({ key: "value", num: 42 });
});
it("should return undefined for null/empty JSON", () => {
expect(fromJson(null)).toBeUndefined();
expect(fromJson(undefined)).toBeUndefined();
expect(fromJson("")).toBeUndefined();
});
it("should return undefined for invalid JSON", () => {
expect(fromJson("not valid json")).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { GlobalSettingsStore } from "../global-settings.js";
import {
DaemonTokenManager,
DAEMON_TOKEN_PREFIX,
DAEMON_TOKEN_HEX_LENGTH,
isDaemonTokenFormat,
} from "../daemon-token.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-daemon-token-test-"));
}
describe("isDaemonTokenFormat", () => {
it("returns true for valid format", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef01")).toBe(true);
});
it("returns true for all lowercase hex", () => {
expect(isDaemonTokenFormat("fn_0123456789abcdef0123456789abcdef")).toBe(true);
});
it("returns false for missing prefix", () => {
expect(isDaemonTokenFormat("a1b2c3d4e5f6789012345678abcdef01")).toBe(false);
});
it("returns false for wrong prefix", () => {
expect(isDaemonTokenFormat("fn__a1b2c3d4e5f6789012345678abcdef01")).toBe(false);
});
it("returns false for wrong length (too short)", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef0")).toBe(false);
});
it("returns false for wrong length (too long)", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef012")).toBe(false);
});
it("returns false for uppercase hex", () => {
expect(isDaemonTokenFormat("fn_A1B2C3D4E5F6789012345678ABCDEF01")).toBe(false);
});
it("returns false for mixed case hex", () => {
expect(isDaemonTokenFormat("fn_A1b2C3d4E5f6789012345678AbCdEf01")).toBe(false);
});
it("returns false for empty string", () => {
expect(isDaemonTokenFormat("")).toBe(false);
});
it("returns false for special characters", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef0!")).toBe(false);
});
it("returns false for prefix only", () => {
expect(isDaemonTokenFormat("fn_")).toBe(false);
});
});
describe("DaemonTokenManager", () => {
let dir: string;
let store: GlobalSettingsStore;
let manager: DaemonTokenManager;
beforeEach(() => {
dir = makeTmpDir();
store = new GlobalSettingsStore(dir);
manager = new DaemonTokenManager(store);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("generateToken()", () => {
it("generates a token with correct format", async () => {
const token = await manager.generateToken();
expect(token).toMatch(/^fn_[0-9a-f]{32}$/);
expect(token.startsWith(DAEMON_TOKEN_PREFIX)).toBe(true);
expect(token.length).toBe(DAEMON_TOKEN_PREFIX.length + DAEMON_TOKEN_HEX_LENGTH);
});
it("stores the token in settings", async () => {
const token = await manager.generateToken();
const settings = await store.getSettings();
expect(settings.daemonToken).toBe(token);
});
it("returns the generated token", async () => {
const token = await manager.generateToken();
expect(typeof token).toBe("string");
expect(token.length).toBeGreaterThan(0);
});
it("throws if token already exists", async () => {
await manager.generateToken();
await expect(manager.generateToken()).rejects.toThrow(
"Daemon token already exists. Use rotateToken() to replace it.",
);
});
it("generates unique tokens on each call", async () => {
// First, rotate to get an existing token
const firstToken = await manager.rotateToken();
// Rotate again to get a second token
const secondToken = await manager.rotateToken();
expect(firstToken).not.toBe(secondToken);
});
});
describe("getToken()", () => {
it("returns undefined when no token", async () => {
const token = await manager.getToken();
expect(token).toBeUndefined();
});
it("returns stored token after generation", async () => {
const generated = await manager.generateToken();
const retrieved = await manager.getToken();
expect(retrieved).toBe(generated);
});
it("returns stored token after rotation", async () => {
await manager.rotateToken();
const retrieved = await manager.getToken();
expect(retrieved).toMatch(/^fn_[0-9a-f]{32}$/);
});
});
describe("validateToken()", () => {
it("returns true for valid token", async () => {
const token = await manager.generateToken();
const isValid = await manager.validateToken(token);
expect(isValid).toBe(true);
});
it("returns false for wrong token", async () => {
await manager.generateToken();
const isValid = await manager.validateToken(
"fn_00000000000000000000000000000001",
);
expect(isValid).toBe(false);
});
it("returns false when no token stored", async () => {
const isValid = await manager.validateToken(
"fn_a1b2c3d4e5f6789012345678abcdef01",
);
expect(isValid).toBe(false);
});
it("returns false for empty string", async () => {
await manager.generateToken();
const isValid = await manager.validateToken("");
expect(isValid).toBe(false);
});
it("returns false for wrong length token", async () => {
await manager.generateToken();
const isValid = await manager.validateToken(
"fn_a1b2c3d4e5f6789012345678abcdef0", // one char short
);
expect(isValid).toBe(false);
});
it("handles timing-safe comparison correctly", async () => {
const token = await manager.generateToken();
// Valid token should return true
expect(await manager.validateToken(token)).toBe(true);
// Invalid token should return false
expect(await manager.validateToken("fn_00000000000000000000000000000001")).toBe(false);
});
});
describe("rotateToken()", () => {
it("generates new token replacing old", async () => {
const oldToken = await manager.generateToken();
const newToken = await manager.rotateToken();
expect(newToken).not.toBe(oldToken);
expect(newToken).toMatch(/^fn_[0-9a-f]{32}$/);
});
it("returns different token each call", async () => {
const tokens = new Set<string>();
for (let i = 0; i < 5; i++) {
tokens.add(await manager.rotateToken());
}
// All tokens should be unique
expect(tokens.size).toBe(5);
});
it("works when no existing token", async () => {
const token = await manager.rotateToken();
expect(token).toMatch(/^fn_[0-9a-f]{32}$/);
expect(await manager.getToken()).toBe(token);
});
it("stores new token after rotation", async () => {
await manager.generateToken();
await manager.rotateToken();
const stored = await manager.getToken();
expect(stored).toMatch(/^fn_[0-9a-f]{32}$/);
});
});
describe("integration: full lifecycle", () => {
it("generate → validate → rotate → validate new → old token fails", async () => {
// Generate a token
const token = await manager.generateToken();
// Validate the original token
expect(await manager.validateToken(token)).toBe(true);
// Rotate to get a new token
const newToken = await manager.rotateToken();
// Old token should no longer be valid
expect(await manager.validateToken(token)).toBe(false);
// New token should be valid
expect(await manager.validateToken(newToken)).toBe(true);
// New token should be different from old
expect(newToken).not.toBe(token);
});
});
describe("token format specifics", () => {
it("generated token has correct prefix", async () => {
const token = await manager.generateToken();
expect(token.startsWith(DAEMON_TOKEN_PREFIX)).toBe(true);
});
it("generated token has exactly 32 lowercase hex chars", async () => {
const token = await manager.generateToken();
const hexPart = token.slice(DAEMON_TOKEN_PREFIX.length);
expect(hexPart).toMatch(/^[0-9a-f]{32}$/);
});
it("generated token has correct total length", async () => {
const token = await manager.generateToken();
expect(token.length).toBe(DAEMON_TOKEN_PREFIX.length + DAEMON_TOKEN_HEX_LENGTH);
});
});
describe("DAEMON_TOKEN_PREFIX constant", () => {
it("is fn_", () => {
expect(DAEMON_TOKEN_PREFIX).toBe("fn_");
});
});
describe("DAEMON_TOKEN_HEX_LENGTH constant", () => {
it("is 32", () => {
expect(DAEMON_TOKEN_HEX_LENGTH).toBe(32);
});
});
});

View File

@@ -0,0 +1,648 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "../db-migrate.js";
import { Database } from "../db.js";
import { mkdir, writeFile, rm, readdir, appendFile } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-migrate-test-"));
}
describe("detectLegacyData", () => {
let tmpDir: string;
let fusionDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("returns false for empty directory", () => {
expect(detectLegacyData(fusionDir)).toBe(false);
});
it("returns true when tasks/ exists", async () => {
await mkdir(join(fusionDir, "tasks"), { recursive: true });
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when config.json exists", async () => {
await mkdir(fusionDir, { recursive: true });
await writeFile(join(fusionDir, "config.json"), '{"nextId":1}');
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when activity-log.jsonl exists", async () => {
await mkdir(fusionDir, { recursive: true });
await writeFile(join(fusionDir, "activity-log.jsonl"), "");
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when archive.jsonl exists", async () => {
await mkdir(fusionDir, { recursive: true });
await writeFile(join(fusionDir, "archive.jsonl"), "");
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when automations/ exists", async () => {
await mkdir(join(fusionDir, "automations"), { recursive: true });
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns true when agents/ exists", async () => {
await mkdir(join(fusionDir, "agents"), { recursive: true });
expect(detectLegacyData(fusionDir)).toBe(true);
});
it("returns false when db already exists", async () => {
await mkdir(join(fusionDir, "tasks"), { recursive: true });
// Create a db file
const db = new Database(fusionDir);
db.init();
db.close();
expect(detectLegacyData(fusionDir)).toBe(false);
});
});
describe("getMigrationStatus", () => {
let tmpDir: string;
let fusionDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("returns all false for empty directory", () => {
const status = getMigrationStatus(fusionDir);
expect(status).toEqual({
hasLegacy: false,
hasDatabase: false,
needsMigration: false,
});
});
it("returns needsMigration when legacy exists but no db", async () => {
await mkdir(join(fusionDir, "tasks"), { recursive: true });
const status = getMigrationStatus(fusionDir);
expect(status.hasLegacy).toBe(true);
expect(status.hasDatabase).toBe(false);
expect(status.needsMigration).toBe(true);
});
it("returns no migration needed when both exist", async () => {
await mkdir(join(fusionDir, "tasks"), { recursive: true });
const db = new Database(fusionDir);
db.init();
db.close();
const status = getMigrationStatus(fusionDir);
expect(status.hasLegacy).toBe(true);
expect(status.hasDatabase).toBe(true);
expect(status.needsMigration).toBe(false);
});
});
describe("migrateFromLegacy", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
beforeEach(async () => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
db = new Database(fusionDir);
db.init();
// Suppress migration console output in tests
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(async () => {
try {
db.close();
} catch {
// already closed
}
await rm(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("config migration", () => {
it("migrates config.json to config table", async () => {
await writeFile(
join(fusionDir, "config.json"),
JSON.stringify({
nextId: 42,
nextWorkflowStepId: 3,
settings: { maxConcurrent: 4, autoMerge: false },
workflowSteps: [{ id: "WS-001", name: "Test", description: "Test step", prompt: "test", enabled: true, createdAt: "2025-01-01", updatedAt: "2025-01-01" }],
}),
);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM config WHERE id = 1").get() as any;
expect(row.nextId).toBe(42);
expect(row.nextWorkflowStepId).toBe(3);
expect(JSON.parse(row.settings).maxConcurrent).toBe(4);
expect(JSON.parse(row.workflowSteps)).toHaveLength(1);
const workflowRows = db.prepare("SELECT * FROM workflow_steps ORDER BY id ASC").all() as any[];
expect(workflowRows).toHaveLength(1);
expect(workflowRows[0]).toMatchObject({
id: "WS-001",
name: "Test",
description: "Test step",
mode: "prompt",
phase: "pre-merge",
prompt: "test",
enabled: 1,
});
});
});
describe("task migration", () => {
it("migrates task.json files to tasks table", async () => {
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
const task = {
id: "FN-001",
title: "Test task",
description: "A test task",
priority: "urgent",
column: "todo",
dependencies: ["FN-000"],
steps: [{ name: "Step 1", status: "done" }],
currentStep: 1,
log: [{ timestamp: "2025-01-01", action: "Created" }],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
size: "M",
reviewLevel: 2,
prInfo: { url: "https://github.com/test/pr/1", number: 1, status: "open", title: "PR", headBranch: "feature", baseBranch: "main", commentCount: 0 },
};
await writeFile(join(taskDir, "task.json"), JSON.stringify(task));
await writeFile(join(taskDir, "PROMPT.md"), "# KB-001\n\nTest task");
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM tasks WHERE id = 'FN-001'").get() as any;
expect(row).toBeDefined();
expect(row.title).toBe("Test task");
expect(row.column).toBe("todo");
expect(row.priority).toBe("urgent");
expect(row.size).toBe("M");
expect(row.reviewLevel).toBe(2);
expect(JSON.parse(row.dependencies)).toEqual(["FN-000"]);
expect(JSON.parse(row.steps)).toHaveLength(1);
expect(JSON.parse(row.prInfo).number).toBe(1);
});
it("defaults migrated tasks to normal priority when legacy task.json omits priority", async () => {
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-001",
description: "Legacy priorityless task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
}),
);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT priority FROM tasks WHERE id = 'FN-001'").get() as { priority: string };
expect(row.priority).toBe("normal");
});
it("skips invalid task.json files", async () => {
const tasksDir = join(fusionDir, "tasks");
const validDir = join(tasksDir, "FN-001");
const invalidDir = join(tasksDir, "FN-002");
await mkdir(validDir, { recursive: true });
await mkdir(invalidDir, { recursive: true });
await writeFile(
join(validDir, "task.json"),
JSON.stringify({
id: "FN-001",
description: "Valid",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
}),
);
await writeFile(join(invalidDir, "task.json"), "not valid json{{");
await migrateFromLegacy(fusionDir, db);
const valid = db.prepare("SELECT * FROM tasks WHERE id = 'FN-001'").get();
const invalid = db.prepare("SELECT * FROM tasks WHERE id = 'FN-002'").get();
expect(valid).toBeDefined();
expect(invalid).toBeUndefined();
});
it("preserves blob files (PROMPT.md, agent.log, attachments)", async () => {
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
const attachDir = join(taskDir, "attachments");
await mkdir(attachDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-001",
description: "Test",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
}),
);
await writeFile(join(taskDir, "PROMPT.md"), "# KB-001\n\nTest");
await writeFile(join(taskDir, "agent.log"), '{"timestamp":"2025","text":"hello","type":"text"}\n');
await writeFile(join(attachDir, "test.txt"), "attachment content");
await migrateFromLegacy(fusionDir, db);
// Blob files should still exist
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(true);
expect(existsSync(join(taskDir, "agent.log"))).toBe(true);
expect(existsSync(join(attachDir, "test.txt"))).toBe(true);
// task.json should be backed up
expect(existsSync(join(taskDir, "task.json.bak"))).toBe(true);
expect(existsSync(join(taskDir, "task.json"))).toBe(false);
});
});
describe("activity log migration", () => {
it("migrates activity-log.jsonl to activityLog table", async () => {
const entries = [
{ id: "1", timestamp: "2025-01-01T00:00:00.000Z", type: "task:created", taskId: "FN-001", taskTitle: "Test", details: "Created KB-001" },
{ id: "2", timestamp: "2025-01-02T00:00:00.000Z", type: "task:moved", taskId: "FN-001", details: "Moved to todo", metadata: { from: "triage", to: "todo" } },
];
await writeFile(
join(fusionDir, "activity-log.jsonl"),
entries.map((e) => JSON.stringify(e)).join("\n") + "\n",
);
await migrateFromLegacy(fusionDir, db);
const rows = db.prepare("SELECT * FROM activityLog ORDER BY timestamp").all() as any[];
expect(rows).toHaveLength(2);
expect(rows[0].taskId).toBe("FN-001");
expect(rows[1].type).toBe("task:moved");
expect(JSON.parse(rows[1].metadata).from).toBe("triage");
});
it("skips malformed activity log lines", async () => {
await writeFile(
join(fusionDir, "activity-log.jsonl"),
'{"id":"1","timestamp":"2025","type":"task:created","details":"ok"}\nnot json\n{"id":"2","timestamp":"2025","type":"task:moved","details":"ok"}\n',
);
await migrateFromLegacy(fusionDir, db);
const rows = db.prepare("SELECT * FROM activityLog").all();
expect(rows).toHaveLength(2);
});
});
describe("archive migration", () => {
it("migrates archive.jsonl to archivedTasks table", async () => {
const entry = {
id: "FN-001",
title: "Archived task",
description: "Was done",
column: "archived",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2025-01-01",
updatedAt: "2025-01-01",
archivedAt: "2025-01-15T00:00:00.000Z",
};
await writeFile(join(fusionDir, "archive.jsonl"), JSON.stringify(entry) + "\n");
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM archivedTasks WHERE id = 'FN-001'").get() as any;
expect(row).toBeDefined();
expect(row.archivedAt).toBe("2025-01-15T00:00:00.000Z");
expect(JSON.parse(row.data).title).toBe("Archived task");
});
});
describe("automations migration", () => {
it("migrates automation JSON files to automations table", async () => {
const automationsDir = join(fusionDir, "automations");
await mkdir(automationsDir, { recursive: true });
const schedule = {
id: "test-uuid",
name: "Daily backup",
description: "Runs daily",
scheduleType: "daily",
cronExpression: "0 0 * * *",
command: "echo backup",
enabled: true,
runCount: 5,
runHistory: [],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
};
await writeFile(join(automationsDir, "test-uuid.json"), JSON.stringify(schedule));
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM automations WHERE id = 'test-uuid'").get() as any;
expect(row).toBeDefined();
expect(row.name).toBe("Daily backup");
expect(row.runCount).toBe(5);
expect(row.enabled).toBe(1);
});
});
describe("agents migration", () => {
it("migrates agent JSON files and heartbeats", async () => {
const agentsDir = join(fusionDir, "agents");
await mkdir(agentsDir, { recursive: true });
const agent = {
id: "agent-001",
name: "Executor 1",
role: "executor",
state: "idle",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
metadata: { version: 1 },
};
await writeFile(join(agentsDir, "agent-001.json"), JSON.stringify(agent));
// Write heartbeats
const heartbeats = [
{ agentId: "agent-001", timestamp: "2025-01-01T00:00:00.000Z", status: "ok", runId: "run-1" },
{ agentId: "agent-001", timestamp: "2025-01-01T00:01:00.000Z", status: "ok", runId: "run-1" },
];
await writeFile(
join(agentsDir, "agent-001-heartbeats.jsonl"),
heartbeats.map((h) => JSON.stringify(h)).join("\n") + "\n",
);
await migrateFromLegacy(fusionDir, db);
const agentRow = db.prepare("SELECT * FROM agents WHERE id = 'agent-001'").get() as any;
expect(agentRow).toBeDefined();
expect(agentRow.name).toBe("Executor 1");
expect(agentRow.role).toBe("executor");
expect(JSON.parse(agentRow.metadata).version).toBe(1);
const heartbeatRows = db.prepare("SELECT * FROM agentHeartbeats WHERE agentId = 'agent-001'").all();
expect(heartbeatRows).toHaveLength(2);
});
});
describe("backups", () => {
it("backs up config.json, activity-log.jsonl, archive.jsonl", async () => {
await writeFile(join(fusionDir, "config.json"), '{"nextId":1}');
await writeFile(join(fusionDir, "activity-log.jsonl"), "");
await writeFile(join(fusionDir, "archive.jsonl"), "");
await migrateFromLegacy(fusionDir, db);
expect(existsSync(join(fusionDir, "config.json.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "activity-log.jsonl.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "archive.jsonl.bak"))).toBe(true);
// Originals should be gone
expect(existsSync(join(fusionDir, "config.json"))).toBe(false);
expect(existsSync(join(fusionDir, "activity-log.jsonl"))).toBe(false);
expect(existsSync(join(fusionDir, "archive.jsonl"))).toBe(false);
});
it("backs up automations/ and agents/ directories", async () => {
await mkdir(join(fusionDir, "automations"), { recursive: true });
await mkdir(join(fusionDir, "agents"), { recursive: true });
await migrateFromLegacy(fusionDir, db);
expect(existsSync(join(fusionDir, "automations.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "agents.bak"))).toBe(true);
expect(existsSync(join(fusionDir, "automations"))).toBe(false);
expect(existsSync(join(fusionDir, "agents"))).toBe(false);
});
it("backs up individual task.json files, preserving blob files", async () => {
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-001",
description: "Test",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2025-01-01",
updatedAt: "2025-01-01",
}),
);
await writeFile(join(taskDir, "PROMPT.md"), "# Test");
await migrateFromLegacy(fusionDir, db);
// tasks/ directory should still exist
expect(existsSync(tasksDir)).toBe(true);
// PROMPT.md should still be there
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(true);
// task.json should be backed up
expect(existsSync(join(taskDir, "task.json.bak"))).toBe(true);
expect(existsSync(join(taskDir, "task.json"))).toBe(false);
});
});
describe("idempotency", () => {
it("does not fail when no legacy data exists", async () => {
// Fresh fusionDir with no legacy files
await expect(migrateFromLegacy(fusionDir, db)).resolves.not.toThrow();
});
});
describe("comment migration", () => {
it("deduplicates overlapping steeringComments and comments during legacy import", async () => {
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-002");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-002",
description: "Comment overlap",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
steeringComments: [
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user" },
],
comments: [
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "General note", createdAt: "2025-01-03T00:00:00.000Z", author: "alice" },
],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
}),
);
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT steeringComments, comments FROM tasks WHERE id = 'FN-002'").get() as any;
expect(JSON.parse(row.steeringComments)).toEqual([
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user" },
]);
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "General note", createdAt: "2025-01-03T00:00:00.000Z", author: "alice" },
]);
});
});
describe("data integrity", () => {
it("preserves all task fields through migration", async () => {
const tasksDir = join(fusionDir, "tasks");
const taskDir = join(tasksDir, "FN-001");
await mkdir(taskDir, { recursive: true });
const fullTask = {
id: "FN-001",
title: "Full task",
description: "All fields populated",
column: "in-progress",
status: "running",
size: "L",
reviewLevel: 3,
currentStep: 2,
worktree: "/tmp/wt",
blockedBy: "FN-000",
paused: true,
baseBranch: "main",
modelPresetId: "complex",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
mergeRetries: 2,
error: "Something",
summary: "Fixed it",
thinkingLevel: "high",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
columnMovedAt: "2025-01-02T00:00:00.000Z",
dependencies: ["FN-000"],
steps: [{ name: "Step 1", status: "done" }, { name: "Step 2", status: "in-progress" }],
log: [{ timestamp: "2025-01-01", action: "Created" }],
attachments: [{ filename: "test.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: "2025-01-01" }],
steeringComments: [{ id: "c1", text: "Fix this", createdAt: "2025-01-01", author: "user" }],
workflowStepResults: [{ workflowStepId: "WS-001", workflowStepName: "QA", status: "passed" }],
prInfo: { url: "https://github.com/test/pr/1", number: 1, status: "open", title: "PR", headBranch: "feature", baseBranch: "main", commentCount: 3 },
issueInfo: { url: "https://github.com/test/issues/1", number: 10, state: "open", title: "Issue" },
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 10,
url: "https://github.com/test/issues/1",
},
breakIntoSubtasks: true,
enabledWorkflowSteps: ["WS-001", "WS-002"],
};
await writeFile(join(taskDir, "task.json"), JSON.stringify(fullTask));
await migrateFromLegacy(fusionDir, db);
const row = db.prepare("SELECT * FROM tasks WHERE id = 'FN-001'").get() as any;
expect(row.id).toBe("FN-001");
expect(row.title).toBe("Full task");
expect(row.column).toBe("in-progress");
expect(row.status).toBe("running");
expect(row.size).toBe("L");
expect(row.reviewLevel).toBe(3);
expect(row.currentStep).toBe(2);
expect(row.worktree).toBe("/tmp/wt");
expect(row.blockedBy).toBe("FN-000");
expect(row.paused).toBe(1);
expect(row.baseBranch).toBe("main");
expect(row.modelPresetId).toBe("complex");
expect(row.modelProvider).toBe("anthropic");
expect(row.modelId).toBe("claude-sonnet-4-5");
expect(row.validatorModelProvider).toBe("openai");
expect(row.validatorModelId).toBe("gpt-4o");
expect(row.mergeRetries).toBe(2);
expect(row.error).toBe("Something");
expect(row.summary).toBe("Fixed it");
expect(row.thinkingLevel).toBe("high");
expect(row.createdAt).toBe("2025-01-01T00:00:00.000Z");
expect(row.updatedAt).toBe("2025-01-02T00:00:00.000Z");
expect(row.columnMovedAt).toBe("2025-01-02T00:00:00.000Z");
expect(JSON.parse(row.dependencies)).toEqual(["FN-000"]);
expect(JSON.parse(row.steps)).toHaveLength(2);
expect(JSON.parse(row.log)).toHaveLength(1);
expect(JSON.parse(row.attachments)).toHaveLength(1);
expect(JSON.parse(row.steeringComments)).toHaveLength(1);
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Fix this", createdAt: "2025-01-01", author: "user" },
]);
expect(JSON.parse(row.workflowStepResults)).toHaveLength(1);
expect(JSON.parse(row.prInfo).number).toBe(1);
expect(JSON.parse(row.issueInfo).number).toBe(10);
expect(row.sourceIssueProvider).toBe("github");
expect(row.sourceIssueRepository).toBe("runfusion/fusion");
expect(row.sourceIssueExternalIssueId).toBe("I_kgDOExample");
expect(row.sourceIssueNumber).toBe(10);
expect(row.sourceIssueUrl).toBe("https://github.com/test/issues/1");
expect(row.breakIntoSubtasks).toBe(1);
expect(JSON.parse(row.enabledWorkflowSteps)).toEqual(["WS-001", "WS-002"]);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
/**
* Regression tests for the FTS5 runtime guard.
*
* On Node builds whose bundled SQLite lacks FTS5 (older 22.x LTS),
* `CREATE VIRTUAL TABLE … USING fts5(…)` throws `no such module: fts5`
* and the dashboard crashes on first-run DB migration. These tests lock in
* the fallback path: init() must succeed, and search() must route through
* LIKE-based SQL.
*
* The `FUSION_DISABLE_FTS5=1` env var forces the probe to report FTS5 as
* unavailable even on runtimes that support it — so the CI machine can
* exercise the same code path a fresh install on an old Node would hit.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { Database } from "../db.js";
import { ArchiveDatabase } from "../archive-db.js";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-fts5-guard-test-"));
}
describe("FTS5 runtime guard", () => {
let prevEnv: string | undefined;
beforeEach(() => {
prevEnv = process.env.FUSION_DISABLE_FTS5;
process.env.FUSION_DISABLE_FTS5 = "1";
});
afterEach(() => {
if (prevEnv === undefined) {
delete process.env.FUSION_DISABLE_FTS5;
} else {
process.env.FUSION_DISABLE_FTS5 = prevEnv;
}
});
describe("Database", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
});
afterEach(async () => {
try { db.close(); } catch { /* already closed */ }
await rm(tmpDir, { recursive: true, force: true });
});
it("reports fts5Available=false when FUSION_DISABLE_FTS5 is set", () => {
expect(db.fts5Available).toBe(false);
});
it("init() does not throw when FTS5 is unavailable", () => {
expect(() => db.init()).not.toThrow();
});
it("skips creating tasks_fts virtual table", () => {
db.init();
const row = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='tasks_fts'"
).get() as { name: string } | undefined;
expect(row).toBeUndefined();
});
it("skips creating FTS5 triggers", () => {
db.init();
const triggers = db.prepare(
"SELECT name FROM sqlite_master WHERE type='trigger'"
).all() as { name: string }[];
const ftsTriggers = triggers.filter((t) => t.name.startsWith("tasks_fts_"));
expect(ftsTriggers).toHaveLength(0);
});
it("still advances the schemaVersion so migrations don't retry", () => {
db.init();
const row = db.prepare(
"SELECT value FROM __meta WHERE key = 'schemaVersion'"
).get() as { value: string };
// Migration 21 guards FTS5; 35 also guards. The final version is
// the full SCHEMA_VERSION regardless of FTS5 availability.
expect(Number(row.value)).toBeGreaterThanOrEqual(35);
});
});
describe("TaskStore.searchTasks LIKE fallback", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
it("finds tasks by exact id match", async () => {
await store.createTask({ description: "First task" });
await store.createTask({ description: "Second task" });
const results = await store.searchTasks("FN-001");
expect(results).toHaveLength(1);
expect(results[0].id).toBe("FN-001");
});
it("finds tasks by title substring", async () => {
await store.createTask({ title: "Fix login bug", description: "Login issue" });
await store.createTask({ title: "Add dashboard feature", description: "New UI" });
const results = await store.searchTasks("dashboard");
expect(results).toHaveLength(1);
expect(results[0].title).toBe("Add dashboard feature");
});
it("finds tasks by description substring", async () => {
await store.createTask({ description: "Fix the login button on the homepage" });
await store.createTask({ description: "Update the settings page layout" });
const results = await store.searchTasks("homepage");
expect(results).toHaveLength(1);
expect(results[0].description).toContain("homepage");
});
it("finds tasks by comment text", async () => {
const task = await store.createTask({ description: "A task" });
await store.addComment(task.id, "Need to prioritize the xylophone implementation", "tester");
const results = await store.searchTasks("xylophone");
expect(results).toHaveLength(1);
expect(results[0].id).toBe(task.id);
});
it("is case insensitive (LIKE on SQLite is ASCII-case-insensitive)", async () => {
await store.createTask({ title: "UPPERCASE SEARCH TEST", description: "x" });
const results = await store.searchTasks("uppercase");
expect(results).toHaveLength(1);
});
it("uses OR semantics across tokens", async () => {
await store.createTask({ title: "Fix login", description: "Button issues" });
await store.createTask({ title: "Add dashboard", description: "New features" });
const results = await store.searchTasks("login dashboard");
expect(results).toHaveLength(2);
});
it("returns empty array for non-matching query", async () => {
await store.createTask({ description: "Regular task description" });
const results = await store.searchTasks("xyznonexistent12345");
expect(results).toHaveLength(0);
});
it("escapes LIKE metacharacters in user input", async () => {
await store.createTask({ description: "this has 100% coverage" });
await store.createTask({ description: "the word percent does not have a literal" });
// "100%" with a literal percent should match only the first task,
// not every task via wildcard.
const results = await store.searchTasks("100%");
expect(results).toHaveLength(1);
expect(results[0].description).toContain("100%");
});
it("respects limit option", async () => {
await store.createTask({ title: "widget alpha", description: "x" });
await store.createTask({ title: "widget beta", description: "x" });
await store.createTask({ title: "widget gamma", description: "x" });
const results = await store.searchTasks("widget", { limit: 2 });
expect(results).toHaveLength(2);
});
it("excludes archived tasks when includeArchived is false", async () => {
const uniqueTerm = `archguardterm${Date.now()}`;
const task = await store.createTask({ description: `archived ${uniqueTerm}` });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const withArchived = await store.searchTasks(uniqueTerm);
const withoutArchived = await store.searchTasks(uniqueTerm, { includeArchived: false });
expect(withArchived.some((r) => r.id === task.id)).toBe(true);
expect(withoutArchived.some((r) => r.id === task.id)).toBe(false);
});
});
describe("ArchiveDatabase.search LIKE fallback", () => {
let tmpDir: string;
let fusionDir: string;
let archive: ArchiveDatabase;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
archive = new ArchiveDatabase(fusionDir);
archive.init();
});
afterEach(async () => {
try { archive.close(); } catch { /* already closed */ }
await rm(tmpDir, { recursive: true, force: true });
});
it("reports fts5Available=false under the env override", () => {
expect(archive.fts5Available).toBe(false);
});
it("init() does not throw when FTS5 is unavailable", () => {
// init was called in beforeEach; re-running should still work
expect(() => archive.init()).not.toThrow();
});
it("skips creating archived_tasks_fts virtual table", () => {
// Direct probe via sqlite_master — exposed through Database's prepared
// statement interface isn't available here, so we test via a known
// side effect: search() must still return results.
archive.upsert({
id: "FN-ARCH-001",
archivedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2025-12-01T00:00:00.000Z",
updatedAt: "2025-12-02T00:00:00.000Z",
title: "archived widget alpha",
description: "this is an archived task about widgets",
comments: [],
} as any);
const results = archive.search("widget", 10);
expect(results).toHaveLength(1);
expect(results[0].id).toBe("FN-ARCH-001");
});
it("finds archived tasks via LIKE across id, title, description, comments", () => {
archive.upsert({
id: "FN-ARCH-002",
archivedAt: "2026-01-02T00:00:00.000Z",
createdAt: "2025-12-01T00:00:00.000Z",
updatedAt: "2025-12-02T00:00:00.000Z",
title: "unrelated",
description: "task mentions xylophone in the body",
comments: [],
} as any);
archive.upsert({
id: "FN-ARCH-003",
archivedAt: "2026-01-03T00:00:00.000Z",
createdAt: "2025-12-03T00:00:00.000Z",
updatedAt: "2025-12-03T00:00:00.000Z",
title: "unrelated",
description: "no match here",
comments: [],
} as any);
const results = archive.search("xylophone", 10);
expect(results.map((r) => r.id)).toEqual(["FN-ARCH-002"]);
});
it("returns empty array for empty or whitespace-only query", () => {
expect(archive.search("", 10)).toEqual([]);
expect(archive.search(" ", 10)).toEqual([]);
});
});
});

View File

@@ -0,0 +1,243 @@
import { describe, it, expect, vi } from "vitest";
import {
getGhErrorMessage,
parseRepoFromRemote,
} from "../gh-cli.js";
// Tests for pure functions (no child_process dependency)
describe("getGhErrorMessage", () => {
it("returns authentication error message for auth errors", () => {
const error = new Error("not logged into any hosts");
expect(getGhErrorMessage(error)).toContain("not authenticated");
expect(getGhErrorMessage(error)).toContain("gh auth login");
});
it("returns not found message for 404 errors", () => {
const error = new Error("404 Not Found");
expect(getGhErrorMessage(error)).toContain("not found");
});
it("returns rate limit message for rate limit errors", () => {
const error = new Error("API rate limit exceeded 403");
expect(getGhErrorMessage(error)).toContain("rate limit");
});
it("returns generic message for unknown errors", () => {
const error = new Error("something went wrong");
expect(getGhErrorMessage(error)).toBe("something went wrong");
});
it("handles non-Error values", () => {
expect(getGhErrorMessage("string error")).toBe("string error");
expect(getGhErrorMessage(123)).toBe("123");
expect(getGhErrorMessage(null)).toBe("null");
});
});
describe("parseRepoFromRemote", () => {
it("parses HTTPS remote URLs", () => {
expect(parseRepoFromRemote("https://github.com/owner/repo.git")).toEqual({
owner: "owner",
repo: "repo",
});
expect(parseRepoFromRemote("https://github.com/owner/repo")).toEqual({
owner: "owner",
repo: "repo",
});
});
it("parses SSH remote URLs", () => {
expect(parseRepoFromRemote("git@github.com:owner/repo.git")).toEqual({
owner: "owner",
repo: "repo",
});
expect(parseRepoFromRemote("git@github.com:owner/repo")).toEqual({
owner: "owner",
repo: "repo",
});
});
it("returns null for non-GitHub URLs", () => {
expect(parseRepoFromRemote("https://gitlab.com/owner/repo.git")).toBeNull();
expect(parseRepoFromRemote("https://bitbucket.org/owner/repo.git")).toBeNull();
});
it("returns null for invalid URLs", () => {
expect(parseRepoFromRemote("not-a-url")).toBeNull();
expect(parseRepoFromRemote("")).toBeNull();
});
});
// Tests for functions that depend on child_process - using inline implementations
describe("gh-cli functions (inline tests)", () => {
// Inline implementation of getCurrentRepo logic for testing
function getCurrentRepoLogic(
execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer,
cwd?: string
) {
try {
const remoteUrl = execFileSyncFn("git", ["remote", "get-url", "origin"], {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
}).toString().trim();
return parseRepoFromRemote(remoteUrl);
} catch {
return null;
}
}
describe("getCurrentRepo logic", () => {
it("returns owner/repo from git remote", () => {
const mockExec = vi.fn().mockReturnValue("https://github.com/myorg/myrepo.git\n");
const result = getCurrentRepoLogic(mockExec, "/repo/path");
expect(result).toEqual({ owner: "myorg", repo: "myrepo" });
expect(mockExec).toHaveBeenCalledWith(
"git",
["remote", "get-url", "origin"],
expect.objectContaining({ cwd: "/repo/path" })
);
});
it("returns null when git command fails", () => {
const mockExec = vi.fn().mockImplementation(() => {
throw new Error("not a git repository");
});
expect(getCurrentRepoLogic(mockExec)).toBeNull();
});
it("returns null when remote is not a GitHub URL", () => {
const mockExec = vi.fn().mockReturnValue("https://gitlab.com/owner/repo.git\n");
expect(getCurrentRepoLogic(mockExec)).toBeNull();
});
});
// Inline implementation of isGhAvailable logic for testing
function isGhAvailableLogic(execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer) {
try {
execFileSyncFn("gh", ["--version"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
});
return true;
} catch {
return false;
}
}
describe("isGhAvailable logic", () => {
it("returns true when gh --version succeeds", () => {
const mockExec = vi.fn().mockReturnValue("gh version 2.40.0");
expect(isGhAvailableLogic(mockExec)).toBe(true);
expect(mockExec).toHaveBeenCalledWith("gh", ["--version"], expect.any(Object));
});
it("returns false when gh --version throws", () => {
const mockExec = vi.fn().mockImplementation(() => {
throw new Error("command not found: gh");
});
expect(isGhAvailableLogic(mockExec)).toBe(false);
});
});
// Inline implementation of isGhAuthenticated logic for testing
function isGhAuthenticatedLogic(execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer) {
try {
const result = execFileSyncFn("gh", ["auth", "status"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
});
return result.includes("Logged in") || result.includes("Authenticated");
} catch {
return false;
}
}
describe("isGhAuthenticated logic", () => {
it("returns true when gh auth status shows logged in", () => {
const mockExec = vi.fn().mockReturnValue("Logged in to github.com as user");
expect(isGhAuthenticatedLogic(mockExec)).toBe(true);
});
it("returns true when gh auth status shows Authenticated", () => {
const mockExec = vi.fn().mockReturnValue("✓ Authenticated with github.com");
expect(isGhAuthenticatedLogic(mockExec)).toBe(true);
});
it("returns false when gh auth status throws", () => {
const mockExec = vi.fn().mockImplementation(() => {
throw new Error("not logged in");
});
expect(isGhAuthenticatedLogic(mockExec)).toBe(false);
});
});
// Inline implementation of runGh logic for testing
interface GhError extends Error {
code: number | null;
stderr: string;
stdout: string;
}
function runGhLogic(
execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer,
args: string[],
cwd?: string
): string {
try {
const result = execFileSyncFn("gh", args, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
cwd,
});
return result.toString();
} catch (err: unknown) {
const execErr = err as Error & { code?: number | null; stdout?: string; stderr?: string };
const error = new Error(`gh command failed: ${execErr.message}`) as GhError;
error.code = execErr.code ?? null;
error.stdout = execErr.stdout ?? "";
error.stderr = execErr.stderr ?? "";
throw error;
}
}
describe("runGh logic", () => {
it("executes gh command with args and returns output", () => {
const mockExec = vi.fn().mockReturnValue("command output\n");
const result = runGhLogic(mockExec, ["pr", "list"]);
expect(result).toBe("command output\n");
expect(mockExec).toHaveBeenCalledWith("gh", ["pr", "list"], expect.any(Object));
});
it("passes cwd option", () => {
const mockExec = vi.fn().mockReturnValue("output");
runGhLogic(mockExec, ["pr", "list"], "/some/path");
expect(mockExec).toHaveBeenCalledWith("gh", ["pr", "list"], expect.objectContaining({
cwd: "/some/path",
}));
});
it("throws GhError on command failure", () => {
const execErr = new Error("command failed") as Error & { code: number; stdout: string; stderr: string };
execErr.code = 1;
execErr.stdout = "";
execErr.stderr = "error message";
const mockExec = vi.fn().mockImplementation(() => {
throw execErr;
});
try {
runGhLogic(mockExec, ["pr", "view", "999"]);
expect.fail("should have thrown");
} catch (err) {
const ghErr = err as GhError;
expect(ghErr.message).toContain("gh command failed");
expect(ghErr.code).toBe(1);
expect(ghErr.stderr).toBe("error message");
}
});
});
});

View File

@@ -0,0 +1,771 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { GlobalSettingsStore, defaultGlobalDir } from "../global-settings.js";
import { DEFAULT_GLOBAL_SETTINGS } from "../types.js";
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-global-settings-test-"));
}
/** Temporarily clear VITEST to test default GlobalSettingsStore resolution. */
async function withDefaultGlobalSettingsStore<T>(
fn: (store: GlobalSettingsStore) => Promise<T>,
): Promise<T> {
const savedVitest = process.env.VITEST;
delete process.env.VITEST;
try {
const store = new GlobalSettingsStore();
return await fn(store);
} finally {
if (savedVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = savedVitest;
}
}
}
describe("GlobalSettingsStore", () => {
let dir: string;
let store: GlobalSettingsStore;
const originalHome = process.env.HOME;
beforeEach(() => {
dir = makeTmpDir();
store = new GlobalSettingsStore(dir);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
});
describe("init()", () => {
it("creates the directory and settings.json if missing", async () => {
const nested = join(dir, "nested", "deep");
const nestedStore = new GlobalSettingsStore(nested);
const created = await nestedStore.init();
expect(created).toBe(true);
expect(existsSync(join(nested, "settings.json"))).toBe(true);
});
it("creates settings.json with defaults on first init", async () => {
await store.init();
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("dark");
expect(parsed.colorTheme).toBe("default");
expect(parsed.ntfyEnabled).toBe(false);
});
it("returns false if settings.json already exists", async () => {
await store.init(); // creates file
const created = await store.init(); // second call
expect(created).toBe(false);
});
it("preserves existing settings on re-init", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const created = await store.init();
expect(created).toBe(false);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
});
it("adopts the legacy ~/.pi/kb directory when ~/.fusion does not exist", async () => {
const homeDir = makeTmpDir();
process.env.HOME = homeDir;
const legacyDir = join(homeDir, ".pi", "kb");
await mkdir(legacyDir, { recursive: true });
await writeFile(
join(legacyDir, "settings.json"),
JSON.stringify({ themeMode: "light" }),
);
try {
await withDefaultGlobalSettingsStore(async (defaultStore) => {
await defaultStore.init();
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "kb"))).toBe(false);
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
});
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
it("adopts the legacy ~/.pi/fusion directory when ~/.fusion does not exist", async () => {
const homeDir = makeTmpDir();
process.env.HOME = homeDir;
// Create the legacy ~/.pi/fusion directory with settings
const legacyDir = join(homeDir, ".pi", "fusion");
await mkdir(legacyDir, { recursive: true });
await writeFile(
join(legacyDir, "settings.json"),
JSON.stringify({ themeMode: "light" }),
);
// Verify legacy exists and new does not
expect(existsSync(join(homeDir, ".pi", "fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".fusion"))).toBe(false);
try {
await withDefaultGlobalSettingsStore(async (defaultStore) => {
await defaultStore.init();
// Verify migration happened
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "fusion"))).toBe(false);
// Verify settings were preserved
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
});
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
});
describe("getSettings()", () => {
it("returns defaults when file does not exist", async () => {
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns persisted values merged with defaults", async () => {
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "settings.json"),
JSON.stringify({ themeMode: "light", colorTheme: "ocean" }),
);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.colorTheme).toBe("ocean");
// Defaults are filled in for missing fields
expect(settings.ntfyEnabled).toBe(false);
expect(settings.ntfyBaseUrl).toBeUndefined();
expect(settings.defaultProvider).toBeUndefined();
});
it("returns defaults on invalid JSON", async () => {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "settings.json"), "not-json{{{");
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns defaults when directory does not exist", async () => {
const nonExistent = new GlobalSettingsStore(join(dir, "nope", "nada"));
const settings = await nonExistent.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
});
describe("updateSettings()", () => {
it("persists a partial update and returns merged settings", async () => {
await store.init();
const updated = await store.updateSettings({ themeMode: "system" });
expect(updated.themeMode).toBe("system");
expect(updated.colorTheme).toBe("default"); // unchanged default
// Verify persistence
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("system");
});
it("merges multiple updates without losing fields", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
expect(settings.ntfyEnabled).toBe(true);
expect(settings.ntfyTopic).toBe("my-topic");
expect(settings.themeMode).toBe("dark"); // preserved default
});
it("creates directory if missing", async () => {
const nested = join(dir, "auto", "create");
const nestedStore = new GlobalSettingsStore(nested);
await nestedStore.updateSettings({ themeMode: "light" });
expect(existsSync(join(nested, "settings.json"))).toBe(true);
const settings = await nestedStore.getSettings();
expect(settings.themeMode).toBe("light");
});
it("can clear a field by setting it to null (null-as-delete semantics)", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic" });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ defaultProvider: null });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBeUndefined();
});
it("clearing ntfyTopic with null removes it from disk and returns undefined", async () => {
await store.init();
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
// Verify it was persisted
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyTopic).toBe("my-topic");
// Clear the topic with null
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ ntfyTopic: null });
// Verify it was removed from disk
const rawAfter = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(rawAfter.ntfyTopic).toBeUndefined();
// Verify getSettings returns undefined
const settings = await store.getSettings();
expect(settings.ntfyTopic).toBeUndefined();
});
it("clearing ntfyDashboardHost with null removes it from disk", async () => {
await store.init();
await store.updateSettings({ ntfyDashboardHost: "https://dashboard.example.com" });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ ntfyDashboardHost: null });
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyDashboardHost).toBeUndefined();
const settings = await store.getSettings();
expect(settings.ntfyDashboardHost).toBeUndefined();
});
it("clearing ntfyBaseUrl with null removes it from disk and falls back to default behavior", async () => {
await store.init();
await store.updateSettings({ ntfyBaseUrl: "https://ntfy.internal.example" });
const rawBefore = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(rawBefore.ntfyBaseUrl).toBe("https://ntfy.internal.example");
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ ntfyBaseUrl: null });
const rawAfter = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(rawAfter.ntfyBaseUrl).toBeUndefined();
const settings = await store.getSettings();
expect(settings.ntfyBaseUrl).toBeUndefined();
});
it("persists custom ntfy event lists including planning-awaiting-input", async () => {
await store.init();
await store.updateSettings({ ntfyEvents: ["planning-awaiting-input", "failed"] });
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyEvents).toEqual(["planning-awaiting-input", "failed"]);
const settings = await store.getSettings();
expect(settings.ntfyEvents).toEqual(["planning-awaiting-input", "failed"]);
});
it("clearing ntfyEvents with null resets to default on read", async () => {
await store.init();
await store.updateSettings({ ntfyEvents: ["in-review", "failed"] });
// Verify it was persisted with custom value
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyEvents).toEqual(["in-review", "failed"]);
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ ntfyEvents: null });
// After clear, reading back gives the default value
// (either undefined on disk with default applied, or default written directly)
const settings = await store.getSettings();
expect(settings.ntfyEvents).toEqual(["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"]);
});
it("handles concurrent updates safely via locking", async () => {
await store.init();
// Fire 10 concurrent updates
const promises = Array.from({ length: 10 }, (_, i) =>
store.updateSettings({ ntfyTopic: `topic-${i}` }),
);
await Promise.all(promises);
// The final value should be one of the submitted values (last writer wins)
const settings = await store.getSettings();
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
});
});
describe("schema protection", () => {
it("preserves unknown keys during updateSettings", async () => {
await store.init();
// Simulate a setting that existed in an older schema version
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
raw.legacyCustomField = "preserve-me";
raw.anotherRemovedSetting = 42;
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
// Update a known field — unknown keys must survive the write cycle
await store.updateSettings({ ntfyEnabled: true });
const ondisk = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(ondisk.legacyCustomField).toBe("preserve-me");
expect(ondisk.anotherRemovedSetting).toBe(42);
expect(ondisk.ntfyEnabled).toBe(true);
});
it("readRaw returns all keys including unknown ones", async () => {
await store.init();
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
raw.futureField = "hello";
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
const result = await store.readRaw();
expect(result.futureField).toBe("hello");
expect(result.themeMode).toBe("dark");
});
it("readRaw returns empty object for missing file", async () => {
const emptyStore = new GlobalSettingsStore(join(dir, "nonexistent"));
const result = await emptyStore.readRaw();
expect(result).toEqual({});
});
it("preserves unknown keys across multiple save cycles", async () => {
await store.init();
// Inject an unknown key
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
raw.removedFeatureHost = "http://example.com";
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
// Multiple save cycles with different known fields
await store.updateSettings({ ntfyEnabled: true });
await store.updateSettings({ ntfyTopic: "test-topic" });
await store.updateSettings({ themeMode: "light" });
const ondisk = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(ondisk.removedFeatureHost).toBe("http://example.com");
expect(ondisk.ntfyEnabled).toBe(true);
expect(ondisk.ntfyTopic).toBe("test-topic");
expect(ondisk.themeMode).toBe("light");
});
});
describe("getSettingsPath()", () => {
it("returns the path to settings.json", () => {
const path = store.getSettingsPath();
expect(path).toBe(join(dir, "settings.json"));
});
});
describe("atomic writes", () => {
it("does not leave tmp files after a successful write", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const tmpPath = join(dir, "settings.json.tmp");
expect(existsSync(tmpPath)).toBe(false);
});
});
describe("in-memory cache", () => {
it("returns cached result on second getSettings() call without reading disk", async () => {
await store.init();
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "light" }));
// First call reads from disk
const first = await store.getSettings();
expect(first.themeMode).toBe("light");
// Modify the file externally
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "dark" }));
// Second call should return cached result (light), not the new disk value (dark)
const second = await store.getSettings();
expect(second.themeMode).toBe("light");
});
it("updateSettings() updates the cache", async () => {
await store.init();
// First call populates cache
const first = await store.getSettings();
expect(first.themeMode).toBe("dark");
// Update settings
await store.updateSettings({ themeMode: "light" });
// getSettings should return updated cached value
const second = await store.getSettings();
expect(second.themeMode).toBe("light");
});
it("invalidateCache() forces re-read from disk", async () => {
await store.init();
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "light" }));
// First call reads from disk
const first = await store.getSettings();
expect(first.themeMode).toBe("light");
// Modify the file externally
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "system" }));
// Without invalidation, should return cached value
const cached = await store.getSettings();
expect(cached.themeMode).toBe("light");
// After invalidation, should re-read from disk
store.invalidateCache();
const afterInvalidate = await store.getSettings();
expect(afterInvalidate.themeMode).toBe("system");
});
});
// ── Global Lane Model Settings (FN-1710) ──────────────────────────
describe("global lane model settings", () => {
it("all *Global* lane fields default to undefined", async () => {
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
expect(settings.planningGlobalProvider).toBeUndefined();
expect(settings.planningGlobalModelId).toBeUndefined();
expect(settings.validatorGlobalProvider).toBeUndefined();
expect(settings.validatorGlobalModelId).toBeUndefined();
expect(settings.titleSummarizerGlobalProvider).toBeUndefined();
expect(settings.titleSummarizerGlobalModelId).toBeUndefined();
});
it("persists executionGlobalProvider/executionGlobalModelId", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.executionGlobalProvider).toBe("anthropic");
expect(raw.executionGlobalModelId).toBe("claude-sonnet-4-5");
});
it("persists planningGlobalProvider/planningGlobalModelId", async () => {
await store.updateSettings({
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
});
it("persists validatorGlobalProvider/validatorGlobalModelId", async () => {
await store.updateSettings({
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4o",
});
const settings = await store.getSettings();
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4o");
});
it("persists titleSummarizerGlobalProvider/titleSummarizerGlobalModelId", async () => {
await store.updateSettings({
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-haiku",
});
const settings = await store.getSettings();
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
});
it("persists all global lane fields together", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-opus-4",
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4-turbo",
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-opus-4");
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-sonnet-4-5");
});
it("can clear global lane fields with null", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
// Clear them
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalModelId: null });
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
});
it("merges global lane fields without losing other fields", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
await store.updateSettings({
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
// Both should be present
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
});
});
// ── Model Baseline/Fallback Round-Trip Regression (FN-1729) ──────────────
describe("model baseline/fallback round-trip regression (FN-1729)", () => {
it("defaultProvider/defaultModelId round-trips with defaults + persisted values", async () => {
// Persist default baseline
await store.updateSettings({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
let settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.defaultProvider).toBe("anthropic");
expect(raw.defaultModelId).toBe("claude-sonnet-4-5");
});
it("fallbackProvider/fallbackModelId round-trips with defaults + persisted values", async () => {
await store.updateSettings({
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
});
let settings = await store.getSettings();
expect(settings.fallbackProvider).toBe("openai");
expect(settings.fallbackModelId).toBe("gpt-4o");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.fallbackProvider).toBe("openai");
expect(raw.fallbackModelId).toBe("gpt-4o");
});
it("all global model lanes round-trip correctly", async () => {
await store.updateSettings({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
executionGlobalProvider: "google",
executionGlobalModelId: "gemini-2.5-pro",
planningGlobalProvider: "anthropic",
planningGlobalModelId: "claude-opus-4",
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4-turbo",
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-haiku",
});
// Verify all values
let settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
expect(settings.fallbackProvider).toBe("openai");
expect(settings.fallbackModelId).toBe("gpt-4o");
expect(settings.executionGlobalProvider).toBe("google");
expect(settings.executionGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.planningGlobalProvider).toBe("anthropic");
expect(settings.planningGlobalModelId).toBe("claude-opus-4");
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.defaultProvider).toBe("anthropic");
expect(raw.defaultModelId).toBe("claude-sonnet-4-5");
expect(raw.fallbackProvider).toBe("openai");
expect(raw.fallbackModelId).toBe("gpt-4o");
expect(raw.executionGlobalProvider).toBe("google");
expect(raw.executionGlobalModelId).toBe("gemini-2.5-pro");
expect(raw.planningGlobalProvider).toBe("anthropic");
expect(raw.planningGlobalModelId).toBe("claude-opus-4");
expect(raw.validatorGlobalProvider).toBe("openai");
expect(raw.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(raw.titleSummarizerGlobalProvider).toBe("anthropic");
expect(raw.titleSummarizerGlobalModelId).toBe("claude-haiku");
// Clear and verify empty
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ defaultProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ defaultModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ fallbackProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ fallbackModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ planningGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ planningGlobalModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ validatorGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ validatorGlobalModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ titleSummarizerGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ titleSummarizerGlobalModelId: null });
settings = await store.getSettings();
expect(settings.defaultProvider).toBeUndefined();
expect(settings.defaultModelId).toBeUndefined();
expect(settings.fallbackProvider).toBeUndefined();
expect(settings.fallbackModelId).toBeUndefined();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
expect(settings.planningGlobalProvider).toBeUndefined();
expect(settings.planningGlobalModelId).toBeUndefined();
expect(settings.validatorGlobalProvider).toBeUndefined();
expect(settings.validatorGlobalModelId).toBeUndefined();
expect(settings.titleSummarizerGlobalProvider).toBeUndefined();
expect(settings.titleSummarizerGlobalModelId).toBeUndefined();
// Verify cleared from persistence
const rawAfter = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(rawAfter.defaultProvider).toBeUndefined();
expect(rawAfter.defaultModelId).toBeUndefined();
expect(rawAfter.fallbackProvider).toBeUndefined();
expect(rawAfter.fallbackModelId).toBeUndefined();
expect(rawAfter.executionGlobalProvider).toBeUndefined();
expect(rawAfter.executionGlobalModelId).toBeUndefined();
expect(rawAfter.planningGlobalProvider).toBeUndefined();
expect(rawAfter.planningGlobalModelId).toBeUndefined();
expect(rawAfter.validatorGlobalProvider).toBeUndefined();
expect(rawAfter.validatorGlobalModelId).toBeUndefined();
expect(rawAfter.titleSummarizerGlobalProvider).toBeUndefined();
expect(rawAfter.titleSummarizerGlobalModelId).toBeUndefined();
});
it("model baseline/fallback defaults do not persist until explicitly set", async () => {
// Initialize the store to create settings file with defaults
await store.init();
// Don't set any model settings
const settings = await store.getSettings();
// All should be undefined (not default values)
expect(settings.defaultProvider).toBeUndefined();
expect(settings.defaultModelId).toBeUndefined();
expect(settings.fallbackProvider).toBeUndefined();
expect(settings.fallbackModelId).toBeUndefined();
// Settings file should exist with defaults but no model fields persisted
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
// Only default theme fields should be present
expect(raw.themeMode).toBe("dark");
expect(raw.colorTheme).toBe("default");
// Model fields should not be persisted
expect(raw.defaultProvider).toBeUndefined();
expect(raw.defaultModelId).toBeUndefined();
expect(raw.fallbackProvider).toBeUndefined();
expect(raw.fallbackModelId).toBeUndefined();
});
it("partial provider without modelId is valid and persists correctly", async () => {
// Set provider without modelId
await store.updateSettings({
defaultProvider: "anthropic",
// defaultModelId intentionally omitted
});
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBeUndefined();
// Verify partial pair persisted
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.defaultProvider).toBe("anthropic");
expect(raw.defaultModelId).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,856 @@
/**
* InsightStore Tests
*
* Covers:
* - Insight create/get/list/update/delete/upsert lifecycle
* - Insight run create/list/update/upsert lifecycle
* - Fingerprint-based upsert dedupe (no duplicate rows)
* - Stable identity on upsert (id/createdAt preserved)
* - Deterministic ordering under timestamp ties
* - Migration: pre-33 DB upgrades to include insight tables
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Database, createDatabase, fromJson } from "../db.js";
import { InsightStore, computeInsightFingerprint } from "../insight-store.js";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type {
Insight,
InsightRun,
InsightCategory,
InsightStatus,
InsightProvenance,
InsightRunTrigger,
InsightRunStatus,
} from "../insight-types.js";
// ── Test Fixtures ────────────────────────────────────────────────────
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-insight-test-"));
}
let fusionDir: string;
let db: Database;
let store: InsightStore;
function createProvenance(overrides: Partial<InsightProvenance> = {}): InsightProvenance {
return {
trigger: "manual",
description: "Test generation",
relatedEntityIds: [],
...overrides,
};
}
beforeEach(() => {
fusionDir = makeTmpDir();
db = createDatabase(fusionDir);
db.init();
store = new InsightStore(db);
});
// ── Insight CRUD ────────────────────────────────────────────────────
describe("InsightStore", () => {
describe("createInsight", () => {
it("creates an insight and returns it with assigned id and timestamps", () => {
const input = {
title: "Test Insight",
category: "quality" as InsightCategory,
provenance: createProvenance(),
};
const insight = store.createInsight("test-project", input);
expect(insight.id).toMatch(/^INS-[A-Z0-9]+-[A-Z0-9]+$/);
expect(insight.projectId).toBe("test-project");
expect(insight.title).toBe("Test Insight");
expect(insight.content).toBeNull();
expect(insight.category).toBe("quality");
expect(insight.status).toBe("generated");
expect(insight.fingerprint).toBeTruthy();
expect(insight.lastRunId).toBeNull();
expect(insight.createdAt).toBeTruthy();
expect(insight.updatedAt).toBeTruthy();
});
it("accepts optional content and custom status", () => {
const input = {
title: "Insight with content",
content: "Detailed description",
category: "performance" as InsightCategory,
status: "confirmed" as InsightStatus,
provenance: createProvenance(),
};
const insight = store.createInsight("proj", input);
expect(insight.content).toBe("Detailed description");
expect(insight.status).toBe("confirmed");
});
it("uses provided fingerprint when given", () => {
const input = {
title: "Custom fingerprint",
category: "security" as InsightCategory,
provenance: createProvenance(),
fingerprint: "my-custom-fingerprint",
};
const insight = store.createInsight("proj", input);
expect(insight.fingerprint).toBe("my-custom-fingerprint");
});
it("persists insight to the database", () => {
const insight = store.createInsight("proj", {
title: "Persisted",
category: "architecture",
provenance: createProvenance(),
});
const fromDb = store.getInsight(insight.id);
expect(fromDb).toEqual(insight);
});
it("emits insight:created event", () => {
const handler = vi.fn();
store.on("insight:created", handler);
const insight = store.createInsight("proj", {
title: "Event test",
category: "ux",
provenance: createProvenance(),
});
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(insight);
});
});
describe("getInsight", () => {
it("returns the insight when found", () => {
const created = store.createInsight("proj", {
title: "To get",
category: "testability",
provenance: createProvenance(),
});
const found = store.getInsight(created.id);
expect(found).toEqual(created);
});
it("returns undefined when not found", () => {
const found = store.getInsight("INS-NOTFOUND");
expect(found).toBeUndefined();
});
});
describe("listInsights", () => {
it("returns all insights for a project", () => {
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
store.createInsight("other", { title: "C", category: "architecture", provenance: createProvenance() });
const list = store.listInsights({ projectId: "proj" });
expect(list).toHaveLength(2);
});
it("filters by category", () => {
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
const list = store.listInsights({ projectId: "proj", category: "quality" });
expect(list).toHaveLength(1);
expect(list[0].title).toBe("A");
});
it("filters by status", () => {
store.createInsight("proj", { title: "A", category: "quality", status: "confirmed", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "quality", status: "generated", provenance: createProvenance() });
const list = store.listInsights({ projectId: "proj", status: "confirmed" });
expect(list).toHaveLength(1);
expect(list[0].title).toBe("A");
});
it("supports pagination with limit and offset", () => {
for (let i = 0; i < 10; i++) {
store.createInsight("proj", { title: `Insight ${i}`, category: "quality", provenance: createProvenance() });
}
const page1 = store.listInsights({ projectId: "proj", limit: 3, offset: 0 });
const page2 = store.listInsights({ projectId: "proj", limit: 3, offset: 3 });
expect(page1).toHaveLength(3);
expect(page2).toHaveLength(3);
expect(page1[0].id).not.toEqual(page2[0].id);
});
it("is ordered ascending by createdAt, then id (deterministic)", () => {
// Create insights with explicit timestamps 1s apart to ensure distinct timestamps
const now = new Date();
const insertedIds: string[] = [];
for (let i = 0; i < 5; i++) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
const id = `INS-LIST-${i}`;
insertedIds.push(id);
store.getDatabase().prepare(`
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
"proj",
`Insight ${i}`,
null,
"quality",
"generated",
`fp-list-${i}`,
null,
null,
ts,
ts,
);
}
const list = store.listInsights({ projectId: "proj" });
expect(list.map((i) => i.id)).toEqual(insertedIds);
// Verify ascending order by createdAt
for (let i = 1; i < list.length; i++) {
expect(list[i - 1].createdAt < list[i].createdAt).toBe(true);
}
});
});
describe("updateInsight", () => {
it("updates mutable fields", () => {
const original = store.createInsight("proj", {
title: "Original",
category: "quality",
provenance: createProvenance(),
});
const updated = store.updateInsight(original.id, {
title: "Updated Title",
content: "Updated content",
status: "confirmed",
});
expect(updated!.title).toBe("Updated Title");
expect(updated!.content).toBe("Updated content");
expect(updated!.status).toBe("confirmed");
expect(updated!.id).toBe(original.id);
expect(updated!.createdAt).toBe(original.createdAt);
// updatedAt should be >= original.createdAt (updated after creation)
expect(updated!.updatedAt >= original.createdAt).toBe(true);
});
it("returns undefined for non-existent insight", () => {
const result = store.updateInsight("INS-NOTFOUND", { title: "X" });
expect(result).toBeUndefined();
});
it("emits insight:updated event", () => {
const handler = vi.fn();
store.on("insight:updated", handler);
const insight = store.createInsight("proj", {
title: "To update",
category: "reliability",
provenance: createProvenance(),
});
store.updateInsight(insight.id, { status: "stale" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].status).toBe("stale");
});
});
describe("deleteInsight", () => {
it("deletes an existing insight", () => {
const insight = store.createInsight("proj", {
title: "To delete",
category: "dependency",
provenance: createProvenance(),
});
const deleted = store.deleteInsight(insight.id);
expect(deleted).toBe(true);
expect(store.getInsight(insight.id)).toBeUndefined();
});
it("returns false for non-existent insight", () => {
const deleted = store.deleteInsight("INS-NOTFOUND");
expect(deleted).toBe(false);
});
it("emits insight:deleted event", () => {
const handler = vi.fn();
store.on("insight:deleted", handler);
const insight = store.createInsight("proj", {
title: "To delete",
category: "documentation",
provenance: createProvenance(),
});
store.deleteInsight(insight.id);
expect(handler).toHaveBeenCalledWith(insight.id);
});
});
describe("upsertInsight (dedupe)", () => {
it("creates a new insight when no fingerprint match exists", () => {
const result = store.upsertInsight("proj", {
title: "New insight",
category: "architecture",
provenance: createProvenance(),
fingerprint: "new-fp",
});
expect(result.id).toMatch(/^INS-/);
expect(result.fingerprint).toBe("new-fp");
expect(store.listInsights({ projectId: "proj" })).toHaveLength(1);
});
it("updates existing insight when fingerprint matches (no duplicate)", () => {
// First upsert — creates
const created = store.upsertInsight("proj", {
title: "Original title",
category: "quality",
provenance: createProvenance(),
fingerprint: "same-fp",
});
const countBefore = store.listInsights({ projectId: "proj" }).length;
expect(countBefore).toBe(1);
// Second upsert with same fingerprint — updates (no duplicate)
const updated = store.upsertInsight("proj", {
title: "Updated title",
content: "Added content",
category: "quality",
provenance: createProvenance(),
fingerprint: "same-fp",
});
expect(updated.id).toBe(created.id); // Same id
expect(updated.title).toBe("Updated title");
expect(updated.content).toBe("Added content");
expect(updated.createdAt).toBe(created.createdAt); // Original createdAt preserved
const countAfter = store.listInsights({ projectId: "proj" }).length;
expect(countAfter).toBe(1); // No duplicate created
});
it("preserves stable identity on upsert (id and createdAt unchanged)", () => {
const first = store.upsertInsight("proj", {
title: "Stable identity test",
category: "workflow",
provenance: createProvenance(),
fingerprint: "stable-fp",
});
const second = store.upsertInsight("proj", {
title: "Updated title",
category: "workflow",
provenance: createProvenance({ trigger: "schedule" }),
fingerprint: "stable-fp",
});
expect(second.id).toBe(first.id);
expect(second.createdAt).toBe(first.createdAt);
// updatedAt should be >= first.createdAt (updated after first creation)
expect(second.updatedAt >= first.createdAt).toBe(true);
});
it("upserting different fingerprints creates separate insights", () => {
store.upsertInsight("proj", {
title: "Insight A",
category: "quality",
provenance: createProvenance(),
fingerprint: "fp-a",
});
store.upsertInsight("proj", {
title: "Insight B",
category: "quality",
provenance: createProvenance(),
fingerprint: "fp-b",
});
const list = store.listInsights({ projectId: "proj" });
expect(list).toHaveLength(2);
expect(list.map((i) => i.fingerprint)).toContain("fp-a");
expect(list.map((i) => i.fingerprint)).toContain("fp-b");
});
it("upserting same fingerprint in different projects creates separate insights", () => {
store.upsertInsight("proj-a", {
title: "Shared title",
category: "performance",
provenance: createProvenance(),
fingerprint: "cross-project-fp",
});
store.upsertInsight("proj-b", {
title: "Shared title",
category: "performance",
provenance: createProvenance(),
fingerprint: "cross-project-fp",
});
const listA = store.listInsights({ projectId: "proj-a" });
const listB = store.listInsights({ projectId: "proj-b" });
expect(listA).toHaveLength(1);
expect(listB).toHaveLength(1);
expect(listA[0].id).not.toEqual(listB[0].id);
});
});
describe("countInsights", () => {
it("counts all insights for a project", () => {
store.createInsight("proj", { title: "A", category: "quality", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "performance", provenance: createProvenance() });
store.createInsight("other", { title: "C", category: "architecture", provenance: createProvenance() });
expect(store.countInsights({ projectId: "proj" })).toBe(2);
});
it("counts with filters", () => {
store.createInsight("proj", { title: "A", category: "quality", status: "confirmed", provenance: createProvenance() });
store.createInsight("proj", { title: "B", category: "quality", status: "generated", provenance: createProvenance() });
expect(store.countInsights({ projectId: "proj", category: "quality" })).toBe(2);
expect(store.countInsights({ projectId: "proj", status: "confirmed" })).toBe(1);
});
});
describe("deterministic ordering", () => {
it("ordering is stable across repeated reads", () => {
// Create insights with explicit timestamps 1s apart to ensure distinct timestamps
const now = new Date();
for (let i = 0; i < 10; i++) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
store.getDatabase().prepare(`
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`INS-STABLE-${i}`,
"proj",
`Insight ${i}`,
null,
"quality",
"generated",
`fp-stable-${i}`,
null,
null,
ts,
ts,
);
}
// Ordering is stable: same reads across multiple calls
const read1 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
const read2 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
const read3 = store.listInsights({ projectId: "proj" }).map((i) => i.id);
expect(read1).toEqual(read2);
expect(read2).toEqual(read3);
// Verify the expected IDs are present
expect(read1).toEqual([
"INS-STABLE-0", "INS-STABLE-1", "INS-STABLE-2", "INS-STABLE-3", "INS-STABLE-4",
"INS-STABLE-5", "INS-STABLE-6", "INS-STABLE-7", "INS-STABLE-8", "INS-STABLE-9",
]);
});
it("results are ascending (oldest first) by createdAt, then id", () => {
// Create insights with explicit timestamps using SQL to avoid millisecond collisions
const now = new Date();
for (let i = 0; i < 5; i++) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
store.getDatabase().prepare(`
INSERT INTO project_insights (id, projectId, title, content, category, status, fingerprint, provenance, lastRunId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`INS-ORDER-${i}`,
"proj",
`Insight ${i}`,
null,
"quality",
"generated",
`fp-order-${i}`,
null,
null,
ts,
ts,
);
}
const list = store.listInsights({ projectId: "proj" });
expect(list).toHaveLength(5);
// Verify IDs match what we inserted (auto-incremented order 0..4)
expect(list.map((i) => i.id)).toEqual([
"INS-ORDER-0",
"INS-ORDER-1",
"INS-ORDER-2",
"INS-ORDER-3",
"INS-ORDER-4",
]);
// Verify ascending order by createdAt
for (let i = 1; i < list.length; i++) {
expect(list[i - 1].createdAt < list[i].createdAt).toBe(true);
}
});
});
describe("computeInsightFingerprint", () => {
it("produces consistent fingerprints for same input", () => {
const fp1 = computeInsightFingerprint("Test Insight", "quality");
const fp2 = computeInsightFingerprint("Test Insight", "quality");
expect(fp1).toBe(fp2);
});
it("produces consistent fingerprints regardless of case", () => {
const fp1 = computeInsightFingerprint("Test Insight", "quality");
const fp2 = computeInsightFingerprint("test insight", "quality");
expect(fp1).toBe(fp2);
});
it("different titles produce different fingerprints", () => {
const fp1 = computeInsightFingerprint("Title A", "quality");
const fp2 = computeInsightFingerprint("Title B", "quality");
expect(fp1).not.toBe(fp2);
});
it("different categories produce different fingerprints", () => {
const fp1 = computeInsightFingerprint("Same Title", "quality");
const fp2 = computeInsightFingerprint("Same Title", "performance");
expect(fp1).not.toBe(fp2);
});
it("trims whitespace before hashing", () => {
const fp1 = computeInsightFingerprint(" Test ", "quality");
const fp2 = computeInsightFingerprint("Test", "quality");
expect(fp1).toBe(fp2);
});
});
});
// ── Insight Run CRUD ────────────────────────────────────────────────
describe("InsightStore Run CRUD", () => {
describe("createRun", () => {
it("creates a run with pending status", () => {
const run = store.createRun("proj", { trigger: "manual" });
expect(run.id).toMatch(/^INSR-/);
expect(run.projectId).toBe("proj");
expect(run.trigger).toBe("manual");
expect(run.status).toBe("pending");
expect(run.insightsCreated).toBe(0);
expect(run.insightsUpdated).toBe(0);
expect(run.createdAt).toBeTruthy();
expect(run.startedAt).toBeNull();
expect(run.completedAt).toBeNull();
});
it("persists run to the database", () => {
const created = store.createRun("proj", { trigger: "schedule" });
const fromDb = store.getRun(created.id);
expect(fromDb).toEqual(created);
});
it("emits run:created event", () => {
const handler = vi.fn();
store.on("run:created", handler);
const run = store.createRun("proj", { trigger: "api" });
expect(handler).toHaveBeenCalledWith(run);
});
});
describe("getRun", () => {
it("returns run when found", () => {
const created = store.createRun("proj", { trigger: "manual" });
expect(store.getRun(created.id)).toEqual(created);
});
it("returns undefined when not found", () => {
expect(store.getRun("INSR-NOTFOUND")).toBeUndefined();
});
});
describe("listRuns", () => {
it("returns runs for a project", () => {
store.createRun("proj", { trigger: "manual" });
store.createRun("proj", { trigger: "schedule" });
store.createRun("other", { trigger: "manual" });
const list = store.listRuns({ projectId: "proj" });
expect(list).toHaveLength(2);
});
it("filters by status", () => {
store.createRun("proj", { trigger: "manual" }); // pending
const running = store.createRun("proj", { trigger: "schedule" });
store.updateRun(running.id, { status: "running" });
const pending = store.listRuns({ projectId: "proj", status: "pending" });
expect(pending).toHaveLength(1);
expect(pending[0].status).toBe("pending");
});
it("filters by trigger", () => {
store.createRun("proj", { trigger: "manual" });
store.createRun("proj", { trigger: "schedule" });
const manual = store.listRuns({ projectId: "proj", trigger: "manual" });
expect(manual).toHaveLength(1);
});
it("supports pagination", () => {
for (let i = 0; i < 10; i++) {
store.createRun("proj", { trigger: "manual" });
}
const page1 = store.listRuns({ projectId: "proj", limit: 3, offset: 0 });
expect(page1).toHaveLength(3);
});
it("is ordered descending by createdAt (newest first)", () => {
// Create runs with explicit descending timestamps to ensure deterministic ordering
const now = new Date();
for (let i = 4; i >= 0; i--) {
const ts = new Date(now.getTime() + i * 1000).toISOString();
store.getDatabase().prepare(`
INSERT INTO project_insight_runs (id, projectId, trigger, status, summary, error, insightsCreated, insightsUpdated, inputMetadata, outputMetadata, createdAt, startedAt, completedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`INSR-ORDER-${i}`,
"proj",
"manual",
"pending",
null,
null,
0,
0,
null,
null,
ts,
null,
null,
);
}
const list = store.listRuns({ projectId: "proj" });
expect(list).toHaveLength(5);
// Descending by createdAt: newest first (ts=4, ts=3, ts=2, ts=1, ts=0)
for (let i = 1; i < list.length; i++) {
const prev = list[i - 1];
const curr = list[i];
expect(prev.createdAt > curr.createdAt).toBe(true);
}
});
});
describe("updateRun", () => {
it("updates mutable fields", () => {
const run = store.createRun("proj", { trigger: "manual" });
const updated = store.updateRun(run.id, {
status: "running",
startedAt: "2025-01-01T00:00:00.000Z",
});
expect(updated!.status).toBe("running");
expect(updated!.startedAt).toBe("2025-01-01T00:00:00.000Z");
expect(updated!.id).toBe(run.id);
});
it("auto-sets completedAt when transitioning to terminal state", () => {
const run = store.createRun("proj", { trigger: "schedule" });
const updated = store.updateRun(run.id, {
status: "completed",
summary: "Done",
insightsCreated: 5,
insightsUpdated: 2,
});
expect(updated!.status).toBe("completed");
expect(updated!.completedAt).toBeTruthy();
});
it("does not override completedAt if already provided", () => {
const run = store.createRun("proj", { trigger: "manual" });
const fixed = "2025-06-01T12:00:00.000Z";
const updated = store.updateRun(run.id, {
status: "failed",
completedAt: fixed,
error: "boom",
});
expect(updated!.completedAt).toBe(fixed);
});
it("returns undefined for non-existent run", () => {
const result = store.updateRun("INSR-NOTFOUND", { status: "running" });
expect(result).toBeUndefined();
});
it("emits run:updated event on status change", () => {
const handler = vi.fn();
store.on("run:updated", handler);
const run = store.createRun("proj", { trigger: "manual" });
store.updateRun(run.id, { status: "running" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].status).toBe("running");
});
it("emits run:completed event when reaching terminal state", () => {
const handler = vi.fn();
store.on("run:completed", handler);
const run = store.createRun("proj", { trigger: "schedule" });
store.updateRun(run.id, { status: "completed" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].id).toBe(run.id);
expect(handler.mock.calls[0][0].status).toBe("completed");
});
});
describe("upsertRun", () => {
it("creates new run when no pending/running run exists", () => {
const run = store.upsertRun("proj", "schedule", { trigger: "schedule" });
expect(run.id).toMatch(/^INSR-/);
expect(run.status).toBe("pending");
});
it("returns existing pending/running run instead of creating duplicate", () => {
const first = store.createRun("proj", { trigger: "schedule" });
const second = store.upsertRun("proj", "schedule", { trigger: "schedule" });
expect(second.id).toBe(first.id);
expect(store.listRuns({ projectId: "proj", trigger: "schedule" })).toHaveLength(1);
});
it("creates new run when existing run is terminal", () => {
const first = store.createRun("proj", { trigger: "schedule" });
store.updateRun(first.id, { status: "completed" });
const second = store.upsertRun("proj", "schedule", { trigger: "schedule" });
expect(second.id).not.toBe(first.id);
expect(store.listRuns({ projectId: "proj" })).toHaveLength(2);
});
});
describe("countRuns", () => {
it("counts runs with optional filters", () => {
store.createRun("proj", { trigger: "manual" });
store.createRun("proj", { trigger: "schedule" });
store.createRun("other", { trigger: "manual" });
expect(store.countRuns({ projectId: "proj" })).toBe(2);
expect(store.countRuns({ projectId: "proj", trigger: "manual" })).toBe(1);
});
});
});
// ── Migration Test ───────────────────────────────────────────────────
describe("Migration: pre-33 DB upgrade", () => {
it("creates insight tables when upgrading from schema version 32", () => {
const legacyDir = mkdtempSync(join(tmpdir(), "fn-mig-test-"));
try {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(45);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
// to simulate a pre-33 database
const db2 = createDatabase(legacyDir);
db2.init();
db2.prepare("UPDATE __meta SET value = '32' WHERE key = 'schemaVersion'").run();
// Drop insight tables/indexes to fully simulate pre-33 state
db2.prepare("DROP TABLE IF EXISTS project_insight_runs").run();
db2.prepare("DROP TABLE IF EXISTS project_insights").run();
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsProjectId").run();
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsFingerprint").run();
db2.prepare("DROP INDEX IF EXISTS idxProjectInsightsCategory").run();
db2.prepare("DROP INDEX IF EXISTS idxInsightRunsProjectId").run();
db2.close();
// Step 3: Verify pre-33 state (after downgrade, before re-init)
// Note: we check the version BEFORE calling init() on db3
// because init() would immediately run migration 33.
// We verify pre-33 state by re-opening without calling init() on the new instance,
// then calling init() and verifying it upgrades.
const db3 = createDatabase(legacyDir);
// Read version without running migrations
const versionBefore = db3.getSchemaVersion();
expect(versionBefore).toBe(32);
// Verify insight tables are absent in the pre-33 state
const tablesBefore = db3.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_%'"
).all() as { name: string }[];
const tableNamesBefore = tablesBefore.map((t) => t.name);
expect(tableNamesBefore).not.toContain("project_insights");
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(45);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_%'"
).all() as { name: string }[];
const tableNamesAfter = tablesAfter.map((t) => t.name);
expect(tableNamesAfter).toContain("project_insights");
expect(tableNamesAfter).toContain("project_insight_runs");
// Verify indexes exist
const indexes = db3.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
).all() as { name: string }[];
const indexNames = indexes.map((i) => i.name);
expect(indexNames).toContain("idxProjectInsightsProjectId");
expect(indexNames).toContain("idxProjectInsightsFingerprint");
expect(indexNames).toContain("idxInsightRunsProjectId");
db3.close();
} finally {
rmSync(legacyDir, { recursive: true, force: true });
}
});
it("migration is idempotent — running twice does not fail", () => {
const testDir = mkdtempSync(join(tmpdir(), "fn-idempotent-test-"));
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(45);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(45);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createLogger } from "../logger.js";
describe("core createLogger", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
});
it("emits info logs to stderr with an info severity marker", () => {
const logger = createLogger("core-test");
logger.log("hello");
expect(logSpy).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith("\u0000fnlvl=info\u0000[core-test] hello");
});
it("emits warn logs with a warn severity marker", () => {
const logger = createLogger("core-test");
logger.warn("careful");
expect(warnSpy).toHaveBeenCalledWith("\u0000fnlvl=warn\u0000[core-test] careful");
});
it("emits error logs with an error severity marker", () => {
const logger = createLogger("core-test");
const err = new Error("boom");
logger.error("broken", err);
expect(errorSpy).toHaveBeenCalledWith("\u0000fnlvl=error\u0000[core-test] broken", err);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
compactMemoryWithAi,
COMPACT_MEMORY_SYSTEM_PROMPT,
createAutoSummarizeAutomation,
syncAutoSummarizeAutomation,
AUTO_SUMMARIZE_SCHEDULE_NAME,
DEFAULT_AUTO_SUMMARIZE_SCHEDULE,
AiServiceError,
__resetCompactionState,
} from "../memory-compaction.js";
describe("memory-compaction", () => {
beforeEach(() => {
__resetCompactionState();
});
// ── Constants ──────────────────────────────────────────────────────────────
describe("constants", () => {
it("should have correct system prompt", () => {
expect(COMPACT_MEMORY_SYSTEM_PROMPT).toContain("memory distillation");
expect(COMPACT_MEMORY_SYSTEM_PROMPT).toContain("compacted markdown");
});
it("should have system prompt that instructs to preserve important info", () => {
expect(COMPACT_MEMORY_SYSTEM_PROMPT).toContain("architectural conventions");
expect(COMPACT_MEMORY_SYSTEM_PROMPT).toContain("pitfalls");
expect(COMPACT_MEMORY_SYSTEM_PROMPT).toContain("decisions");
});
it("should have system prompt that instructs to remove redundant info", () => {
expect(COMPACT_MEMORY_SYSTEM_PROMPT).toContain("Remove");
expect(COMPACT_MEMORY_SYSTEM_PROMPT).toContain("redundant");
});
it("should have correct auto-summarize schedule name", () => {
expect(AUTO_SUMMARIZE_SCHEDULE_NAME).toBe("Memory Auto-Summarize");
});
it("should have correct default schedule", () => {
expect(DEFAULT_AUTO_SUMMARIZE_SCHEDULE).toBe("0 3 * * *");
});
});
// ── createAutoSummarizeAutomation ───────────────────────────────────────────
describe("createAutoSummarizeAutomation", () => {
it("should create automation with default settings", () => {
const automation = createAutoSummarizeAutomation({});
expect(automation.name).toBe(AUTO_SUMMARIZE_SCHEDULE_NAME);
expect(automation.scheduleType).toBe("custom");
expect(automation.cronExpression).toBe(DEFAULT_AUTO_SUMMARIZE_SCHEDULE);
expect(automation.enabled).toBe(true);
expect(automation.steps!).toHaveLength(1);
expect(automation.steps![0].type).toBe("ai-prompt");
expect(automation.steps![0].id).toBe("memory-auto-summarize");
});
it("should use custom schedule when provided", () => {
const automation = createAutoSummarizeAutomation({
memoryAutoSummarizeSchedule: "0 */6 * * *",
});
expect(automation.cronExpression).toBe("0 */6 * * *");
});
it("should include threshold in prompt", () => {
const automation = createAutoSummarizeAutomation({
memoryAutoSummarizeThresholdChars: 75000,
});
expect(automation.steps![0].prompt).toContain("75000");
});
it("should include model provider in step when provided", () => {
const automation = createAutoSummarizeAutomation(
{},
"anthropic",
"claude-sonnet-4-5"
);
expect(automation.steps![0].modelProvider).toBe("anthropic");
expect(automation.steps![0].modelId).toBe("claude-sonnet-4-5");
});
it("should not include model fields when not provided", () => {
const automation = createAutoSummarizeAutomation({});
expect(automation.steps![0]).not.toHaveProperty("modelProvider");
expect(automation.steps![0]).not.toHaveProperty("modelId");
expect(automation.steps![0]).not.toHaveProperty("modelProvider");
expect(automation.steps![0]).not.toHaveProperty("modelId");
});
it("should set correct timeout", () => {
const automation = createAutoSummarizeAutomation({});
expect(automation.steps![0].timeoutMs).toBe(120_000);
expect(automation.steps![0].timeoutMs).toBe(120_000);
});
it("should prompt to preserve core sections", () => {
const automation = createAutoSummarizeAutomation({});
expect(automation.steps![0].prompt).toContain("Architecture");
expect(automation.steps![0].prompt).toContain("Conventions");
expect(automation.steps![0].prompt).toContain("Pitfalls");
expect(automation.steps![0].prompt).toContain("Architecture");
expect(automation.steps![0].prompt).toContain("Conventions");
expect(automation.steps![0].prompt).toContain("Pitfalls");
});
it("should prompt to check threshold and skip when below", () => {
const automation = createAutoSummarizeAutomation({});
expect(automation.steps![0].prompt).toContain("Below threshold");
expect(automation.steps![0].prompt).toContain("skipped");
expect(automation.steps![0].prompt).toContain("Below threshold");
expect(automation.steps![0].prompt).toContain("skipped");
});
it("should prompt to write compacted content to file", () => {
const automation = createAutoSummarizeAutomation({});
expect(automation.steps![0].prompt).toContain(".fusion/memory/MEMORY.md");
expect(automation.steps![0].prompt).toContain(".fusion/memory/MEMORY.md");
});
});
// ── syncAutoSummarizeAutomation ─────────────────────────────────────────────
describe("syncAutoSummarizeAutomation", () => {
it("should delete schedule when auto-summarize is disabled", async () => {
const mockStore = {
listSchedules: vi.fn().mockResolvedValue([
{ id: "sched-1", name: AUTO_SUMMARIZE_SCHEDULE_NAME },
]),
deleteSchedule: vi.fn().mockResolvedValue(undefined),
};
await syncAutoSummarizeAutomation(mockStore as any, {
memoryAutoSummarizeEnabled: false,
});
expect(mockStore.deleteSchedule).toHaveBeenCalledWith("sched-1");
});
it("should not delete schedule when auto-summarize is disabled but no schedule exists", async () => {
const mockStore = {
listSchedules: vi.fn().mockResolvedValue([]),
deleteSchedule: vi.fn().mockResolvedValue(undefined),
};
await syncAutoSummarizeAutomation(mockStore as any, {
memoryAutoSummarizeEnabled: false,
});
expect(mockStore.deleteSchedule).not.toHaveBeenCalled();
});
it("should create new schedule when auto-summarize is enabled and no schedule exists", async () => {
const mockStore = {
listSchedules: vi.fn().mockResolvedValue([]),
createSchedule: vi.fn().mockResolvedValue({ id: "new-sched-1" }),
};
const result = await syncAutoSummarizeAutomation(mockStore as any, {
memoryAutoSummarizeEnabled: true,
});
expect(mockStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({
name: AUTO_SUMMARIZE_SCHEDULE_NAME,
scheduleType: "custom",
enabled: true,
})
);
expect(result).toEqual({ id: "new-sched-1" });
});
it("should update existing schedule when auto-summarize is enabled", async () => {
const mockStore = {
listSchedules: vi.fn().mockResolvedValue([
{ id: "existing-sched", name: AUTO_SUMMARIZE_SCHEDULE_NAME },
]),
updateSchedule: vi.fn().mockResolvedValue({ id: "existing-sched" }),
};
await syncAutoSummarizeAutomation(mockStore as any, {
memoryAutoSummarizeEnabled: true,
memoryAutoSummarizeSchedule: "0 3 * * 1",
});
expect(mockStore.updateSchedule).toHaveBeenCalledWith(
"existing-sched",
expect.objectContaining({
scheduleType: "custom",
cronExpression: "0 3 * * 1",
enabled: true,
})
);
});
it("should use default schedule when not specified", async () => {
const mockStore = {
listSchedules: vi.fn().mockResolvedValue([]),
createSchedule: vi.fn().mockResolvedValue({ id: "new-sched" }),
};
await syncAutoSummarizeAutomation(mockStore as any, {
memoryAutoSummarizeEnabled: true,
});
expect(mockStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({
cronExpression: DEFAULT_AUTO_SUMMARIZE_SCHEDULE,
})
);
});
it("should throw error for invalid cron expression", async () => {
const mockStore = {
listSchedules: vi.fn().mockResolvedValue([]),
};
await expect(
syncAutoSummarizeAutomation(mockStore as any, {
memoryAutoSummarizeEnabled: true,
memoryAutoSummarizeSchedule: "not-a-cron",
})
).rejects.toThrow("Invalid auto-summarize schedule");
});
});
// ── compactMemoryWithAi ────────────────────────────────────────────────────
describe("compactMemoryWithAi", () => {
it("should throw AiServiceError when AI service cannot process request", async () => {
const content = "Some memory content that is long enough";
await expect(compactMemoryWithAi(content, "/tmp")).rejects.toThrow(AiServiceError);
await expect(compactMemoryWithAi(content, "/tmp")).rejects.toThrow(
/(AI engine not available|No model selected)/
);
});
it("should throw AiServiceError with provider and modelId when engine not available", async () => {
const content = "Some memory content that is long enough";
await expect(
compactMemoryWithAi(content, "/tmp", "anthropic", "claude-sonnet-4-5")
).rejects.toThrow(AiServiceError);
});
it("should throw AiServiceError for empty content", async () => {
// Empty content will fail because the AI engine isn't available
await expect(compactMemoryWithAi("", "/tmp")).rejects.toThrow(AiServiceError);
});
it("should throw AiServiceError for short content", async () => {
// Short content will still fail because the AI engine isn't available
const shortContent = "Too short";
await expect(compactMemoryWithAi(shortContent, "/tmp")).rejects.toThrow(AiServiceError);
});
});
// ── Error Classes ───────────────────────────────────────────────────────────
describe("error classes", () => {
it("AiServiceError should have correct name", () => {
const err = new AiServiceError("ai failed");
expect(err.name).toBe("AiServiceError");
expect(err.message).toBe("ai failed");
});
it("AiServiceError should be an instance of Error", () => {
const err = new AiServiceError("test");
expect(err).toBeInstanceOf(Error);
});
});
// ── State Reset ───────────────────────────────────────────────────────────
describe("__resetCompactionState", () => {
it("should be callable without error", () => {
expect(() => __resetCompactionState()).not.toThrow();
});
});
// ── Message Content Extraction ─────────────────────────────────────────────
describe("message content extraction", () => {
it("should extract string content from assistant message", () => {
// This test documents the expected content extraction for string content
const message = {
role: "assistant" as const,
content: "Compacted memory content here",
};
// Simulate the extraction logic
let extracted = "";
if (typeof message.content === "string") {
extracted = message.content.trim();
}
expect(extracted).toBe("Compacted memory content here");
});
it("should extract array content blocks from assistant message", () => {
// This test documents the expected content extraction for array content
const contentBlocks = [
{ type: "text", text: "First part of " },
{ type: "text", text: "compacted memory." },
];
// Simulate the extraction logic
const extracted = contentBlocks
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("")
.trim();
expect(extracted).toBe("First part of compacted memory.");
});
});
});

View File

@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
agentDailyMemoryPath,
agentMemoryDreamsPath,
agentMemoryLongTermPath,
createMemoryDreamsAutomation,
DEFAULT_MEMORY_DREAMS_SCHEDULE,
ensureAgentMemoryFiles,
MEMORY_DREAMS_SCHEDULE_NAME,
processAgentMemoryDreams,
syncMemoryDreamsAutomation,
} from "../memory-dreams.js";
describe("memory-dreams automation", () => {
it("creates a scheduled dream processor automation with defaults", () => {
const automation = createMemoryDreamsAutomation({});
expect(automation.name).toBe(MEMORY_DREAMS_SCHEDULE_NAME);
expect(automation.cronExpression).toBe(DEFAULT_MEMORY_DREAMS_SCHEDULE);
expect(automation.steps).toHaveLength(1);
expect(automation.steps![0].id).toBe("memory-dream-processor");
expect(automation.steps![0].prompt).toContain(".fusion/memory/DREAMS.md");
expect(automation.steps![0].prompt).toContain(".fusion/memory/MEMORY.md");
expect(automation.steps![0].prompt).toContain(".fusion/agent-memory/{agentId}/");
expect(automation.steps![0].prompt).toContain("Keep agent memory separate from workspace memory");
});
it("uses custom schedule and model when provided", () => {
const automation = createMemoryDreamsAutomation(
{ memoryDreamsSchedule: "0 */8 * * *" },
"anthropic",
"claude-sonnet-4-5",
);
expect(automation.cronExpression).toBe("0 */8 * * *");
expect(automation.steps![0].modelProvider).toBe("anthropic");
expect(automation.steps![0].modelId).toBe("claude-sonnet-4-5");
});
it("deletes an existing automation when dreams are disabled", async () => {
const automationStore = {
listSchedules: vi.fn().mockResolvedValue([{ id: "dreams-1", name: MEMORY_DREAMS_SCHEDULE_NAME }]),
deleteSchedule: vi.fn().mockResolvedValue(undefined),
};
await syncMemoryDreamsAutomation(automationStore as any, { memoryDreamsEnabled: false });
expect(automationStore.deleteSchedule).toHaveBeenCalledWith("dreams-1");
});
it("creates an automation when dreams are enabled", async () => {
const automationStore = {
listSchedules: vi.fn().mockResolvedValue([]),
createSchedule: vi.fn().mockImplementation(async (input) => ({ id: "dreams-1", ...input })),
};
const result = await syncMemoryDreamsAutomation(automationStore as any, { memoryDreamsEnabled: true });
expect(automationStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ name: MEMORY_DREAMS_SCHEDULE_NAME }),
);
expect(result?.id).toBe("dreams-1");
});
it("creates agent long-term, daily, and dreams memory files", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "agent-dreams-"));
try {
const date = new Date("2026-04-17T12:00:00.000Z");
await ensureAgentMemoryFiles(rootDir, {
id: "ceo-agent",
name: "CEO",
memory: "Prioritize roadmap sequencing.",
} as any, date);
await expect(readFile(agentMemoryLongTermPath(rootDir, "ceo-agent"), "utf-8"))
.resolves.toContain("Prioritize roadmap sequencing");
await expect(readFile(agentMemoryDreamsPath(rootDir, "ceo-agent"), "utf-8"))
.resolves.toContain("Agent Memory Dreams");
await expect(readFile(agentDailyMemoryPath(rootDir, "ceo-agent", date), "utf-8"))
.resolves.toContain("Agent Daily Memory 2026-04-17");
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});
it("processes agent daily memory into agent dreams and long-term updates", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "agent-dreams-process-"));
try {
const date = new Date("2026-04-17T12:00:00.000Z");
const agent = {
id: "ceo-agent",
name: "CEO",
role: "executor",
state: "idle",
memory: "Existing CEO preference.",
metadata: {},
createdAt: date.toISOString(),
updatedAt: date.toISOString(),
} as any;
await ensureAgentMemoryFiles(rootDir, agent, date);
await writeFile(
agentDailyMemoryPath(rootDir, "ceo-agent", date),
"# Agent Daily Memory 2026-04-17\n\n- CEO should delegate implementation after sequencing.",
"utf-8",
);
const result = await processAgentMemoryDreams(rootDir, [agent], async (prompt) => {
expect(prompt).toContain("private memory for agent CEO");
expect(prompt).toContain("delegate implementation");
return "## DREAMS\n\nDelegation after sequencing is recurring.\n\n## LONG_TERM_UPDATES\n\n- Delegate implementation after roadmap sequencing.";
}, date);
expect(result).toEqual([{
agentId: "ceo-agent",
dreams: "Delegation after sequencing is recurring.",
longTermUpdates: "- Delegate implementation after roadmap sequencing.",
}]);
await expect(readFile(agentMemoryDreamsPath(rootDir, "ceo-agent"), "utf-8"))
.resolves.toContain("Delegation after sequencing is recurring");
await expect(readFile(agentMemoryLongTermPath(rootDir, "ceo-agent"), "utf-8"))
.resolves.toContain("Delegate implementation after roadmap sequencing");
await expect(readFile(agentDailyMemoryPath(rootDir, "ceo-agent", date), "utf-8"))
.resolves.toContain("Processed into dreams");
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,661 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database } from "../db.js";
import { MessageStore } from "../message-store.js";
import type { Message, Mailbox } from "../types.js";
describe("MessageStore", () => {
let store: MessageStore;
let db: Database;
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "kb-msg-test-"));
db = new Database(tempDir);
db.init();
store = new MessageStore(db);
});
afterEach(() => {
db.close();
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("sendMessage() and getMessage()", () => {
it("creates and retrieves a message", () => {
const message = store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello agent!",
type: "user-to-agent",
});
expect(message.id).toBeTruthy();
expect(message.id).toMatch(/^msg-/);
expect(message.fromId).toBe("user-1");
expect(message.fromType).toBe("user");
expect(message.toId).toBe("agent-1");
expect(message.toType).toBe("agent");
expect(message.content).toBe("Hello agent!");
expect(message.type).toBe("user-to-agent");
expect(message.read).toBe(false);
expect(message.createdAt).toBeTruthy();
expect(message.updatedAt).toBeTruthy();
const retrieved = store.getMessage(message.id);
expect(retrieved).toEqual(message);
});
it("auto-fills sender as system when not provided", () => {
const message = store.sendMessage({
toId: "user-1",
toType: "user",
content: "System notification",
type: "system",
});
expect(message.fromId).toBe("system");
expect(message.fromType).toBe("system");
});
it("stores metadata when provided", () => {
const message = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Task completed",
type: "agent-to-user",
metadata: { taskId: "FN-001", priority: "high" },
});
expect(message.metadata).toEqual({ taskId: "FN-001", priority: "high" });
});
it("persists reply link metadata through storage roundtrip", () => {
const original = store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Can you help?",
type: "user-to-agent",
});
const reply = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Sure",
type: "agent-to-user",
metadata: { replyTo: { messageId: original.id } },
});
expect(reply.metadata).toEqual({ replyTo: { messageId: original.id } });
expect(store.getMessage(reply.id)?.metadata).toEqual({ replyTo: { messageId: original.id } });
});
it("rejects malformed reply metadata", () => {
expect(() => {
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Bad metadata",
type: "agent-to-user",
metadata: { replyTo: { messageId: "" } },
});
}).toThrow("metadata.replyTo.messageId must be a non-empty string");
});
it("returns null for non-existent message", () => {
const result = store.getMessage("msg-nonexistent");
expect(result).toBeNull();
});
});
describe("message-to-agent hook", () => {
it("does not call the hook for non-agent recipients", () => {
const hook = vi.fn();
const hookedStore = new MessageStore(db, { onMessageToAgent: hook });
hookedStore.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Hello user",
type: "agent-to-user",
});
expect(hook).not.toHaveBeenCalled();
});
it("calls the hook when a message is sent to an agent", () => {
const hook = vi.fn();
const hookedStore = new MessageStore(db, { onMessageToAgent: hook });
const message = hookedStore.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello agent",
type: "user-to-agent",
});
expect(hook).toHaveBeenCalledTimes(1);
expect(hook).toHaveBeenCalledWith(message);
});
it("does nothing when no hook is configured", () => {
expect(() => {
store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "No hook configured",
type: "user-to-agent",
});
}).not.toThrow();
});
it("setMessageToAgentHook updates the hook used for subsequent messages", () => {
const firstHook = vi.fn();
const secondHook = vi.fn();
const hookedStore = new MessageStore(db, { onMessageToAgent: firstHook });
hookedStore.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "First",
type: "user-to-agent",
});
hookedStore.setMessageToAgentHook(secondHook);
hookedStore.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Second",
type: "user-to-agent",
});
expect(firstHook).toHaveBeenCalledTimes(1);
expect(secondHook).toHaveBeenCalledTimes(1);
});
});
describe("getInbox()", () => {
it("returns inbox messages for a participant", () => {
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Message 1",
type: "agent-to-user",
});
store.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Message 2",
type: "agent-to-user",
});
const inbox = store.getInbox("user-1", "user");
expect(inbox).toHaveLength(2);
// Newest first
expect(inbox[0].content).toBe("Message 2");
expect(inbox[1].content).toBe("Message 1");
});
it("returns empty array for participant with no messages", () => {
const inbox = store.getInbox("user-99", "user");
expect(inbox).toEqual([]);
});
it("filters by read status", () => {
const msg1 = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unread",
type: "agent-to-user",
});
const msg2 = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Will be read",
type: "agent-to-user",
});
store.markAsRead(msg2.id);
const unreadOnly = store.getInbox("user-1", "user", { read: false });
expect(unreadOnly).toHaveLength(1);
expect(unreadOnly[0].id).toBe(msg1.id);
const readOnly = store.getInbox("user-1", "user", { read: true });
expect(readOnly).toHaveLength(1);
expect(readOnly[0].id).toBe(msg2.id);
});
it("applies pagination (limit/offset)", () => {
for (let i = 0; i < 5; i++) {
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: `Message ${i}`,
type: "agent-to-user",
});
}
const page1 = store.getInbox("user-1", "user", { limit: 2, offset: 0 });
expect(page1).toHaveLength(2);
const page2 = store.getInbox("user-1", "user", { limit: 2, offset: 2 });
expect(page2).toHaveLength(2);
// No overlap
expect(page1[0].id).not.toBe(page2[0].id);
});
it("filters by message type", () => {
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Agent message",
type: "agent-to-user",
});
store.sendMessage({
fromId: "system",
fromType: "system",
toId: "user-1",
toType: "user",
content: "System message",
type: "system",
});
const agentOnly = store.getInbox("user-1", "user", { type: "agent-to-user" });
expect(agentOnly).toHaveLength(1);
expect(agentOnly[0].type).toBe("agent-to-user");
const systemOnly = store.getInbox("user-1", "user", { type: "system" });
expect(systemOnly).toHaveLength(1);
expect(systemOnly[0].type).toBe("system");
});
});
describe("getOutbox()", () => {
it("returns sent messages for a participant", () => {
store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Outgoing 1",
type: "user-to-agent",
});
store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-2",
toType: "agent",
content: "Outgoing 2",
type: "user-to-agent",
});
const outbox = store.getOutbox("user-1", "user");
expect(outbox).toHaveLength(2);
expect(outbox[0].content).toBe("Outgoing 2");
expect(outbox[1].content).toBe("Outgoing 1");
});
it("returns empty array when no messages sent", () => {
const outbox = store.getOutbox("user-99", "user");
expect(outbox).toEqual([]);
});
});
describe("markAsRead()", () => {
it("marks a message as read", () => {
const message = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Read me",
type: "agent-to-user",
});
expect(message.read).toBe(false);
const updated = store.markAsRead(message.id);
expect(updated.read).toBe(true);
const retrieved = store.getMessage(message.id);
expect(retrieved!.read).toBe(true);
});
it("is idempotent for already-read messages", () => {
const message = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Already read",
type: "agent-to-user",
});
store.markAsRead(message.id);
const updated = store.markAsRead(message.id);
expect(updated.read).toBe(true);
});
it("throws for non-existent message", () => {
expect(() => store.markAsRead("msg-nonexistent")).toThrow("not found");
});
});
describe("markAllAsRead()", () => {
it("marks all unread messages as read", () => {
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Msg 1",
type: "agent-to-user",
});
store.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Msg 2",
type: "agent-to-user",
});
const count = store.markAllAsRead("user-1", "user");
expect(count).toBe(2);
const inbox = store.getInbox("user-1", "user");
expect(inbox.every((m) => m.read)).toBe(true);
});
it("returns 0 when no unread messages", () => {
const count = store.markAllAsRead("user-99", "user");
expect(count).toBe(0);
});
});
describe("deleteMessage()", () => {
it("deletes a message", () => {
const message = store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Delete me",
type: "user-to-agent",
});
store.deleteMessage(message.id);
const retrieved = store.getMessage(message.id);
expect(retrieved).toBeNull();
});
it("removes message from inbox", () => {
const message = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Delete me",
type: "agent-to-user",
});
store.deleteMessage(message.id);
const inbox = store.getInbox("user-1", "user");
expect(inbox).toHaveLength(0);
});
it("removes message from outbox", () => {
const message = store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Delete me",
type: "user-to-agent",
});
store.deleteMessage(message.id);
const outbox = store.getOutbox("user-1", "user");
expect(outbox).toHaveLength(0);
});
it("throws for non-existent message", () => {
expect(() => store.deleteMessage("msg-nonexistent")).toThrow("not found");
});
});
describe("getConversation()", () => {
it("returns all messages between two participants", () => {
// user-1 sends to agent-1
store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello",
type: "user-to-agent",
});
// agent-1 replies to user-1
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Hi there",
type: "agent-to-user",
});
// Unrelated message
store.sendMessage({
fromId: "agent-2",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unrelated",
type: "agent-to-user",
});
const conversation = store.getConversation(
{ id: "user-1", type: "user" },
{ id: "agent-1", type: "agent" },
);
expect(conversation).toHaveLength(2);
// Oldest first
expect(conversation[0].content).toBe("Hello");
expect(conversation[1].content).toBe("Hi there");
});
it("returns empty array when no conversation exists", () => {
const conversation = store.getConversation(
{ id: "user-1", type: "user" },
{ id: "agent-99", type: "agent" },
);
expect(conversation).toEqual([]);
});
});
describe("getMailbox()", () => {
it("returns mailbox summary with unread count", () => {
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unread 1",
type: "agent-to-user",
});
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Unread 2",
type: "agent-to-user",
});
const mailbox = store.getMailbox("user-1", "user");
expect(mailbox.ownerId).toBe("user-1");
expect(mailbox.ownerType).toBe("user");
expect(mailbox.unreadCount).toBe(2);
expect(mailbox.lastMessage).toBeTruthy();
expect(mailbox.lastMessage!.content).toBe("Unread 2");
});
it("returns 0 unread when no messages", () => {
const mailbox = store.getMailbox("user-99", "user");
expect(mailbox.unreadCount).toBe(0);
expect(mailbox.lastMessage).toBeUndefined();
});
it("counts only unread messages", () => {
const msg1 = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Will be read",
type: "agent-to-user",
});
store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Stays unread",
type: "agent-to-user",
});
store.markAsRead(msg1.id);
const mailbox = store.getMailbox("user-1", "user");
expect(mailbox.unreadCount).toBe(1);
});
});
describe("events", () => {
it("emits message:sent event on send", () => {
const events: Message[] = [];
store.on("message:sent", (msg) => events.push(msg));
store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello",
type: "user-to-agent",
});
expect(events).toHaveLength(1);
expect(events[0].content).toBe("Hello");
});
it("emits message:received event on send", () => {
const events: Message[] = [];
store.on("message:received", (msg) => events.push(msg));
store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello",
type: "user-to-agent",
});
expect(events).toHaveLength(1);
});
it("emits message:read event on mark as read", () => {
const events: Message[] = [];
store.on("message:read", (msg) => events.push(msg));
const message = store.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Read me",
type: "agent-to-user",
});
store.markAsRead(message.id);
expect(events).toHaveLength(1);
expect(events[0].read).toBe(true);
});
it("emits message:deleted event on delete", () => {
const events: string[] = [];
store.on("message:deleted", (id) => events.push(id));
const message = store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Delete me",
type: "user-to-agent",
});
store.deleteMessage(message.id);
expect(events).toHaveLength(1);
expect(events[0]).toBe(message.id);
});
});
});

View File

@@ -0,0 +1,745 @@
/**
* Tests for migration and first-run detection
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils";
import {
FirstRunDetector,
MigrationCoordinator,
BackwardCompat,
ProjectRequiredError,
type ProjectSetupInput,
} from "../migration.js";
import { CentralCore } from "../central-core.js";
// Helper to create a fake kb project
function createFakeKbProject(dir: string): void {
const kbDir = join(dir, ".fusion");
mkdirSync(kbDir, { recursive: true });
// Create empty fusion.db file (SQLite needs actual format, but for detection an empty file works)
writeFileSync(join(kbDir, "fusion.db"), "SQLite format 3\x00");
}
// Helper to create a fake git remote
async function initGitRepo(dir: string, remoteUrl?: string): Promise<void> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await execFileAsync("git", ["init"], { cwd: dir });
await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: dir });
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: dir });
if (remoteUrl) {
await execFileAsync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir });
}
}
describe("FirstRunDetector", () => {
let tempGlobalDir: string;
beforeEach(() => {
tempGlobalDir = tempWorkspace("kb-migration-test-");
});
describe("detectFirstRunState", () => {
it("should detect fresh-install when no central DB and no local .fusion/", async () => {
useIsolatedCwd("kb-fresh-");
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("fresh-install");
});
it("should detect setup-wizard when local .fusion/ exists but no central DB", async () => {
const tempProjectDir = useIsolatedCwd("kb-needs-migration-");
createFakeKbProject(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("setup-wizard");
});
it("should detect setup-wizard from nested directory inside an existing project with no central DB", async () => {
const tempProjectDir = tempWorkspace("kb-needs-migration-nested-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "features", "deep");
mkdirSync(nestedDir, { recursive: true });
const originalCwd = process.cwd();
process.chdir(nestedDir);
try {
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("setup-wizard");
} finally {
process.chdir(originalCwd);
}
});
it("should detect setup-wizard when central DB exists but is empty", async () => {
// Initialize central DB with no projects
const central = new CentralCore(tempGlobalDir);
await central.init();
await central.close();
useIsolatedCwd("kb-setup-wizard-");
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("setup-wizard");
});
it("should detect normal-operation when central DB has projects", async () => {
// Create a separate global dir for this test to avoid conflicts with beforeEach's tempGlobalDir
const testGlobalDir = tempWorkspace("kb-normal-op-global-");
// Create and initialize central
const testCentral = new CentralCore(testGlobalDir);
await testCentral.init();
// Register a project
const projectDir = tempWorkspace("kb-test-project-");
await testCentral.registerProject({
name: "Test Project",
path: projectDir,
});
// Create a temp dir for the cwd
useIsolatedCwd("kb-normal-op-");
try {
// Pass existing central to avoid concurrent connection issues
const detector = new FirstRunDetector(testGlobalDir);
const state = await detector.detectFirstRunState(testCentral);
expect(state).toBe("normal-operation");
} finally {
await testCentral.close();
}
}, 15_000);
it("should return fresh-install when central DB exists but is unreadable", async () => {
const tempProjectDir = useIsolatedCwd("kb-corrupt-central-");
createFakeKbProject(tempProjectDir);
mkdirSync(tempGlobalDir, { recursive: true });
writeFileSync(join(tempGlobalDir, "fusion-central.db"), "not a sqlite database");
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("fresh-install");
});
it("should return fresh-install when central DB exists but is unreadable and no local project is found", async () => {
useIsolatedCwd("kb-corrupt-central-no-local-");
mkdirSync(tempGlobalDir, { recursive: true });
writeFileSync(join(tempGlobalDir, "fusion-central.db"), "not a sqlite database");
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("fresh-install");
});
});
describe("hasCentralDb", () => {
it("should return false when central DB does not exist", () => {
const detector = new FirstRunDetector(tempGlobalDir);
expect(detector.hasCentralDb()).toBe(false);
});
it("should return true when central DB exists", async () => {
const central = new CentralCore(tempGlobalDir);
await central.init();
await central.close();
const detector = new FirstRunDetector(tempGlobalDir);
expect(detector.hasCentralDb()).toBe(true);
});
});
describe("detectExistingProjects", () => {
it("should detect project in cwd", async () => {
const tempProjectDir = tempWorkspace("kb-detect-");
createFakeKbProject(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(tempProjectDir);
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(tempProjectDir);
expect(projects[0].hasDb).toBe(true);
});
it("should walk up directory tree to find .fusion/", async () => {
const tempProjectDir = tempWorkspace("kb-parent-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "components");
mkdirSync(nestedDir, { recursive: true });
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(nestedDir);
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(tempProjectDir);
});
it("should stop safely at home/root boundaries when no project is found", async () => {
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(tmpdir());
expect(Array.isArray(projects)).toBe(true);
expect(projects.length).toBe(0);
});
it("should still check the starting directory when cwd matches the stop boundary", async () => {
const fakeHome = tempWorkspace("kb-home-boundary-");
createFakeKbProject(fakeHome);
const detector = new FirstRunDetector(fakeHome);
const projects = await detector.detectExistingProjects(fakeHome);
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(fakeHome);
});
it("should return empty array when no project found", async () => {
const emptyDir = tempWorkspace("kb-empty-");
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(emptyDir);
expect(projects).toHaveLength(0);
});
});
describe("generateProjectName", () => {
it("should use directory basename when no git remote", async () => {
const tempProjectDir = tempWorkspace("my-awesome-project-");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toContain("my-awesome-project");
});
it("should extract repo name from HTTPS git remote", async () => {
const tempProjectDir = tempWorkspace("kb-git-https-");
await initGitRepo(tempProjectDir, "https://github.com/owner/my-repo.git");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toBe("my-repo");
});
it("should extract repo name from SSH git remote", async () => {
const tempProjectDir = tempWorkspace("kb-git-ssh-");
await initGitRepo(tempProjectDir, "git@github.com:owner/my-ssh-repo");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toBe("my-ssh-repo");
});
});
describe("getCentralDbPath", () => {
it("should return correct path", () => {
const detector = new FirstRunDetector(tempGlobalDir);
expect(detector.getCentralDbPath()).toBe(join(tempGlobalDir, "fusion-central.db"));
});
});
});
describe("MigrationCoordinator", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempWorkspace("kb-coordinator-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
});
describe("registerSingleProject", () => {
it("should register a new project successfully", async () => {
const tempProjectDir = tempWorkspace("kb-register-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject(tempProjectDir);
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(1);
expect(result.errors).toHaveLength(0);
// Verify project was registered
const project = await central.getProject(result.projectsRegistered[0]);
expect(project).toBeDefined();
expect(project!.path).toBe(tempProjectDir);
expect(project!.status).toBe("active");
});
it("should be idempotent - return existing project if already registered", async () => {
const tempProjectDir = tempWorkspace("kb-idempotent-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
// First registration
const result1 = await coordinator.registerSingleProject(tempProjectDir);
expect(result1.success).toBe(true);
// Second registration - should be idempotent
const result2 = await coordinator.registerSingleProject(tempProjectDir);
expect(result2.success).toBe(true);
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
expect(result2.errors).toHaveLength(0);
});
it("should reject relative paths", async () => {
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject("./relative/path");
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain("must be absolute");
});
it("should reject absolute paths that are not valid kb projects", async () => {
const tempProjectDir = tempWorkspace("kb-invalid-project-");
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject(tempProjectDir);
expect(result.success).toBe(false);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors[0]).toContain("not a valid kb project");
});
it("should handle duplicate names by appending suffix", async () => {
const tempRoot = tempWorkspace("kb-duplicate-names-");
const tempProjectDir1 = join(tempRoot, "same-project");
const tempProjectDir2 = join(tempRoot, "group", "same-project");
mkdirSync(tempProjectDir1, { recursive: true });
mkdirSync(tempProjectDir2, { recursive: true });
createFakeKbProject(tempProjectDir1);
createFakeKbProject(tempProjectDir2);
const coordinator = new MigrationCoordinator(central);
const result1 = await coordinator.registerSingleProject(tempProjectDir1);
const result2 = await coordinator.registerSingleProject(tempProjectDir2);
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
const project1 = await central.getProject(result1.projectsRegistered[0]);
const project2 = await central.getProject(result2.projectsRegistered[0]);
expect(project1!.name).toBe("same-project");
expect(project2!.name).toBe("same-project-1");
});
it("should reject nested project registration when parent is already registered", async () => {
const parentProjectDir = tempWorkspace("kb-parent-project-");
createFakeKbProject(parentProjectDir);
const nestedProjectDir = join(parentProjectDir, "apps", "nested-project");
mkdirSync(nestedProjectDir, { recursive: true });
createFakeKbProject(nestedProjectDir);
const coordinator = new MigrationCoordinator(central);
const parentResult = await coordinator.registerSingleProject(parentProjectDir);
const nestedResult = await coordinator.registerSingleProject(nestedProjectDir);
expect(parentResult.success).toBe(true);
expect(nestedResult.success).toBe(false);
expect(nestedResult.errors[0]).toContain("overlaps an existing registered project");
});
it("should register the detected ancestor project root when called from a nested directory", async () => {
const tempProjectDir = tempWorkspace("kb-nested-register-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "packages", "feature");
mkdirSync(nestedDir, { recursive: true });
const detector = new FirstRunDetector(tempGlobalDir);
const detected = await detector.detectExistingProjects(nestedDir);
expect(detected).toHaveLength(1);
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject(detected[0].path);
expect(result.success).toBe(true);
const projects = await central.listProjects();
expect(projects).toHaveLength(1);
expect(projects[0].path.endsWith(tempProjectDir)).toBe(true);
});
});
describe("completeSetup", () => {
it("should register multiple projects from wizard", async () => {
const tempProjectDir1 = tempWorkspace("kb-setup1-");
const tempProjectDir2 = tempWorkspace("kb-setup2-");
createFakeKbProject(tempProjectDir1);
createFakeKbProject(tempProjectDir2);
const coordinator = new MigrationCoordinator(central);
const inputs: ProjectSetupInput[] = [
{ path: tempProjectDir1, name: "Project One" },
{ path: tempProjectDir2, name: "Project Two" },
];
const result = await coordinator.completeSetup(inputs);
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(2);
expect(result.errors).toHaveLength(0);
});
it("should skip already registered projects", async () => {
const tempProjectDir = tempWorkspace("kb-setup-existing-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
// Register first
const result1 = await coordinator.registerSingleProject(tempProjectDir);
// Try to register again via completeSetup
const inputs: ProjectSetupInput[] = [{ path: tempProjectDir, name: "Some Name" }];
const result2 = await coordinator.completeSetup(inputs);
expect(result2.success).toBe(true);
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
});
it("should reject invalid setup project paths", async () => {
const validProjectDir = tempWorkspace("kb-setup-valid-");
const invalidProjectDir = tempWorkspace("kb-setup-invalid-");
createFakeKbProject(validProjectDir);
const inputs: ProjectSetupInput[] = [
{ path: validProjectDir, name: "Valid Project" },
{ path: invalidProjectDir, name: "Invalid Project" },
];
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.completeSetup(inputs);
expect(result.success).toBe(false);
expect(result.projectsRegistered).toHaveLength(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("not a valid kb project");
});
});
describe("coordinateMigration", () => {
it("should auto-register an existing local project when no projects are registered", async () => {
const tempProjectDir = tempWorkspace("kb-coordinate-migration-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "feature");
mkdirSync(nestedDir, { recursive: true });
const originalCwd = process.cwd();
process.chdir(nestedDir);
try {
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(1);
expect(result.errors).toHaveLength(0);
const registered = await central.listProjects();
expect(registered).toHaveLength(1);
expect(registered[0].path.endsWith(tempProjectDir)).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it("should return success for fresh-install state", async () => {
// Close and remove central to simulate fresh state
await central.close();
const { rmSync } = await import("node:fs");
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
central = new CentralCore(tempGlobalDir);
await central.init();
// Change to fresh dir (no .fusion/)
useIsolatedCwd("kb-fresh-coord-");
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
it("should be a no-op in setup-wizard state when no local project exists", async () => {
useIsolatedCwd("kb-setup-wizard-coord-");
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
it("should be a no-op in normal-operation when projects already exist", async () => {
const existingProjectDir = tempWorkspace("kb-normal-op-existing-");
await central.registerProject({
name: "Existing Project",
path: existingProjectDir,
});
const localProjectDir = useIsolatedCwd("kb-normal-op-local-");
createFakeKbProject(localProjectDir);
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.errors).toHaveLength(0);
const registered = await central.listProjects();
expect(registered).toHaveLength(1);
});
});
});
describe("BackwardCompat", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempWorkspace("kb-compat-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
});
describe("resolveProjectContext", () => {
it("should use explicit project ID when provided", async () => {
const tempProjectDir = tempWorkspace("kb-explicit-");
const project = await central.registerProject({
name: "Explicit Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/other/dir", project.id);
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
});
it("should auto-use single project when no explicit ID provided", async () => {
const tempProjectDir = tempWorkspace("kb-single-");
const project = await central.registerProject({
name: "Single Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/other/dir");
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
});
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
const tempProjectDir1 = tempWorkspace("kb-multi1-");
const tempProjectDir2 = tempWorkspace("kb-multi2-");
await central.registerProject({ name: "Project 1", path: tempProjectDir1 });
await central.registerProject({ name: "Project 2", path: tempProjectDir2 });
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
ProjectRequiredError
);
try {
await compat.resolveProjectContext("/some/dir");
} catch (err) {
expect(err).toBeInstanceOf(ProjectRequiredError);
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
}
});
it("should find project by name (case-insensitive)", async () => {
const tempProjectDir = tempWorkspace("kb-byname-");
const project = await central.registerProject({
name: "My Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/dir", "my project");
expect(context.projectId).toBe(project.id);
});
it("should throw when project not found", async () => {
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir", "nonexistent")).rejects.toThrow(
ProjectRequiredError
);
});
});
describe("isLegacyMode", () => {
it("should return false when central DB exists", async () => {
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(false);
});
it("should return true when no central DB", async () => {
// Close and remove central DB
await central.close();
const { rmSync } = await import("node:fs");
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
// Need to re-init CentralCore for it to work
central = new CentralCore(tempGlobalDir);
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(true);
});
});
});
describe("CentralCore migration helpers", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempWorkspace("kb-central-migration-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
});
describe("autoRegisterProject", () => {
it("should auto-register a project with generated name", async () => {
const tempProjectDir = tempWorkspace("kb-autoreg-");
createFakeKbProject(tempProjectDir);
const project = await central.autoRegisterProject(tempProjectDir);
expect(project).toBeDefined();
expect(project.path).toBe(tempProjectDir);
expect(project.isolationMode).toBe("in-process");
expect(project.status).toBe("active");
expect(project.name).toContain("kb-autoreg"); // Based on directory name
});
it("should reject nested auto-registration when parent project is already registered", async () => {
const parentProjectDir = tempWorkspace("kb-central-parent-");
createFakeKbProject(parentProjectDir);
const nestedProjectDir = join(parentProjectDir, "packages", "nested");
mkdirSync(nestedProjectDir, { recursive: true });
createFakeKbProject(nestedProjectDir);
await central.autoRegisterProject(parentProjectDir);
await expect(central.autoRegisterProject(nestedProjectDir)).rejects.toThrow(/overlaps an existing registered project/);
});
it("should be idempotent - return existing project if already registered", async () => {
const tempProjectDir = tempWorkspace("kb-autoreg-dup-");
createFakeKbProject(tempProjectDir);
const project1 = await central.autoRegisterProject(tempProjectDir);
const project2 = await central.autoRegisterProject(tempProjectDir);
expect(project1.id).toBe(project2.id);
expect(project1.name).toBe(project2.name);
});
});
describe("isProjectRegistered", () => {
it("should return false for unregistered project", async () => {
const tempProjectDir = tempWorkspace("kb-unreg-");
const isRegistered = await central.isProjectRegistered(tempProjectDir);
expect(isRegistered).toBe(false);
});
it("should return true for registered project", async () => {
const tempProjectDir = tempWorkspace("kb-registered-");
await central.registerProject({
name: "Registered",
path: tempProjectDir,
});
const isRegistered = await central.isProjectRegistered(tempProjectDir);
expect(isRegistered).toBe(true);
});
});
describe("getFirstRunState", () => {
it("should return setup-wizard when no projects", async () => {
const state = await central.getFirstRunState();
expect(state).toBe("setup-wizard");
});
it("should return normal-operation when projects exist", async () => {
const tempProjectDir = tempWorkspace("kb-state-test-");
await central.registerProject({
name: "State Test",
path: tempProjectDir,
});
const state = await central.getFirstRunState();
expect(state).toBe("normal-operation");
});
});
});
describe("ProjectRequiredError", () => {
it("should include available projects in error", () => {
const available = [
{ id: "proj_1", name: "Project One" },
{ id: "proj_2", name: "Project Two" },
];
const error = new ProjectRequiredError("Test message", available);
expect(error.message).toBe("Test message");
expect(error.name).toBe("ProjectRequiredError");
expect(error.availableProjects).toEqual(available);
});
});

View File

@@ -0,0 +1,544 @@
/**
* Mission Factory Parity Integration Tests
*
* These tests verify that Factory mission behavior stays consistent across
* MissionStore persistence layers. They test:
* - Clarification artifacts (planningNotes, verification) persist across restart
* - Feature execution transitions stay synchronized
* - Retry round behavior is consistent
* - Blocked paths prevent further scheduling
*
* Run: pnpm --filter @fusion/core exec vitest run src/mission-factory-parity.integration.test.ts
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-factory-parity-"));
}
/**
* Parity Matrix: Maps scenario → MissionStore API → persisted field
*
* | Scenario | API | Field |
* |------------------------------------|------------------------------|--------------------------|
* | Planning notes persist | updateMilestone/slice | planningNotes |
* | Verification criteria persist | updateMilestone/slice | verification |
* | Enriched context tied to hierarchy | buildEnrichedDescription | (computed) |
* | Feature link stable across restart | linkFeatureToTask | taskId |
* | Feature status transitions | updateFeatureStatus | status |
* | Rollup reflects current state | getMissionHealth | tasksCompleted, etc. |
* | Autopilot enabled persists | updateMission(autopilot) | autopilotEnabled |
* | Blocked features tracked | updateFeatureStatus(blocked) | status=blocked |
*/
describe("MissionFactory Parity: Core MissionStore", () => {
let rootDir: string;
let taskStore: TaskStore;
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-11T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore.init();
});
afterEach(async () => {
vi.useRealTimers();
await rm(rootDir, { recursive: true, force: true });
});
describe("Parity Matrix: Clarification Artifacts Persistence", () => {
it("milestone planningNotes persist across store restart", async () => {
const missionStore = taskStore.getMissionStore();
// Create hierarchy
const mission = missionStore.createMission({
title: "Auth System",
description: "Build authentication",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Core Auth",
description: "Implement JWT",
});
// Update planning notes
const planningNotes = "Using RS256 signing strategy";
missionStore.updateMilestone(milestone.id, { planningNotes });
// Simulate restart by creating new store instance
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
// Verify persistence
const retrieved = missionStore2.getMilestone(milestone.id);
expect(retrieved).toBeDefined();
expect(retrieved!.planningNotes).toBe(planningNotes);
});
it("milestone verification persists across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, {
title: "Core",
description: "Core implementation",
});
const verification = "Users can authenticate with email/password";
missionStore.updateMilestone(milestone.id, { verification });
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrieved = missionStore2.getMilestone(milestone.id);
expect(retrieved!.verification).toBe(verification);
});
it("slice planningNotes persist across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, {
title: "S1",
description: "Slice 1",
});
const planningNotes = "Use existing design system tokens";
missionStore.updateSlice(slice.id, { planningNotes });
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrieved = missionStore2.getSlice(slice.id);
expect(retrieved!.planningNotes).toBe(planningNotes);
});
it("slice verification persists across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, {
title: "S1",
description: "Slice 1",
});
const verification = "Login form accepts valid credentials";
missionStore.updateSlice(slice.id, { verification });
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrieved = missionStore2.getSlice(slice.id);
expect(retrieved!.verification).toBe(verification);
});
it("enriched description tied to correct hierarchy node", async () => {
const missionStore = taskStore.getMissionStore();
// Create hierarchy with distinct context at each level
const mission = missionStore.createMission({
title: "Auth Mission",
description: "Build complete auth system",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Login Milestone",
description: "Implement login flow",
planningNotes: "JWT with refresh tokens",
verification: "Users can log in",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Login Slice",
description: "Build login UI",
planningNotes: "Use existing components",
verification: "Form validates input",
});
const feature = missionStore.addFeature(slice.id, {
title: "Login Form Feature",
description: "Email/password form",
acceptanceCriteria: "Shows validation errors",
});
// Build enriched description
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toBeDefined();
// Verify context is tied to correct levels
expect(enriched).toContain("Auth Mission");
expect(enriched).toContain("Login Milestone");
expect(enriched).toContain("Login Slice");
expect(enriched).toContain("Login Form Feature");
// Verify distinct planning notes
expect(enriched).toContain("JWT with refresh tokens");
expect(enriched).toContain("Use existing components");
});
it("enriched description omits empty sections", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Minimal Mission",
description: "Just basics",
});
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, {
title: "F1",
description: "Feature",
});
const enriched = missionStore.buildEnrichedDescription(feature.id);
// Should not have undefined/null strings in output
expect(enriched).not.toMatch(/Planning Notes:\s*undefined/);
expect(enriched).not.toMatch(/Verification:\s*undefined/);
expect(enriched).not.toMatch(/Description:\s*undefined/);
});
});
describe("Parity Matrix: Feature Execution Transitions", () => {
it("linkFeatureToTask creates stable link", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, {
title: "F1",
description: "Feature 1",
});
// First create the task in the store (linkFeatureToTask requires task to exist)
const task = await taskStore.createTask({
title: "Task for F1",
description: "Created for feature link",
});
// Link feature to task
missionStore.linkFeatureToTask(feature.id, task.id);
// Restart and verify link persists
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const linked = missionStore2.getFeatureByTaskId(task.id);
expect(linked).toBeDefined();
expect(linked!.id).toBe(feature.id);
});
it("updateFeatureStatus transitions are recorded correctly", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, {
title: "F1",
description: "Feature",
});
// Transition through states (note: 'done' not 'completed')
missionStore.updateFeatureStatus(feature.id, "defined");
missionStore.updateFeatureStatus(feature.id, "in-progress");
missionStore.updateFeatureStatus(feature.id, "blocked");
missionStore.updateFeatureStatus(feature.id, "done");
// Verify final state
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
const featureState = hierarchy!.milestones[0].slices[0].features[0];
expect(featureState.status).toBe("done");
});
it("triageFeature enriches task with context", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Auth Mission",
description: "Build auth",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Core Auth",
description: "Implement JWT",
verification: "Login works",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Login",
description: "Login UI",
});
const feature = missionStore.addFeature(slice.id, {
title: "Login Form",
description: "Standard form",
});
// Triage the feature (creates task and links)
const updatedFeature = await missionStore.triageFeature(feature.id);
expect(updatedFeature).toBeDefined();
expect(updatedFeature.taskId).toBeDefined();
expect(updatedFeature.taskId).toMatch(/^FN-/);
// Verify the task has enriched description
const task = await taskStore.getTask(updatedFeature.taskId!);
expect(task).toBeDefined();
expect(task!.description).toContain("Auth Mission");
expect(task!.description).toContain("Core Auth");
expect(task!.description).toContain("Login Form");
});
});
describe("Parity Matrix: Mission Health Rollups", () => {
it("getMissionHealth reflects current feature states", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
// Add features with various states
const f1 = missionStore.addFeature(slice.id, { title: "F1" });
const f2 = missionStore.addFeature(slice.id, { title: "F2" });
const f3 = missionStore.addFeature(slice.id, { title: "F3" });
// Use correct status values
missionStore.updateFeatureStatus(f1.id, "done");
missionStore.updateFeatureStatus(f2.id, "in-progress");
missionStore.updateFeatureStatus(f3.id, "blocked");
const health = missionStore.getMissionHealth(mission.id);
expect(health).toBeDefined();
expect(health!.totalTasks).toBe(3);
expect(health!.tasksCompleted).toBe(1);
expect(health!.tasksInFlight).toBe(1);
});
it("health rollup updates when feature status changes", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, { title: "F1" });
// Initial health - no completed features
let health = missionStore.getMissionHealth(mission.id);
expect(health!.tasksCompleted).toBe(0);
// Complete the feature (status = 'done')
missionStore.updateFeatureStatus(feature.id, "done");
// Health should update
health = missionStore.getMissionHealth(mission.id);
expect(health!.tasksCompleted).toBe(1);
});
it("blocked features tracked in health", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
// Create blocked features
const f1 = missionStore.addFeature(slice.id, { title: "F1" });
const f2 = missionStore.addFeature(slice.id, { title: "F2" });
missionStore.updateFeatureStatus(f1.id, "blocked");
missionStore.updateFeatureStatus(f2.id, "blocked");
// Note: MissionHealth doesn't have a blockedFeatures field,
// but it does track tasksFailed for failed tasks
const health = missionStore.getMissionHealth(mission.id);
expect(health).toBeDefined();
expect(health!.totalTasks).toBe(2);
});
});
describe("Parity Matrix: Autopilot Configuration", () => {
it("autopilotEnabled persists across restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Test",
autopilotEnabled: true,
});
// Verify initial state
let retrieved = missionStore.getMission(mission.id);
expect(retrieved!.autopilotEnabled).toBe(true);
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
// Verify persistence
retrieved = missionStore2.getMission(mission.id);
expect(retrieved!.autopilotEnabled).toBe(true);
});
it("autopilotEnabled can be toggled", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Test",
autopilotEnabled: false,
});
// Enable autopilot
missionStore.updateMission(mission.id, { autopilotEnabled: true });
let retrieved = missionStore.getMission(mission.id);
expect(retrieved!.autopilotEnabled).toBe(true);
// Disable autopilot
missionStore.updateMission(mission.id, { autopilotEnabled: false });
retrieved = missionStore.getMission(mission.id);
expect(retrieved!.autopilotEnabled).toBe(false);
});
it("autopilotState persists across restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Test",
autopilotEnabled: true,
});
// Update autopilot state
missionStore.updateMission(mission.id, { autopilotState: "watching" });
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrieved = missionStore2.getMission(mission.id);
expect(retrieved!.autopilotState).toBe("watching");
});
});
describe("Parity Matrix: Blocked Feature Paths", () => {
it("blocked features remain blocked across restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, { title: "F1" });
missionStore.updateFeatureStatus(feature.id, "blocked");
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
// Verify blocked status persisted
const hierarchy = missionStore2.getMissionWithHierarchy(mission.id);
const fState = hierarchy!.milestones[0].slices[0].features[0];
expect(fState.status).toBe("blocked");
});
it("blocked features affect mission health", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, { title: "F1" });
missionStore.updateFeatureStatus(feature.id, "blocked");
const health = missionStore.getMissionHealth(mission.id);
expect(health).toBeDefined();
expect(health!.totalTasks).toBe(1);
// Mission is in planning status since we haven't activated it yet
expect(health!.status).toBe("planning");
});
it("blocked feature can be unblocked", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, { title: "F1" });
// Block then unblock
missionStore.updateFeatureStatus(feature.id, "blocked");
missionStore.updateFeatureStatus(feature.id, "defined");
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
const fState = hierarchy!.milestones[0].slices[0].features[0];
expect(fState.status).toBe("defined");
});
});
describe("Parity Matrix: Deterministic Event Ordering", () => {
it("mission events ordered by timestamp with stable tiebreaker", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
// Create events in rapid succession (same millisecond)
vi.advanceTimersByTime(0);
missionStore.logMissionEvent(mission.id, "warning", "First");
vi.advanceTimersByTime(1);
missionStore.logMissionEvent(mission.id, "warning", "Second");
vi.advanceTimersByTime(1);
missionStore.logMissionEvent(mission.id, "warning", "Third");
const result = missionStore.getMissionEvents(mission.id);
// Events are ordered by timestamp DESC, id DESC (most recent first)
expect(result.events.length).toBeGreaterThanOrEqual(3);
// Most recent event should be first
expect(result.events[0].description).toBe("Third");
});
it("event log persists across restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test" });
missionStore.logMissionEvent(mission.id, "warning", "Test message", {
source: "parity_test",
});
// Restart
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const result = missionStore2.getMissionEvents(mission.id);
expect(result.events.some((e) => e.description === "Test message")).toBe(true);
});
});
});

View File

@@ -0,0 +1,572 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
import { Database } from "../db.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-integration-"));
}
function getPrivateDb(store: TaskStore): Database | null {
return (store as unknown as { _db: Database | null })._db;
}
function assertHierarchyIntegrity(
hierarchy: NonNullable<ReturnType<ReturnType<TaskStore["getMissionStore"]>["getMissionWithHierarchy"]>>,
) {
expect(hierarchy.milestones.every((milestone, index) => milestone.orderIndex === index)).toBe(true);
expect(new Set(hierarchy.milestones.map((milestone) => milestone.id)).size).toBe(hierarchy.milestones.length);
for (const milestone of hierarchy.milestones) {
expect(milestone.slices.every((slice, index) => slice.orderIndex === index)).toBe(true);
expect(new Set(milestone.slices.map((slice) => slice.id)).size).toBe(milestone.slices.length);
for (const slice of milestone.slices) {
expect(slice.milestoneId).toBe(milestone.id);
expect(new Set(slice.features.map((feature) => feature.id)).size).toBe(slice.features.length);
for (const feature of slice.features) {
expect(feature.sliceId).toBe(slice.id);
}
}
}
}
/**
* Creates a mission hierarchy large enough to exercise rollups, reorder logic,
* and cascade deletions in integration scenarios.
*/
async function createHierarchy(store: TaskStore) {
const missionStore = store.getMissionStore();
const mission = missionStore.createMission({
title: "Launch authentication",
description: "Mission hierarchy integration test",
});
const milestones = Array.from({ length: 3 }, (_, milestoneIndex) => {
const milestone = missionStore.addMilestone(mission.id, {
title: `Milestone ${milestoneIndex + 1}`,
description: `Phase ${milestoneIndex + 1}`,
});
const slices = Array.from({ length: 2 }, (_, sliceIndex) => {
const slice = missionStore.addSlice(milestone.id, {
title: `Slice ${milestoneIndex + 1}.${sliceIndex + 1}`,
description: `Slice ${milestoneIndex + 1}.${sliceIndex + 1}`,
});
const features = Array.from({ length: 3 }, (_, featureIndex) =>
missionStore.addFeature(slice.id, {
title: `Feature ${milestoneIndex + 1}.${sliceIndex + 1}.${featureIndex + 1}`,
description: "Feature description",
acceptanceCriteria: "criterion",
}),
);
return { ...slice, features };
});
return { ...milestone, slices };
});
return { missionStore, mission, milestones };
}
/**
* MissionStore integration tests verify the missions hierarchy when it shares
* the same SQLite database as TaskStore. These scenarios cover linking tasks
* to features, rollup state transitions, hierarchy integrity after reorders and
* deletions, foreign-key cleanup, and event emissions that other packages rely on.
*/
describe("MissionStore integration with TaskStore", () => {
let rootDir: string;
let taskStore: TaskStore;
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore.init();
});
afterEach(async () => {
vi.useRealTimers();
await rm(rootDir, { recursive: true, force: true });
});
it("creates and retrieves a full hierarchy through the shared MissionStore", async () => {
const { missionStore, mission } = await createHierarchy(taskStore);
const fullMission = missionStore.getMissionWithHierarchy(mission.id);
expect(fullMission).toBeDefined();
expect(fullMission?.milestones).toHaveLength(3);
expect(fullMission?.milestones.every((milestone) => milestone.slices.length === 2)).toBe(true);
expect(
fullMission?.milestones.every((milestone) =>
milestone.slices.every((slice) => {
const hierarchySlice = slice as typeof slice & { features: Array<{ id: string }> };
return hierarchySlice.features.length === 3;
}),
),
).toBe(true);
});
it("links features to real TaskStore tasks and updates sliceId without populating missionId", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const feature = milestones[0].slices[0].features[0];
const linkedTask = await taskStore.createTask({
title: "Build login form",
description: "Implement the login form task used for mission linking.",
column: "todo",
});
// TaskStore persists tasks to disk first, so create a DB-backed snapshot
// using a normal update path before MissionStore writes the linkage field.
await taskStore.moveTask(linkedTask.id, "in-progress");
const linkedFeature = missionStore.linkFeatureToTask(feature.id, linkedTask.id);
const storedTask = await taskStore.getTask(linkedTask.id);
const taskRow = getPrivateDb(taskStore)?.prepare(
"SELECT missionId, sliceId FROM tasks WHERE id = ?",
).get(linkedTask.id) as { missionId: string | null; sliceId: string | null } | undefined;
expect(linkedFeature.taskId).toBe(linkedTask.id);
expect(linkedFeature.status).toBe("triaged");
expect(storedTask.sliceId).toBe(milestones[0].slices[0].id);
expect(taskRow?.sliceId).toBe(milestones[0].slices[0].id);
expect(taskRow?.missionId).toBe(mission.id);
const linkedHierarchy = missionStore.getMissionWithHierarchy(mission.id);
expect(linkedHierarchy?.milestones[0].slices[0].features[0].taskId).toBe(linkedTask.id);
});
it("rolls up status from features to slices, milestones, and mission", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const [firstMilestone] = milestones;
const [firstSlice] = firstMilestone.slices;
const linkedFeatures: { featureId: string; taskId: string }[] = [];
for (const feature of firstSlice.features) {
const task = await taskStore.createTask({
title: feature.title,
description: `Task for ${feature.title}`,
column: "todo",
});
await taskStore.moveTask(task.id, "in-progress");
missionStore.linkFeatureToTask(feature.id, task.id);
linkedFeatures.push({ featureId: feature.id, taskId: task.id });
}
let updatedSlice = missionStore.getSlice(firstSlice.id);
let updatedMilestone = missionStore.getMilestone(firstMilestone.id);
let updatedMission = missionStore.getMission(mission.id);
expect(updatedSlice?.status).toBe("active");
expect(updatedMilestone?.status).toBe("active");
expect(updatedMission?.status).toBe("active");
for (const { featureId, taskId } of linkedFeatures) {
await taskStore.moveTask(taskId, "in-review");
await taskStore.moveTask(taskId, "done");
missionStore.updateFeature(featureId, { taskId, status: "done" });
}
updatedSlice = missionStore.getSlice(firstSlice.id);
updatedMilestone = missionStore.getMilestone(firstMilestone.id);
updatedMission = missionStore.getMission(mission.id);
expect(updatedSlice?.status).toBe("complete");
expect(updatedMilestone?.status).toBe("active");
expect(updatedMission?.status).toBe("active");
});
it("persists missionId and sliceId when linking a feature to a task", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
column: "todo",
});
missionStore.linkFeatureToTask(feature.id, task.id);
const reloaded = await taskStore.getTask(task.id);
expect(reloaded.missionId).toBe(mission.id);
expect(reloaded.sliceId).toBe(slice.id);
});
it("clears missionId and sliceId when unlinking a feature from a task", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
column: "todo",
});
missionStore.linkFeatureToTask(feature.id, task.id);
missionStore.unlinkFeatureFromTask(feature.id);
const reloaded = await taskStore.getTask(task.id);
expect(reloaded.missionId).toBeUndefined();
expect(reloaded.sliceId).toBeUndefined();
});
it("cascades mission deletion across milestones, slices, and features", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const milestoneIds = milestones.map((milestone) => milestone.id);
const sliceIds = milestones.flatMap((milestone) => milestone.slices.map((slice) => slice.id));
const featureIds = milestones.flatMap((milestone) =>
milestone.slices.flatMap((slice) => slice.features.map((feature) => feature.id)),
);
missionStore.deleteMission(mission.id);
expect(missionStore.getMission(mission.id)).toBeUndefined();
expect(milestoneIds.every((id) => missionStore.getMilestone(id) === undefined)).toBe(true);
expect(sliceIds.every((id) => missionStore.getSlice(id) === undefined)).toBe(true);
expect(featureIds.every((id) => missionStore.getFeature(id) === undefined)).toBe(true);
});
it("recomputes order indexes after deleting a middle milestone and preserves child integrity after reorder", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const [firstMilestone, middleMilestone, lastMilestone] = milestones;
missionStore.deleteMilestone(middleMilestone.id);
const afterDelete = missionStore.listMilestones(mission.id);
expect(afterDelete.map((milestone) => milestone.id)).toEqual([firstMilestone.id, lastMilestone.id]);
missionStore.reorderMilestones(mission.id, [firstMilestone.id, lastMilestone.id]);
const afterRecompute = missionStore.listMilestones(mission.id);
expect(afterRecompute.map((milestone) => milestone.orderIndex)).toEqual([0, 1]);
expect(missionStore.getSlice(middleMilestone.slices[0].id)).toBeUndefined();
expect(missionStore.getFeature(middleMilestone.slices[0].features[0].id)).toBeUndefined();
missionStore.reorderMilestones(mission.id, [lastMilestone.id, firstMilestone.id]);
const reordered = missionStore.getMissionWithHierarchy(mission.id);
expect(reordered?.milestones.map((milestone) => milestone.id)).toEqual([
lastMilestone.id,
firstMilestone.id,
]);
expect(reordered?.milestones[0].slices.map((slice) => slice.id)).toEqual(
lastMilestone.slices.map((slice) => slice.id),
);
expect(reordered?.milestones[1].slices[0].features.map((feature) => feature.id)).toEqual(
firstMilestone.slices[0].features.map((feature) => feature.id),
);
expect(reordered).toBeDefined();
assertHierarchyIntegrity(reordered!);
});
it("emits mission lifecycle events for creation, linking, and slice activation in order", async () => {
const missionStore = taskStore.getMissionStore();
const events: string[] = [];
missionStore.on("mission:created", () => events.push("mission:created"));
missionStore.on("feature:linked", () => events.push("feature:linked"));
missionStore.on("slice:activated", () => events.push("slice:activated"));
const mission = missionStore.createMission({ title: "Event mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Event milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Event slice" });
const feature = missionStore.addFeature(slice.id, { title: "Event feature" });
const task = await taskStore.createTask({
title: "Event task",
description: "Task for event assertions",
column: "todo",
});
await taskStore.moveTask(task.id, "in-progress");
missionStore.linkFeatureToTask(feature.id, task.id);
await missionStore.activateSlice(slice.id);
expect(events).toEqual(["mission:created", "feature:linked", "slice:activated"]);
});
it("uses the same Database instance for TaskStore and MissionStore", () => {
const missionStore = taskStore.getMissionStore();
const db = getPrivateDb(taskStore);
const missionStoreDb = (missionStore as unknown as { db: Database }).db;
expect(db).toBeDefined();
expect(missionStoreDb).toBe(db);
});
it("keeps hierarchy retrievable after repeated deterministic reorder operations", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
missionStore.reorderMilestones(mission.id, [milestones[2].id, milestones[0].id, milestones[1].id]);
missionStore.reorderMilestones(mission.id, [milestones[1].id, milestones[2].id, milestones[0].id]);
for (const milestone of missionStore.listMilestones(mission.id)) {
const slices = missionStore.listSlices(milestone.id);
missionStore.reorderSlices(
milestone.id,
slices
.map((slice) => slice.id)
.reverse(),
);
}
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
expect(hierarchy?.milestones).toHaveLength(3);
expect(hierarchy).toBeDefined();
assertHierarchyIntegrity(hierarchy!);
});
it("keeps hierarchy valid under overlapping reorder and lookup operations", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
await Promise.all([
Promise.resolve().then(() =>
missionStore.reorderMilestones(mission.id, [milestones[1].id, milestones[2].id, milestones[0].id]),
),
Promise.resolve().then(() => {
const slices = missionStore.listSlices(milestones[0].id);
missionStore.reorderSlices(milestones[0].id, slices.map((slice) => slice.id).reverse());
}),
Promise.resolve().then(() => missionStore.getMissionWithHierarchy(mission.id)),
Promise.resolve().then(() => missionStore.listMissions()),
]);
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
expect(hierarchy).toBeDefined();
assertHierarchyIntegrity(hierarchy!);
});
it("keeps all descendants retrievable after bulk feature completion updates", async () => {
const { missionStore, mission } = await createHierarchy(taskStore);
const hierarchy = missionStore.getMissionWithHierarchy(mission.id)!;
for (const milestone of hierarchy.milestones) {
for (const slice of milestone.slices) {
for (const feature of slice.features) {
missionStore.updateFeature(feature.id, { status: "done" });
}
}
}
const refreshed = missionStore.getMissionWithHierarchy(mission.id)!;
expect(refreshed.milestones).toHaveLength(3);
expect(
refreshed.milestones.every((milestone) =>
milestone.slices.every((slice) => {
const hierarchySlice = slice as typeof slice & { features: Array<{ status: string }> };
return hierarchySlice.features.every((feature) => feature.status === "done");
}),
),
).toBe(true);
});
it("clears mission feature task links when a linked task is deleted", async () => {
const { missionStore, milestones } = await createHierarchy(taskStore);
const feature = milestones[0].slices[0].features[0];
const task = await taskStore.createTask({
title: "Delete linked task",
description: "Task used to verify foreign key cleanup.",
column: "todo",
});
await taskStore.moveTask(task.id, "in-progress");
missionStore.linkFeatureToTask(feature.id, task.id);
await taskStore.deleteTask(task.id);
const refreshed = missionStore.getFeature(feature.id);
expect(refreshed?.taskId).toBeUndefined();
}, 15000);
// ── Parity: Restart Fidelity Tests ──────────────────────────────────
describe("Parity: Restart Fidelity", () => {
it("persists mission status across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Restart Test Mission",
description: "Testing persistence",
});
// Verify initial status is planning
expect(mission.status).toBe("planning");
// Update to active
missionStore.updateMission(mission.id, { status: "active", autopilotEnabled: true });
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrieved = missionStore2.getMission(mission.id);
expect(retrieved).toBeDefined();
expect(retrieved!.title).toBe("Restart Test Mission");
expect(retrieved!.status).toBe("active");
expect(retrieved!.autopilotEnabled).toBe(true);
});
it("persists autopilot state across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Autopilot State Test",
autopilotEnabled: true,
});
// Update autopilot state
missionStore.updateMission(mission.id, { autopilotState: "watching" });
// Update to a different state
missionStore.updateMission(mission.id, { autopilotState: "inactive" });
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrieved = missionStore2.getMission(mission.id);
expect(retrieved!.autopilotState).toBe("inactive");
});
it("persists feature-to-task linkage across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Linkage Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, { title: "F1" });
const task = await taskStore.createTask({
title: "Linked Task",
description: "Task linked to feature",
column: "todo",
});
missionStore.linkFeatureToTask(feature.id, task.id);
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrieved = missionStore2.getFeature(feature.id);
expect(retrieved!.taskId).toBe(task.id);
expect(retrieved!.status).toBe("triaged");
});
it("persists feature status across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Status Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
const feature = missionStore.addFeature(slice.id, { title: "F1" });
// Transition through states
missionStore.updateFeatureStatus(feature.id, "triaged");
missionStore.updateFeatureStatus(feature.id, "in-progress");
missionStore.updateFeatureStatus(feature.id, "blocked");
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const hierarchy = missionStore2.getMissionWithHierarchy(mission.id);
expect(hierarchy!.milestones[0].slices[0].features[0].status).toBe("blocked");
});
it("persists mission events across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Events Test" });
// Log multiple events
vi.advanceTimersByTime(1);
missionStore.logMissionEvent(mission.id, "mission_started", "Mission started");
vi.advanceTimersByTime(1);
missionStore.logMissionEvent(mission.id, "slice_activated", "Slice activated");
vi.advanceTimersByTime(1);
missionStore.logMissionEvent(mission.id, "feature_triaged", "Feature triaged");
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const events = missionStore2.getMissionEvents(mission.id);
expect(events.events.length).toBe(3);
// Events are ordered by timestamp DESC, so most recent first
expect(events.events[0].eventType).toBe("feature_triaged"); // Most recent
expect(events.events[1].eventType).toBe("slice_activated");
expect(events.events[2].eventType).toBe("mission_started"); // Oldest
});
it("persists hierarchy ordering across store restart", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
// Reorder milestones
missionStore.reorderMilestones(mission.id, [
milestones[2].id,
milestones[0].id,
milestones[1].id,
]);
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const hierarchy = missionStore2.getMissionWithHierarchy(mission.id);
expect(hierarchy!.milestones[0].id).toBe(milestones[2].id);
expect(hierarchy!.milestones[1].id).toBe(milestones[0].id);
expect(hierarchy!.milestones[2].id).toBe(milestones[1].id);
});
it("persists planning notes and verification across store restart", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Planning Context Test" });
const milestone = missionStore.addMilestone(mission.id, {
title: "M1",
planningNotes: "Use JWT authentication",
verification: "Users can log in",
});
const slice = missionStore.addSlice(milestone.id, {
title: "S1",
planningNotes: "Build login form component",
verification: "Form validates input",
});
// Restart store
taskStore.close();
const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore2.init();
const missionStore2 = taskStore2.getMissionStore();
const retrievedMilestone = missionStore2.getMilestone(milestone.id);
expect(retrievedMilestone!.planningNotes).toBe("Use JWT authentication");
expect(retrievedMilestone!.verification).toBe("Users can log in");
const retrievedSlice = missionStore2.getSlice(slice.id);
expect(retrievedSlice!.planningNotes).toBe("Build login form component");
expect(retrievedSlice!.verification).toBe("Form validates input");
});
});
});

View File

@@ -0,0 +1,543 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-planning-"));
}
/**
* MissionStore planning context integration tests verify the enriched triage flow
* that adds mission hierarchy context to task descriptions. These scenarios cover:
* - Full hierarchy context enrichment in task descriptions
* - Omission of empty hierarchy sections
* - Custom description override bypassing enrichment
* - Bulk triage with enrichment
* - Enrichment after interview updates
* - Plan state transitions
*/
describe("MissionStore planning context integration", () => {
let rootDir: string;
let taskStore: TaskStore;
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await taskStore.init();
});
afterEach(async () => {
vi.useRealTimers();
await rm(rootDir, { recursive: true, force: true });
});
describe("buildEnrichedDescription", () => {
it("enriches task description with full hierarchy context", async () => {
const missionStore = taskStore.getMissionStore();
// Create full hierarchy with rich context
const mission = missionStore.createMission({
title: "Launch Authentication",
description: "Build a complete auth system",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Core Auth",
description: "Implement core authentication",
verification: "Users can log in and log out",
planningNotes: "Decided on JWT strategy",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Login Page",
description: "Build the login UI",
verification: "Login form accepts valid credentials",
planningNotes: "Use existing design system",
});
const feature = missionStore.addFeature(slice.id, {
title: "Login Form",
description: "Standard login form with email/password",
acceptanceCriteria: "Form validates input and shows errors",
});
// Build enriched description
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toBeDefined();
// Mission context
expect(enriched).toContain("Launch Authentication");
expect(enriched).toContain("Build a complete auth system");
// Milestone context
expect(enriched).toContain("Core Auth");
expect(enriched).toContain("Implement core authentication");
expect(enriched).toContain("Users can log in and log out");
expect(enriched).toContain("Decided on JWT strategy");
// Slice context
expect(enriched).toContain("Login Page");
expect(enriched).toContain("Build the login UI");
expect(enriched).toContain("Login form accepts valid credentials");
expect(enriched).toContain("Use existing design system");
// Feature context
expect(enriched).toContain("Login Form");
expect(enriched).toContain("Standard login form with email/password");
expect(enriched).toContain("Form validates input and shows errors");
});
it("omits empty hierarchy sections from enriched description", async () => {
const missionStore = taskStore.getMissionStore();
// Create minimal hierarchy
const mission = missionStore.createMission({
title: "Minimal Mission",
description: "Just a title and description",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Minimal Milestone",
// No description, verification, or planningNotes
});
const slice = missionStore.addSlice(milestone.id, {
title: "Minimal Slice",
// No description, verification, or planningNotes
});
const feature = missionStore.addFeature(slice.id, {
title: "Minimal Feature",
description: "Feature with description only",
// No acceptance criteria
});
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toBeDefined();
// Mission title should be present
expect(enriched).toContain("Minimal Mission");
expect(enriched).toContain("Just a title and description");
// Milestone title should be present but description/verification/notes sections should not be empty
expect(enriched).toContain("Minimal Milestone");
// Should not have empty sections like "Description: undefined"
expect(enriched).not.toMatch(/Description:\s*undefined/);
expect(enriched).not.toMatch(/Verification:\s*undefined/);
expect(enriched).not.toMatch(/Planning Notes:\s*undefined/);
// Feature context
expect(enriched).toContain("Minimal Feature");
expect(enriched).toContain("Feature with description only");
});
it("returns undefined for non-existent feature", async () => {
const missionStore = taskStore.getMissionStore();
const enriched = missionStore.buildEnrichedDescription("non-existent-id");
expect(enriched).toBeUndefined();
});
it("returns undefined when slice is not found", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
// Manually delete the slice to simulate orphan feature
missionStore.deleteSlice(slice.id);
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toBeUndefined();
});
});
describe("triageFeature with enrichment", () => {
it("triageFeature enriches task description with full hierarchy context", async () => {
const missionStore = taskStore.getMissionStore();
// Create full hierarchy
const mission = missionStore.createMission({
title: "Authentication System",
description: "Implement complete auth",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "User Management",
description: "Handle user accounts",
verification: "Users can manage accounts",
planningNotes: "Use PostgreSQL for user data",
});
const slice = missionStore.addSlice(milestone.id, {
title: "User Registration",
description: "Build registration flow",
verification: "Users can register",
planningNotes: "Add email verification",
});
const feature = missionStore.addFeature(slice.id, {
title: "Registration Form",
description: "Create registration form",
acceptanceCriteria: "Form submits successfully",
});
// Triage the feature (no custom description override)
await missionStore.triageFeature(feature.id);
// Get the linked task
const updatedFeature = missionStore.getFeature(feature.id);
expect(updatedFeature?.taskId).toBeDefined();
const task = await taskStore.getTask(updatedFeature!.taskId!);
expect(task.description).toContain("Authentication System");
expect(task.description).toContain("Implement complete auth");
expect(task.description).toContain("User Management");
expect(task.description).toContain("Handle user accounts");
expect(task.description).toContain("Users can manage accounts");
expect(task.description).toContain("Use PostgreSQL for user data");
expect(task.description).toContain("User Registration");
expect(task.description).toContain("Build registration flow");
expect(task.description).toContain("Users can register");
expect(task.description).toContain("Add email verification");
expect(task.description).toContain("Registration Form");
expect(task.description).toContain("Create registration form");
expect(task.description).toContain("Form submits successfully");
});
it("triageFeature with custom description override skips enrichment", async () => {
const missionStore = taskStore.getMissionStore();
// Create full hierarchy
const mission = missionStore.createMission({
title: "Full Mission",
description: "Full mission description",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Full Milestone",
description: "Full milestone description",
verification: "Full verification",
planningNotes: "Full notes",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Full Slice",
description: "Full slice description",
verification: "Full slice verification",
planningNotes: "Full slice notes",
});
const feature = missionStore.addFeature(slice.id, {
title: "Custom Feature",
description: "Custom feature description",
});
// Triage with custom description override
await missionStore.triageFeature(
feature.id,
undefined, // title uses default
"Custom description override", // description override
);
const updatedFeature = missionStore.getFeature(feature.id);
const task = await taskStore.getTask(updatedFeature!.taskId!);
// Custom description should be used exactly
expect(task.description).toBe("Custom description override");
// Mission context should NOT be present
expect(task.description).not.toContain("Full Mission");
expect(task.description).not.toContain("Full mission description");
expect(task.description).not.toContain("Full Milestone");
});
it("triageSlice enriches all feature tasks with hierarchy context", async () => {
const missionStore = taskStore.getMissionStore();
// Create hierarchy with multiple features
const mission = missionStore.createMission({
title: "Multi Feature Mission",
description: "Testing multiple features",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Multi Feature Milestone",
description: "Multiple features milestone",
verification: "All features complete",
planningNotes: "Coordinate development",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Multi Feature Slice",
description: "Multiple features slice",
verification: "Slice verification",
planningNotes: "Slice planning",
});
// Add 3 features
const feature1 = missionStore.addFeature(slice.id, {
title: "Feature One",
description: "First feature description",
acceptanceCriteria: "First criterion",
});
const feature2 = missionStore.addFeature(slice.id, {
title: "Feature Two",
description: "Second feature description",
acceptanceCriteria: "Second criterion",
});
const feature3 = missionStore.addFeature(slice.id, {
title: "Feature Three",
description: "Third feature description",
acceptanceCriteria: "Third criterion",
});
// Triage all features in the slice
await missionStore.triageSlice(slice.id);
// Check all 3 tasks have enriched descriptions
for (const feature of [feature1, feature2, feature3]) {
const updatedFeature = missionStore.getFeature(feature.id);
const task = await taskStore.getTask(updatedFeature!.taskId!);
// All tasks should have hierarchy context
expect(task.description).toContain("Multi Feature Mission");
expect(task.description).toContain("Multi Feature Milestone");
expect(task.description).toContain("Multi Feature Slice");
// Each task should have its own feature-specific content
expect(task.description).toContain(feature.title);
expect(task.description).toContain(feature.description!);
expect(task.description).toContain(feature.acceptanceCriteria!);
}
});
it("enriched description reflects updates after interview", async () => {
const missionStore = taskStore.getMissionStore();
// Create initial hierarchy
const mission = missionStore.createMission({
title: "Evolving Mission",
description: "Initial mission",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Evolving Milestone",
description: "Initial milestone",
planningNotes: "Initial notes",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Evolving Slice",
description: "Initial slice",
planningNotes: "Initial slice notes",
});
const feature1 = missionStore.addFeature(slice.id, {
title: "Feature Alpha",
description: "First feature",
});
const feature2 = missionStore.addFeature(slice.id, {
title: "Feature Beta",
description: "Second feature",
});
// Triage first feature
await missionStore.triageFeature(feature1.id);
const task1 = await taskStore.getTask(missionStore.getFeature(feature1.id)!.taskId!);
// Verify initial enrichment
expect(task1.description).toContain("Initial notes");
expect(task1.description).toContain("Initial slice notes");
// Update milestone and slice after "interview"
missionStore.updateMilestone(milestone.id, {
planningNotes: "Revised milestone planning: Use JWT tokens, add refresh token support",
});
missionStore.updateSlice(slice.id, {
planningNotes: "Revised slice planning: Use React Hook Form, add validation",
});
// Triage second feature
await missionStore.triageFeature(feature2.id);
const task2 = await taskStore.getTask(missionStore.getFeature(feature2.id)!.taskId!);
// Second task should have updated planning notes
expect(task2.description).toContain("Revised milestone planning");
expect(task2.description).toContain("Revised slice planning");
// First task should still have original notes (historical)
expect(task1.description).toContain("Initial notes");
});
});
describe("planState transitions", () => {
it("defaults planState to not_started for new slices", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Plan State Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
expect(slice.planState).toBe("not_started");
});
it("transitions planState to planned after interview", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Plan State Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
// Simulate interview completion by updating planState
const updated = missionStore.updateSlice(slice.id, {
planState: "planned",
planningNotes: "Interview completed with decisions documented",
verification: "All acceptance criteria met",
});
expect(updated.planState).toBe("planned");
expect(updated.planningNotes).toBe("Interview completed with decisions documented");
expect(updated.verification).toBe("All acceptance criteria met");
});
it("transitions planState to needs_update when revisions needed", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Plan State Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, {
title: "Test Slice",
});
// Slice should default to not_started
expect(slice.planState).toBe("not_started");
// Simulate interview completion by updating planState
let updated = missionStore.updateSlice(slice.id, {
planState: "planned",
planningNotes: "Interview completed with decisions documented",
verification: "All acceptance criteria met",
});
expect(updated.planState).toBe("planned");
// Simulate requesting updates
updated = missionStore.updateSlice(slice.id, {
planState: "needs_update",
});
expect(updated.planState).toBe("needs_update");
});
it("planState changes do not affect milestone or mission status", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Status Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, {
title: "Test Slice",
});
// New missions are "planning" status
expect(mission.status).toBe("planning");
// New milestones are "planning" status
expect(milestone.status).toBe("planning");
expect(slice.status).toBe("pending");
expect(slice.status).toBe("pending");
// Change planState multiple times
missionStore.updateSlice(slice.id, { planState: "planned" });
missionStore.updateSlice(slice.id, { planState: "needs_update" });
missionStore.updateSlice(slice.id, { planState: "planned" });
// Status should remain unchanged
const refreshedMission = missionStore.getMission(mission.id);
const refreshedMilestone = missionStore.getMilestone(milestone.id);
const refreshedSlice = missionStore.getSlice(slice.id);
expect(refreshedMission?.status).toBe("planning");
expect(refreshedMilestone?.status).toBe("planning");
expect(refreshedSlice?.status).toBe("pending");
});
});
describe("milestone interview state integration", () => {
it("milestone interviewState transitions work correctly", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Interview Test" });
const milestone = missionStore.addMilestone(mission.id, {
title: "Test Milestone",
});
// interviewState defaults to not_started
expect(milestone.interviewState).toBe("not_started");
// Transition to in_progress
let updated = missionStore.updateMilestone(milestone.id, {
interviewState: "in_progress",
});
expect(updated.interviewState).toBe("in_progress");
// Complete the interview
updated = missionStore.updateMilestone(milestone.id, {
interviewState: "completed",
planningNotes: "Interview completed successfully",
verification: "All requirements captured",
});
expect(updated.interviewState).toBe("completed");
expect(updated.planningNotes).toBe("Interview completed successfully");
expect(updated.verification).toBe("All requirements captured");
// Request update
updated = missionStore.updateMilestone(milestone.id, {
interviewState: "needs_update",
});
expect(updated.interviewState).toBe("needs_update");
});
it("enriched description includes milestone interview state", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Interview Context Test",
description: "Mission with interview context",
});
// First create milestone, then update with interview results
const milestone = missionStore.addMilestone(mission.id, {
title: "Interviewed Milestone",
description: "Milestone after interview",
});
// Simulate interview completion
missionStore.updateMilestone(milestone.id, {
interviewState: "completed",
verification: "Verified criteria",
planningNotes: "Key decisions from interview",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Test Slice",
});
const feature = missionStore.addFeature(slice.id, {
title: "Test Feature",
description: "Feature description",
});
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toContain("Interviewed Milestone");
expect(enriched).toContain("Key decisions from interview");
expect(enriched).toContain("Verified criteria");
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NodeConnection } from "../node-connection.js";
import type { CentralCore } from "../central-core.js";
import type { NodeConfig } from "../types.js";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json",
},
});
}
describe("NodeConnection", () => {
let connection: NodeConnection;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
connection = new NodeConnection();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("input validation", () => {
it("throws TypeError for empty host", async () => {
await expect(connection.test({ host: " ", port: 3000 })).rejects.toThrow(TypeError);
});
it("throws TypeError for port 0", async () => {
await expect(connection.test({ host: "127.0.0.1", port: 0 })).rejects.toThrow(TypeError);
});
it("throws TypeError for port greater than 65535", async () => {
await expect(connection.test({ host: "127.0.0.1", port: 70_000 })).rejects.toThrow(TypeError);
});
it("throws TypeError for negative timeout", async () => {
await expect(
connection.test({
host: "127.0.0.1",
port: 3000,
timeoutMs: -1,
})
).rejects.toThrow(TypeError);
});
});
describe("successful connections", () => {
it("connects to an IP address and returns metadata", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
status: "ok",
name: "Remote Node",
version: "1.2.3",
uptime: 123,
capabilities: ["executor"],
})
);
const result = await connection.test({
host: "192.168.1.100",
port: 3000,
});
expect(result.success).toBe(true);
expect(result.url).toBe("http://192.168.1.100:3000");
expect(result.nodeInfo).toEqual({
name: "Remote Node",
version: "1.2.3",
uptime: 123,
capabilities: ["executor"],
});
expect(result.latencyMs).toBeTypeOf("number");
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
expect(fetchMock).toHaveBeenCalledWith("http://192.168.1.100:3000/api/health", {
method: "GET",
headers: undefined,
signal: expect.any(AbortSignal),
});
});
it("connects to a hostname", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
const result = await connection.test({
host: "my-server.local",
port: 8080,
});
expect(result.success).toBe(true);
expect(result.url).toBe("http://my-server.local:8080");
expect(fetchMock).toHaveBeenCalledWith("http://my-server.local:8080/api/health", {
method: "GET",
headers: undefined,
signal: expect.any(AbortSignal),
});
});
it("uses https when secure is true", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
const result = await connection.test({
host: "secure.host",
port: 443,
secure: true,
});
expect(result.success).toBe(true);
expect(result.url).toBe("https://secure.host:443");
expect(fetchMock).toHaveBeenCalledWith("https://secure.host:443/api/health", {
method: "GET",
headers: undefined,
signal: expect.any(AbortSignal),
});
});
it("supports a reverse-proxy basePath", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
const result = await connection.test({
host: "host",
port: 3000,
basePath: "/fusion",
});
expect(result.success).toBe(true);
expect(result.url).toBe("http://host:3000/fusion");
expect(fetchMock).toHaveBeenCalledWith("http://host:3000/fusion/api/health", {
method: "GET",
headers: undefined,
signal: expect.any(AbortSignal),
});
});
it("sends bearer auth when apiKey is provided", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
const result = await connection.test({
host: "host",
port: 3000,
apiKey: "secret-key",
});
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledWith("http://host:3000/api/health", {
method: "GET",
headers: {
Authorization: "Bearer secret-key",
},
signal: expect.any(AbortSignal),
});
});
it("applies defaults when optional health fields are missing", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
const result = await connection.test({
host: "minimal.host",
port: 3000,
});
expect(result.success).toBe(true);
expect(result.nodeInfo).toEqual({
name: "minimal.host",
version: "unknown",
uptime: 0,
capabilities: undefined,
});
});
});
describe("error handling", () => {
it("returns timeout classification for AbortError", async () => {
fetchMock.mockRejectedValueOnce(new DOMException("The operation was aborted", "AbortError"));
const result = await connection.test({
host: "host",
port: 3000,
timeoutMs: 2500,
});
expect(result).toMatchObject({
success: false,
error: {
type: "timeout",
},
});
expect(result.error?.message).toContain("2500");
});
it("returns dns-failure when fetch message contains ENOTFOUND", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: getaddrinfo ENOTFOUND missing.local"));
const result = await connection.test({ host: "missing.local", port: 3000 });
expect(result).toMatchObject({
success: false,
error: {
type: "dns-failure",
},
});
});
it("returns connection-refused when fetch message contains ECONNREFUSED", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: connect ECONNREFUSED 127.0.0.1:3000"));
const result = await connection.test({ host: "127.0.0.1", port: 3000 });
expect(result).toMatchObject({
success: false,
error: {
type: "connection-refused",
},
});
});
it("returns ssl-error for TLS certificate failures", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: CERT_HAS_EXPIRED"));
const result = await connection.test({ host: "secure.example", port: 443, secure: true });
expect(result).toMatchObject({
success: false,
error: {
type: "ssl-error",
},
});
});
it("returns auth-failure for HTTP 401", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401));
const result = await connection.test({ host: "host", port: 3000, apiKey: "bad" });
expect(result).toEqual({
success: false,
url: "http://host:3000",
error: {
type: "auth-failure",
message: "Authentication failed (401) while testing http://host:3000",
statusCode: 401,
},
});
});
it("returns auth-failure for HTTP 403", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "forbidden" }, 403));
const result = await connection.test({ host: "host", port: 3000, apiKey: "bad" });
expect(result).toEqual({
success: false,
url: "http://host:3000",
error: {
type: "auth-failure",
message: "Authentication failed (403) while testing http://host:3000",
statusCode: 403,
},
});
});
it("returns unexpected-status for non-auth non-2xx responses", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500));
const result = await connection.test({ host: "host", port: 3000 });
expect(result).toEqual({
success: false,
url: "http://host:3000",
error: {
type: "unexpected-status",
message: "Unexpected response status 500 while testing http://host:3000",
statusCode: 500,
},
});
});
it("returns not-fusion-node when response JSON lacks status field", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ healthy: true }));
const result = await connection.test({ host: "host", port: 3000 });
expect(result).toMatchObject({
success: false,
error: {
type: "not-fusion-node",
},
});
});
it("returns network-error for unknown failures", async () => {
fetchMock.mockRejectedValueOnce(new Error("socket hang up"));
const result = await connection.test({ host: "host", port: 3000 });
expect(result).toEqual({
success: false,
url: "http://host:3000",
error: {
type: "network-error",
message: "socket hang up",
},
});
});
});
describe("testAndRegister", () => {
it("returns node when connection and registration both succeed", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ status: "ok", name: "Remote", version: "1.0.0", uptime: 42 })
);
const node: NodeConfig = {
id: "node_123",
name: "remote-node",
type: "remote",
url: "http://remote.host:3000",
apiKey: "secret",
status: "offline",
maxConcurrent: 4,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
const registerNodeMock = vi.fn().mockResolvedValue(node);
const checkNodeHealthMock = vi.fn().mockResolvedValue("online");
const central = {
registerNode: registerNodeMock,
checkNodeHealth: checkNodeHealthMock,
} as unknown as CentralCore;
const result = await connection.testAndRegister(central, {
name: "remote-node",
host: "remote.host",
port: 3000,
apiKey: "secret",
maxConcurrent: 4,
});
expect(result.success).toBe(true);
expect(result.node).toEqual(node);
expect(result.registrationError).toBeUndefined();
expect(registerNodeMock).toHaveBeenCalledWith({
name: "remote-node",
type: "remote",
url: "http://remote.host:3000",
apiKey: "secret",
maxConcurrent: 4,
});
expect(checkNodeHealthMock).toHaveBeenCalledWith("node_123");
});
it("returns registrationError when registration fails", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
const registerNodeMock = vi
.fn()
.mockRejectedValue(new Error("Node already exists with name: remote-node"));
const checkNodeHealthMock = vi.fn();
const central = {
registerNode: registerNodeMock,
checkNodeHealth: checkNodeHealthMock,
} as unknown as CentralCore;
const result = await connection.testAndRegister(central, {
name: "remote-node",
host: "remote.host",
port: 3000,
});
expect(result.success).toBe(true);
expect(result.node).toBeUndefined();
expect(result.registrationError).toBe("Node already exists with name: remote-node");
expect(checkNodeHealthMock).not.toHaveBeenCalled();
});
it("skips registration when connection test fails", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: connect ECONNREFUSED 127.0.0.1:3000"));
const registerNodeMock = vi.fn();
const checkNodeHealthMock = vi.fn();
const central = {
registerNode: registerNodeMock,
checkNodeHealth: checkNodeHealthMock,
} as unknown as CentralCore;
const result = await connection.testAndRegister(central, {
name: "remote-node",
host: "127.0.0.1",
port: 3000,
});
expect(result).toMatchObject({
success: false,
error: {
type: "connection-refused",
},
});
expect(registerNodeMock).not.toHaveBeenCalled();
expect(checkNodeHealthMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,445 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { DiscoveryConfig, DiscoveredNode } from "../types.js";
interface MockBrowser {
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
emit: (event: string, ...args: unknown[]) => void;
}
const { BonjourMock, publishMock, findMock, destroyMock } = vi.hoisted(() => ({
BonjourMock: vi.fn(),
publishMock: vi.fn(),
findMock: vi.fn(),
destroyMock: vi.fn(),
}));
vi.mock("bonjour-service", () => ({
Bonjour: BonjourMock,
default: BonjourMock,
}));
import { NodeDiscovery } from "../node-discovery.js";
function createMockBrowser(): MockBrowser {
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
const browser: MockBrowser = {
on: vi.fn((event: string, callback: (...args: unknown[]) => void) => {
const callbacks = listeners.get(event) ?? new Set();
callbacks.add(callback);
listeners.set(event, callbacks);
return browser;
}),
off: vi.fn((event: string, callback: (...args: unknown[]) => void) => {
listeners.get(event)?.delete(callback);
return browser;
}),
stop: vi.fn(),
emit(event: string, ...args: unknown[]) {
for (const callback of listeners.get(event) ?? []) {
callback(...args);
}
},
};
return browser;
}
function defaultConfig(overrides: Partial<DiscoveryConfig> = {}): DiscoveryConfig {
return {
broadcast: false,
listen: false,
serviceType: "_fusion._tcp",
port: 4040,
staleTimeoutMs: 300_000,
...overrides,
};
}
function createService(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
name: "peer-node",
port: 4040,
addresses: ["192.168.1.200"],
txt: {
nodeType: "remote",
nodeId: "node_remote_1",
},
...overrides,
};
}
describe("NodeDiscovery", () => {
let browser: MockBrowser;
let publishService: { stop: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
browser = createMockBrowser();
publishService = { stop: vi.fn() };
publishMock.mockReturnValue(publishService);
findMock.mockReturnValue(browser);
destroyMock.mockReturnValue(undefined);
BonjourMock.mockImplementation(() => ({
publish: publishMock,
find: findMock,
destroy: destroyMock,
}));
});
afterEach(() => {
vi.useRealTimers();
});
it("starts and stops broadcast mode", () => {
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
discovery.start("node_local_1", "Local Node");
expect(publishMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local Node",
type: "fusion",
protocol: "tcp",
port: 4040,
txt: expect.objectContaining({
nodeType: "local",
nodeId: "node_local_1",
version: expect.any(String),
}),
}),
);
discovery.stop();
expect(publishService.stop).toHaveBeenCalledTimes(1);
expect(destroyMock).toHaveBeenCalledTimes(1);
});
it("falls back to hostname when broadcast nodeName is empty", () => {
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
discovery.start("node_local_1", " ");
expect(publishMock).toHaveBeenCalledWith(
expect.objectContaining({
name: expect.any(String),
}),
);
});
it("starts listen mode and emits node:discovered/node:lost", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const discoveredHandler = vi.fn();
const lostHandler = vi.fn();
discovery.on("node:discovered", discoveredHandler);
discovery.on("node:lost", lostHandler);
discovery.start("node_local_1", "Local");
expect(findMock).toHaveBeenCalledWith({
type: "fusion",
protocol: "tcp",
});
browser.emit("up", createService());
expect(discoveredHandler).toHaveBeenCalledTimes(1);
expect(discoveredHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: "peer-node",
host: "192.168.1.200",
port: 4040,
nodeType: "remote",
nodeId: "node_remote_1",
discoveredAt: expect.any(String),
lastSeenAt: expect.any(String),
}),
);
browser.emit("down", createService());
expect(lostHandler).toHaveBeenCalledWith("peer-node");
});
it("ignores down events for unknown services", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const lostHandler = vi.fn();
discovery.on("node:lost", lostHandler);
discovery.start("node_local_1", "Local");
browser.emit("down", createService({ name: "missing-node" }));
expect(lostHandler).not.toHaveBeenCalled();
});
it("emits node:updated for already known services", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const updatedHandler = vi.fn();
discovery.on("node:updated", updatedHandler);
discovery.start("node_local_1", "Local");
browser.emit("up", createService());
browser.emit("up", createService({ addresses: ["192.168.1.201"] }));
expect(updatedHandler).toHaveBeenCalledTimes(1);
expect(updatedHandler).toHaveBeenCalledWith(
expect.objectContaining({ host: "192.168.1.201" }),
);
});
it("self-filters discovery events with matching nodeId", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const discoveredHandler = vi.fn();
discovery.on("node:discovered", discoveredHandler);
discovery.start("node_local_1", "Local");
browser.emit(
"up",
createService({
txt: {
nodeType: "local",
nodeId: "node_local_1",
},
}),
);
expect(discoveredHandler).not.toHaveBeenCalled();
expect(discovery.getDiscoveredNodes()).toEqual([]);
});
it("supports combined broadcast + listen mode", () => {
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true, listen: true }));
discovery.start("node_local_1", "Local Node");
expect(publishMock).toHaveBeenCalledTimes(1);
expect(findMock).toHaveBeenCalledTimes(1);
});
it("cleans up stale nodes after staleTimeoutMs", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-08T12:00:00.000Z"));
const discovery = new NodeDiscovery(defaultConfig({ listen: true, staleTimeoutMs: 1_000 }));
const lostHandler = vi.fn();
discovery.on("node:lost", lostHandler);
discovery.start("node_local_1", "Local");
browser.emit("up", createService({ name: "stale-node" }));
expect(discovery.getDiscoveredNode("stale-node")).toBeDefined();
vi.advanceTimersByTime(61_000);
expect(discovery.getDiscoveredNode("stale-node")).toBeUndefined();
expect(lostHandler).toHaveBeenCalledWith("stale-node");
});
it("stop() is idempotent", () => {
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true, listen: true }));
discovery.start("node_local_1", "Local");
expect(() => discovery.stop()).not.toThrow();
expect(() => discovery.stop()).not.toThrow();
expect(publishService.stop).toHaveBeenCalledTimes(1);
expect(browser.stop).toHaveBeenCalledTimes(1);
expect(destroyMock).toHaveBeenCalledTimes(1);
});
it("startBroadcast() and startListening() are idempotent", () => {
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true, listen: true }));
discovery.startBroadcast("node_local_1", "Local");
discovery.startBroadcast("node_local_1", "Local");
discovery.startListening();
discovery.startListening();
expect(publishMock).toHaveBeenCalledTimes(1);
expect(findMock).toHaveBeenCalledTimes(1);
});
it("continues when broadcast publish throws and emits error", () => {
const error = new Error("multicast unavailable");
publishMock.mockImplementation(() => {
throw error;
});
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
const errorHandler = vi.fn();
discovery.on("error", errorHandler);
expect(() => discovery.start("node_local_1", "Local")).not.toThrow();
expect(errorHandler).toHaveBeenCalledWith(error);
});
it("continues when listener startup throws and emits error", () => {
const error = new Error("listen failed");
findMock.mockImplementation(() => {
throw error;
});
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const errorHandler = vi.fn();
discovery.on("error", errorHandler);
expect(() => discovery.start("node_local_1", "Local")).not.toThrow();
expect(errorHandler).toHaveBeenCalledWith(error);
});
it("returns discovered nodes and specific discovered node by name", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
expect(discovery.getDiscoveredNodes()).toEqual([]);
expect(discovery.getDiscoveredNode("missing")).toBeUndefined();
discovery.start("node_local_1", "Local");
browser.emit("up", createService({ name: "peer-a" }));
browser.emit("up", createService({ name: "peer-b", addresses: ["192.168.1.201"] }));
const nodes = discovery.getDiscoveredNodes();
expect(nodes).toHaveLength(2);
expect(nodes.map((node) => node.name).sort()).toEqual(["peer-a", "peer-b"]);
expect(discovery.getDiscoveredNode("peer-b")).toEqual(
expect.objectContaining({ host: "192.168.1.201" }),
);
});
it("defaults nodeType to local when TXT nodeType is missing", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const discoveredHandler = vi.fn();
discovery.on("node:discovered", discoveredHandler);
discovery.start("node_local_1", "Local");
browser.emit(
"up",
createService({
txt: {
nodeId: "node_remote",
},
}),
);
expect(discoveredHandler).toHaveBeenCalledWith(
expect.objectContaining({ nodeType: "local" }),
);
});
it("uses fallback host resolution paths", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const discoveredHandler = vi.fn();
discovery.on("node:discovered", discoveredHandler);
discovery.start("node_local_1", "Local");
browser.emit(
"up",
createService({
addresses: ["fe80::1"],
referer: { address: "10.0.0.9" },
}),
);
expect(discoveredHandler).toHaveBeenCalledWith(
expect.objectContaining({ host: "10.0.0.9" }),
);
browser.emit(
"up",
createService({
name: "referer-only",
addresses: [],
referer: { address: "10.0.0.10" },
}),
);
expect(discovery.getDiscoveredNode("referer-only")).toEqual(
expect.objectContaining({ host: "10.0.0.10" }),
);
});
it("handles service type parsing for short format and uppercase", () => {
const shortTypeDiscovery = new NodeDiscovery(defaultConfig({ broadcast: true, serviceType: "fusion" }));
shortTypeDiscovery.start("node_local_1", "Local");
expect(publishMock).toHaveBeenCalledWith(
expect.objectContaining({ type: "fusion", protocol: "tcp" }),
);
const uppercaseDiscovery = new NodeDiscovery(
defaultConfig({
listen: true,
serviceType: "_FUSION._TCP",
}),
);
uppercaseDiscovery.start("node_local_1", "Local");
expect(findMock).toHaveBeenCalledWith({ type: "fusion", protocol: "tcp" });
});
it("stringifies numeric and boolean TXT values", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const discoveredHandler = vi.fn();
discovery.on("node:discovered", discoveredHandler);
discovery.start("node_local_1", "Local");
browser.emit(
"up",
createService({
txt: {
nodeType: true,
nodeId: 1234,
},
}),
);
expect(discoveredHandler).toHaveBeenCalledWith(
expect.objectContaining({ nodeId: "1234", nodeType: "local" }),
);
});
it("does not emit discovery when host resolution fails", () => {
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
const discoveredHandler = vi.fn();
discovery.on("node:discovered", discoveredHandler);
discovery.start("node_local_1", "Local");
browser.emit(
"up",
createService({
name: "no-host",
addresses: [],
referer: undefined,
}),
);
expect(discoveredHandler).not.toHaveBeenCalled();
expect(discovery.getDiscoveredNode("no-host")).toBeUndefined();
});
it("emits discovery start/stop lifecycle events", () => {
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
const startedHandler = vi.fn();
const stoppedHandler = vi.fn();
discovery.on("discovery:started", startedHandler);
discovery.on("discovery:stopped", stoppedHandler);
discovery.start("node_local_1", "Local");
discovery.stop();
expect(startedHandler).toHaveBeenCalledTimes(1);
expect(stoppedHandler).toHaveBeenCalledTimes(1);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,695 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PluginStore } from "../plugin-store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import type { PluginManifest, PluginState } from "../plugin-types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-plugin-test-"));
}
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
return {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
description: "A test plugin",
...overrides,
};
}
describe("PluginStore", () => {
let rootDir: string;
let store: PluginStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new PluginStore(rootDir);
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
describe("init", () => {
it("creates the database file", async () => {
const dbPath = join(rootDir, ".fusion", "fusion.db");
const { existsSync } = await import("node:fs");
expect(existsSync(dbPath)).toBe(true);
});
it("is idempotent", async () => {
await store.init();
await store.init();
// Should not throw
const plugins = await store.listPlugins();
expect(plugins).toEqual([]);
});
it("creates the plugins table", async () => {
// If the table doesn't exist, listPlugins would fail
const plugins = await store.listPlugins();
expect(Array.isArray(plugins)).toBe(true);
});
});
// ── registerPlugin ─────────────────────────────────────────────────
describe("registerPlugin", () => {
it("registers a valid plugin and returns full record", async () => {
const manifest = makeManifest();
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(plugin.id).toBe("test-plugin");
expect(plugin.name).toBe("Test Plugin");
expect(plugin.version).toBe("1.0.0");
expect(plugin.description).toBe("A test plugin");
expect(plugin.path).toBe("/path/to/plugin");
expect(plugin.enabled).toBe(true);
expect(plugin.state).toBe("installed");
expect(plugin.settings).toEqual({});
expect(plugin.dependencies).toEqual([]);
expect(plugin.createdAt).toBeTruthy();
expect(plugin.updatedAt).toBeTruthy();
});
it("registers plugin with custom settings", async () => {
const manifest = makeManifest();
const plugin = await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: { apiKey: "secret123", maxItems: 10 },
});
expect(plugin.settings).toEqual({ apiKey: "secret123", maxItems: 10 });
});
it("registers plugin with dependencies", async () => {
const manifest = makeManifest({ dependencies: ["other-plugin"] });
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(plugin.dependencies).toEqual(["other-plugin"]);
});
it("registers plugin with settings schema", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string", required: true },
count: { type: "number", defaultValue: 5 },
},
});
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(plugin.settingsSchema).toBeTruthy();
expect(plugin.settingsSchema!.apiKey.type).toBe("string");
expect(plugin.settingsSchema!.count.defaultValue).toBe(5);
});
it("applies default values from settingsSchema when registering", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string", defaultValue: "default-key" },
count: { type: "number", defaultValue: 10 },
enabled: { type: "boolean", defaultValue: true },
},
});
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
// Defaults should be applied
expect(plugin.settings.apiKey).toBe("default-key");
expect(plugin.settings.count).toBe(10);
expect(plugin.settings.enabled).toBe(true);
});
it("overrides defaults with explicit settings", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string", defaultValue: "default-key" },
count: { type: "number", defaultValue: 10 },
},
});
const plugin = await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: { apiKey: "custom-key", count: 20 },
});
// Explicit settings should win over defaults
expect(plugin.settings.apiKey).toBe("custom-key");
expect(plugin.settings.count).toBe(20);
});
it("rejects missing manifest id", async () => {
const manifest = makeManifest({ id: "" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects missing manifest name", async () => {
const manifest = makeManifest({ name: "" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects missing manifest version", async () => {
const manifest = makeManifest({ version: "" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects invalid id format (uppercase)", async () => {
const manifest = makeManifest({ id: "Test-Plugin" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects invalid id format (underscores)", async () => {
const manifest = makeManifest({ id: "test_plugin" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects invalid id format (starts with hyphen)", async () => {
const manifest = makeManifest({ id: "-test-plugin" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects empty path", async () => {
const manifest = makeManifest({ id: "valid-plugin" });
await expect(
store.registerPlugin({ manifest, path: "" }),
).rejects.toThrow("Plugin path is required");
});
it("rejects duplicate plugin id", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin1" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin2" }),
).rejects.toThrow("already registered");
});
it("emits plugin:registered event", async () => {
const listener = vi.fn();
store.on("plugin:registered", listener);
const manifest = makeManifest({ id: "event-plugin" });
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(listener).toHaveBeenCalledWith(plugin);
});
});
// ── unregisterPlugin ─────────────────────────────────────────────
describe("unregisterPlugin", () => {
it("removes a registered plugin", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const removed = await store.unregisterPlugin("test-plugin");
expect(removed.id).toBe("test-plugin");
await expect(store.getPlugin("test-plugin")).rejects.toThrow("not found");
});
it("throws on non-existent plugin", async () => {
await expect(store.unregisterPlugin("nonexistent")).rejects.toThrow(
"not found",
);
});
it("emits plugin:unregistered event", async () => {
const listener = vi.fn();
store.on("plugin:unregistered", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.unregisterPlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].id).toBe("test-plugin");
});
});
// ── getPlugin ────────────────────────────────────────────────────
describe("getPlugin", () => {
it("returns registered plugin", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.getPlugin("test-plugin");
expect(plugin.id).toBe("test-plugin");
expect(plugin.name).toBe("Test Plugin");
});
it("throws ENOENT on non-existent plugin", async () => {
await expect(store.getPlugin("nonexistent")).rejects.toThrow("not found");
});
});
// ── listPlugins ──────────────────────────────────────────────────
describe("listPlugins", () => {
it("returns all registered plugins", async () => {
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-a" }),
path: "/path/a",
});
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-b" }),
path: "/path/b",
});
const plugins = await store.listPlugins();
expect(plugins).toHaveLength(2);
expect(plugins.map((p) => p.id).sort()).toEqual(["plugin-a", "plugin-b"]);
});
it("filters by enabled status", async () => {
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-a" }),
path: "/path/a",
});
const b = await store.registerPlugin({
manifest: makeManifest({ id: "plugin-b" }),
path: "/path/b",
});
await store.disablePlugin("plugin-a");
const enabled = await store.listPlugins({ enabled: true });
expect(enabled).toHaveLength(1);
expect(enabled[0].id).toBe("plugin-b");
const disabled = await store.listPlugins({ enabled: false });
expect(disabled).toHaveLength(1);
expect(disabled[0].id).toBe("plugin-a");
});
it("filters by state", async () => {
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-a" }),
path: "/path/a",
});
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-b" }),
path: "/path/b",
});
// Start plugin-a
await store.updatePluginState("plugin-a", "started");
const installed = await store.listPlugins({ state: "installed" });
expect(installed).toHaveLength(1);
expect(installed[0].id).toBe("plugin-b");
const started = await store.listPlugins({ state: "started" });
expect(started).toHaveLength(1);
expect(started[0].id).toBe("plugin-a");
});
it("returns empty array when no plugins", async () => {
const plugins = await store.listPlugins();
expect(plugins).toEqual([]);
});
});
// ── enablePlugin ─────────────────────────────────────────────────
describe("enablePlugin", () => {
it("sets enabled to true", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.disablePlugin("test-plugin");
const plugin = await store.enablePlugin("test-plugin");
expect(plugin.enabled).toBe(true);
});
it("emits plugin:enabled event", async () => {
const listener = vi.fn();
store.on("plugin:enabled", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.enablePlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
});
it("emits plugin:updated event", async () => {
const listener = vi.fn();
store.on("plugin:updated", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.enablePlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── disablePlugin ────────────────────────────────────────────────
describe("disablePlugin", () => {
it("sets enabled to false", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.disablePlugin("test-plugin");
expect(plugin.enabled).toBe(false);
});
it("emits plugin:disabled event", async () => {
const listener = vi.fn();
store.on("plugin:disabled", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.disablePlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── updatePluginState ────────────────────────────────────────────
describe("updatePluginState", () => {
it("updates state to started", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePluginState("test-plugin", "started");
expect(plugin.state).toBe("started");
});
it("updates state to stopped", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
const plugin = await store.updatePluginState("test-plugin", "stopped");
expect(plugin.state).toBe("stopped");
});
it("updates state to error with message", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePluginState(
"test-plugin",
"error",
"Failed to load",
);
expect(plugin.state).toBe("error");
expect(plugin.error).toBe("Failed to load");
});
it("allows any state to transition to error", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
// installed -> error is valid
const plugin1 = await store.updatePluginState(
"test-plugin",
"error",
"test",
);
expect(plugin1.state).toBe("error");
});
it("rejects invalid state transitions", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
// Cannot go from stopped directly back to installed
await store.updatePluginState("test-plugin", "stopped");
await expect(
store.updatePluginState("test-plugin", "installed"),
).rejects.toThrow("Invalid state transition");
});
it("allows restarting from stopped", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
await store.updatePluginState("test-plugin", "stopped");
const plugin = await store.updatePluginState("test-plugin", "started");
expect(plugin.state).toBe("started");
});
it("emits plugin:stateChanged event", async () => {
const listener = vi.fn();
store.on("plugin:stateChanged", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].id).toBe("test-plugin");
expect(listener.mock.calls[0][1]).toBe("installed");
expect(listener.mock.calls[0][2]).toBe("started");
});
});
// ── updatePluginSettings ─────────────────────────────────────────
describe("updatePluginSettings", () => {
it("merges settings", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string" },
count: { type: "number", defaultValue: 5 },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: { apiKey: "secret123" },
});
const plugin = await store.updatePluginSettings("test-plugin", {
count: 10,
});
expect(plugin.settings).toEqual({ apiKey: "secret123", count: 10 });
});
it("validates required settings", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string", required: true },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
await expect(
store.updatePluginSettings("test-plugin", {}),
).rejects.toThrow('Setting "apiKey" is required');
});
it("validates setting types", async () => {
const manifest = makeManifest({
settingsSchema: {
count: { type: "number" },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
await expect(
store.updatePluginSettings("test-plugin", { count: "not a number" }),
).rejects.toThrow('Setting "count" must be a number');
});
it("validates enum values", async () => {
const manifest = makeManifest({
settingsSchema: {
color: { type: "enum", enumValues: ["red", "green", "blue"] },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
await expect(
store.updatePluginSettings("test-plugin", { color: "yellow" }),
).rejects.toThrow('Setting "color" must be one of');
});
it("validates password type as string", async () => {
const manifest = makeManifest({
settingsSchema: {
apiSecret: { type: "password" },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
// Valid: string value for password
const plugin1 = await store.updatePluginSettings("test-plugin", {
apiSecret: "valid-secret",
});
expect(plugin1.settings.apiSecret).toBe("valid-secret");
// Invalid: non-string value for password
await expect(
store.updatePluginSettings("test-plugin", { apiSecret: 12345 }),
).rejects.toThrow('Setting "apiSecret" must be a string');
});
it("validates array type", async () => {
const manifest = makeManifest({
settingsSchema: {
tags: { type: "array", itemType: "string" },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
// Valid: array of strings
const plugin1 = await store.updatePluginSettings("test-plugin", {
tags: ["bug", "feature"],
});
expect(plugin1.settings.tags).toEqual(["bug", "feature"]);
// Invalid: non-array value
await expect(
store.updatePluginSettings("test-plugin", { tags: "not-an-array" }),
).rejects.toThrow('Setting "tags" must be an array');
// Invalid: array with wrong item type
await expect(
store.updatePluginSettings("test-plugin", { tags: [1, 2, 3] }),
).rejects.toThrow('Setting "tags" must be an array of string');
});
it("validates number array type", async () => {
const manifest = makeManifest({
settingsSchema: {
scores: { type: "array", itemType: "number" },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
// Valid: array of numbers
const plugin1 = await store.updatePluginSettings("test-plugin", {
scores: [10, 20, 30],
});
expect(plugin1.settings.scores).toEqual([10, 20, 30]);
// Invalid: array with wrong item type
await expect(
store.updatePluginSettings("test-plugin", { scores: ["a", "b"] }),
).rejects.toThrow('Setting "scores" must be an array of number');
});
it("emits plugin:updated event", async () => {
const listener = vi.fn();
store.on("plugin:updated", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginSettings("test-plugin", { key: "value" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── updatePlugin ─────────────────────────────────────────────────
describe("updatePlugin", () => {
it("updates name", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", { name: "New Name" });
expect(plugin.name).toBe("New Name");
});
it("updates version", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", { version: "2.0.0" });
expect(plugin.version).toBe("2.0.0");
});
it("updates description", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", {
description: "New description",
});
expect(plugin.description).toBe("New description");
});
it("updates path", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", {
path: "/new/path/to/plugin",
});
expect(plugin.path).toBe("/new/path/to/plugin");
});
it("updates dependencies", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", {
dependencies: ["dep-a", "dep-b"],
});
expect(plugin.dependencies).toEqual(["dep-a", "dep-b"]);
});
it("emits plugin:updated event", async () => {
const listener = vi.fn();
store.on("plugin:updated", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePlugin("test-plugin", { name: "Updated" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,993 @@
import { describe, it, expect } from "vitest";
import { validatePluginManifest } from "../plugin-types.js";
describe("validatePluginManifest", () => {
// ── Valid Manifests ─────────────────────────────────────────────────
describe("valid manifests", () => {
it("accepts a minimal valid manifest", () => {
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts a full valid manifest with all optional fields", () => {
const manifest = {
id: "my-plugin",
name: "My Plugin",
version: "1.2.3",
description: "A test plugin",
author: "Test Author",
homepage: "https://example.com",
fusionVersion: "1.0.0",
dependencies: ["other-plugin"],
settingsSchema: {
apiKey: {
type: "string",
label: "API Key",
description: "Your API key",
required: true,
},
maxItems: {
type: "number",
label: "Max Items",
defaultValue: 10,
},
enabled: {
type: "boolean",
label: "Enable Feature",
defaultValue: true,
},
color: {
type: "enum",
label: "Color",
enumValues: ["red", "green", "blue"],
defaultValue: "blue",
},
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts manifest with version 0.0.1", () => {
const manifest = { id: "test", name: "Test", version: "0.0.1" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
});
it("accepts manifest with large version numbers", () => {
const manifest = { id: "test", name: "Test", version: "100.200.300" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
});
it("accepts manifest with empty dependencies array", () => {
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: [] };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
});
it("accepts manifest with multiple valid dependencies", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
dependencies: ["plugin-a", "plugin-b", "plugin-c"],
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
});
it("accepts manifest with valid settingsSchema", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: {
setting1: { type: "string" },
setting2: { type: "number" },
setting3: { type: "boolean" },
setting4: { type: "enum", enumValues: ["a", "b"] },
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
});
it("accepts password and array types in settingsSchema", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: {
apiSecret: { type: "password", label: "API Secret" },
tags: { type: "array", label: "Tags", itemType: "string" },
scores: { type: "array", label: "Scores", itemType: "number" },
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts string with multiline option", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: {
description: { type: "string", label: "Description", multiline: true },
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
});
// ── Missing Required Fields ─────────────────────────────────────────
describe("missing required fields", () => {
it("rejects manifest with missing id", () => {
const manifest = { name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("id is required and must be a non-empty string");
});
it("rejects manifest with missing name", () => {
const manifest = { id: "my-plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("name is required and must be a non-empty string");
});
it("rejects manifest with missing version", () => {
const manifest = { id: "my-plugin", name: "My Plugin" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("version is required and must be a non-empty string");
});
it("rejects manifest with all required fields missing", () => {
const manifest = { description: "Only a description" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("id is required and must be a non-empty string");
expect(result.errors).toContain("name is required and must be a non-empty string");
expect(result.errors).toContain("version is required and must be a non-empty string");
});
});
// ── Empty Strings ───────────────────────────────────────────────────
describe("empty strings for required fields", () => {
it("rejects manifest with empty id", () => {
const manifest = { id: "", name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("id is required and must be a non-empty string");
});
it("rejects manifest with whitespace-only id", () => {
const manifest = { id: " ", name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("id is required and must be a non-empty string");
});
it("rejects manifest with empty name", () => {
const manifest = { id: "my-plugin", name: "", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("name is required and must be a non-empty string");
});
it("rejects manifest with empty version", () => {
const manifest = { id: "my-plugin", name: "My Plugin", version: "" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("version is required and must be a non-empty string");
});
});
// ── Invalid ID Format ───────────────────────────────────────────────
describe("invalid id format", () => {
it("rejects id with uppercase letters", () => {
const manifest = { id: "My-Plugin", name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
});
it("rejects id with underscores", () => {
const manifest = { id: "my_plugin", name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
});
it("rejects id with spaces", () => {
const manifest = { id: "my plugin", name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
});
it("rejects id starting with a hyphen", () => {
const manifest = { id: "-my-plugin", name: "My Plugin", version: "1.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
});
});
// ── Invalid Version Format ──────────────────────────────────────────
describe("invalid version format", () => {
it("rejects version without semver format", () => {
const manifest = { id: "my-plugin", name: "My Plugin", version: "latest" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
});
it("rejects version with only major number", () => {
const manifest = { id: "my-plugin", name: "My Plugin", version: "1" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
});
it("rejects version with only two parts", () => {
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
});
it("rejects version with four parts", () => {
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0.0.0" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
});
it("rejects version with letters", () => {
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0.0-beta" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
});
it("accepts version with leading zero (1.02.03)", () => {
// This is technically valid semver syntax (though unusual)
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.02.03" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
});
});
// ── Invalid Dependencies ────────────────────────────────────────────
describe("invalid dependencies", () => {
it("rejects non-array dependencies", () => {
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: "not-an-array" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("dependencies must be an array");
});
it("rejects dependencies with non-string items", () => {
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: ["valid", 123, "also-valid"] };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("All dependencies must be non-empty strings");
});
it("rejects dependencies with empty string items", () => {
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: ["valid", "", "also-valid"] };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("All dependencies must be non-empty strings");
});
it("rejects dependencies with whitespace-only string items", () => {
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: [" "] };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("All dependencies must be non-empty strings");
});
});
// ── Invalid settingsSchema ────────────────────────────────────────
describe("invalid settingsSchema", () => {
it("rejects non-object settingsSchema", () => {
const manifest = { id: "test", name: "Test", version: "1.0.0", settingsSchema: "not-an-object" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("settingsSchema must be an object");
});
it("rejects null settingsSchema", () => {
const manifest = { id: "test", name: "Test", version: "1.0.0", settingsSchema: null };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("settingsSchema must be an object");
});
it("rejects setting with invalid type", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: { setting1: { type: "invalid-type" } },
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"settingsSchema.setting1.type must be one of: string, number, boolean, enum, password, array",
);
});
it("rejects enum setting without enumValues", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: { setting1: { type: "enum" } },
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"settingsSchema.setting1.enumValues is required and must be a non-empty array when type is enum",
);
});
it("rejects enum setting with empty enumValues", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: { setting1: { type: "enum", enumValues: [] } },
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"settingsSchema.setting1.enumValues is required and must be a non-empty array when type is enum",
);
});
it("rejects array type without itemType", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: { setting1: { type: "array" } },
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"settingsSchema.setting1.itemType is required and must be \"string\" or \"number\" when type is array",
);
});
it("rejects array type with invalid itemType", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: { setting1: { type: "array", itemType: "boolean" } },
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"settingsSchema.setting1.itemType is required and must be \"string\" or \"number\" when type is array",
);
});
it("rejects multiple invalid settings", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
settingsSchema: {
setting1: { type: "invalid" },
setting2: { type: "enum" },
setting3: { type: "string" },
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(2);
});
});
// ── Runtime Manifest Metadata ───────────────────────────────────────
describe("runtime manifest metadata", () => {
it("accepts manifest with valid runtime metadata", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandbox",
version: "1.0.0",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts manifest with minimal runtime metadata (only required fields)", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "my-runtime",
name: "My Runtime",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts manifest without runtime field", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("rejects non-object runtime", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: "not-an-object",
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime must be an object");
});
it("rejects null runtime", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: null,
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime must be an object");
});
it("rejects runtime with missing runtimeId", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
name: "My Runtime",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.runtimeId is required and must be a non-empty string");
});
it("rejects runtime with empty runtimeId", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "",
name: "My Runtime",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.runtimeId is required and must be a non-empty string");
});
it("rejects runtime with invalid runtimeId format", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "My-Runtime",
name: "My Runtime",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.runtimeId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
});
it("rejects runtime with uppercase in runtimeId", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "CodeInterpreter",
name: "My Runtime",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.runtimeId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
});
it("rejects runtime with missing name", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "my-runtime",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.name is required and must be a non-empty string");
});
it("rejects runtime with empty name", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "my-runtime",
name: "",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.name is required and must be a non-empty string");
});
it("rejects runtime with invalid version format", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "my-runtime",
name: "My Runtime",
version: "latest",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.version must be a valid semver string (e.g., 1.0.0)");
});
it("rejects runtime with non-string version", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "my-runtime",
name: "My Runtime",
version: 123,
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors).toContain("runtime.version must be a string");
});
it("accepts runtime with valid semver version", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "my-runtime",
name: "My Runtime",
version: "2.1.3",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("reports multiple runtime validation errors", () => {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: {
runtimeId: "",
name: "",
version: "bad",
},
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(3);
expect(result.errors).toContain("runtime.runtimeId is required and must be a non-empty string");
expect(result.errors).toContain("runtime.name is required and must be a non-empty string");
expect(result.errors).toContain("runtime.version must be a valid semver string (e.g., 1.0.0)");
});
});
// ── Null/Undefined Input ────────────────────────────────────────────
describe("null/undefined input", () => {
it("rejects null manifest", () => {
const result = validatePluginManifest(null);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Manifest is required");
});
it("rejects undefined manifest", () => {
const result = validatePluginManifest(undefined);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Manifest is required");
});
it("rejects non-object manifest", () => {
const result = validatePluginManifest("string");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Manifest must be an object");
});
it("rejects number manifest", () => {
const result = validatePluginManifest(123);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Manifest must be an object");
});
it("rejects array manifest", () => {
const result = validatePluginManifest([]);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Manifest must be an object");
});
});
// ── Error Message Quality ───────────────────────────────────────────
describe("error message quality", () => {
it("returns all errors, not just the first one", () => {
const manifest = { id: "", name: "", version: "" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.length).toBe(3);
});
it("errors are descriptive enough to fix the issue", () => {
const manifest = { id: "Invalid-ID", name: "", version: "bad" };
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
// Each error should give clear guidance
expect(result.errors.some((e) => e.includes("id"))).toBe(true);
expect(result.errors.some((e) => e.includes("name"))).toBe(true);
expect(result.errors.some((e) => e.includes("version"))).toBe(true);
});
});
});
// ── PluginUiSlotDefinition ─────────────────────────────────────────────
describe("PluginUiSlotDefinition", () => {
it("accepts a valid PluginUiSlotDefinition with all fields", () => {
const slot = {
slotId: "task-detail-tab",
label: "Task Details",
icon: "FileText",
componentPath: "./components/TaskDetailTab.js",
};
expect(slot.slotId).toBe("task-detail-tab");
expect(slot.label).toBe("Task Details");
expect(slot.icon).toBe("FileText");
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
});
it("accepts a valid PluginUiSlotDefinition without optional icon field", () => {
const slot = {
slotId: "header-action",
label: "Header Action",
componentPath: "./components/HeaderAction.js",
};
expect(slot.slotId).toBe("header-action");
expect(slot.label).toBe("Header Action");
expect(slot.componentPath).toBe("./components/HeaderAction.js");
// icon is optional, so it should be undefined
expect((slot as any).icon).toBeUndefined();
});
it("requires slotId field", () => {
const slot = {
label: "Some Label",
componentPath: "./components/Test.js",
};
// TypeScript would catch this at compile time, but at runtime we verify the structure
expect((slot as any).slotId).toBeUndefined();
});
it("requires label field", () => {
const slot = {
slotId: "some-slot",
componentPath: "./components/Test.js",
};
expect((slot as any).label).toBeUndefined();
});
it("requires componentPath field", () => {
const slot = {
slotId: "some-slot",
label: "Some Label",
};
expect((slot as any).componentPath).toBeUndefined();
});
});
// ── FusionPlugin with uiSlots ──────────────────────────────────────────
describe("FusionPlugin with uiSlots", () => {
it("accepts a FusionPlugin with uiSlots array", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "task-detail-tab",
label: "Task Details",
componentPath: "./components/TaskDetailTab.js",
},
{
slotId: "header-action",
label: "Header Action",
icon: "Plus",
componentPath: "./components/HeaderAction.js",
},
],
};
expect(plugin.uiSlots).toHaveLength(2);
expect(plugin.uiSlots![0].slotId).toBe("task-detail-tab");
expect(plugin.uiSlots![1].icon).toBe("Plus");
});
it("accepts a FusionPlugin without uiSlots field", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
};
expect((plugin as any).uiSlots).toBeUndefined();
});
});
// ── FusionPlugin with runtime ──────────────────────────────────────────
describe("FusionPlugin with runtime", () => {
it("accepts a FusionPlugin with runtime registration", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
runtime: {
metadata: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandbox",
version: "1.0.0",
},
factory: async () => ({ execute: async () => {} }),
},
};
expect(plugin.runtime).toBeDefined();
expect(plugin.runtime!.metadata.runtimeId).toBe("code-interpreter");
expect(plugin.runtime!.metadata.name).toBe("Code Interpreter");
expect(typeof plugin.runtime!.factory).toBe("function");
});
it("accepts a FusionPlugin with minimal runtime registration", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
runtime: {
metadata: {
runtimeId: "my-runtime",
name: "My Runtime",
},
factory: async () => {},
},
};
expect(plugin.runtime).toBeDefined();
expect(plugin.runtime!.metadata.runtimeId).toBe("my-runtime");
expect(plugin.runtime!.metadata.name).toBe("My Runtime");
});
it("accepts a FusionPlugin without runtime field", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
};
expect((plugin as any).runtime).toBeUndefined();
});
it("accepts a FusionPlugin with uiSlots and runtime together", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "task-detail-tab",
label: "Task Details",
componentPath: "./components/TaskDetailTab.js",
},
],
runtime: {
metadata: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
},
factory: async () => {},
},
};
expect(plugin.uiSlots).toHaveLength(1);
expect(plugin.runtime).toBeDefined();
expect(plugin.runtime!.metadata.runtimeId).toBe("code-interpreter");
});
});
// ── PluginRuntimeManifestMetadata ──────────────────────────────────────
describe("PluginRuntimeManifestMetadata", () => {
it("accepts a valid PluginRuntimeManifestMetadata with all fields", () => {
const metadata = {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandbox",
version: "1.0.0",
};
expect(metadata.runtimeId).toBe("code-interpreter");
expect(metadata.name).toBe("Code Interpreter");
expect(metadata.description).toBe("Executes code in a sandbox");
expect(metadata.version).toBe("1.0.0");
});
it("accepts a PluginRuntimeManifestMetadata without optional fields", () => {
const metadata = {
runtimeId: "my-runtime",
name: "My Runtime",
};
expect(metadata.runtimeId).toBe("my-runtime");
expect(metadata.name).toBe("My Runtime");
expect((metadata as any).description).toBeUndefined();
expect((metadata as any).version).toBeUndefined();
});
it("accepts valid slug format for runtimeId", () => {
const validIds = ["a", "a1", "a-b", "code-interpreter", "web-search-v2"];
for (const runtimeId of validIds) {
const metadata = { runtimeId, name: "Test" };
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: metadata,
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(true);
}
});
it("rejects invalid slug format for runtimeId", () => {
const invalidIds = ["-starts-with-hyphen", "ends-with-hyphen-", "has_underscore", "has space", "UPPERCASE"];
for (const runtimeId of invalidIds) {
const manifest = {
id: "test",
name: "Test",
version: "1.0.0",
runtime: { runtimeId, name: "Test" },
};
const result = validatePluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("runtimeId"))).toBe(true);
}
});
});
// ── PluginRuntimeRegistration ───────────────────────────────────────────
describe("PluginRuntimeRegistration", () => {
it("accepts a valid PluginRuntimeRegistration", () => {
const registration = {
metadata: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
description: "Executes code in a sandbox",
},
factory: async (ctx: any) => {
return {
execute: async (code: string) => {
return { result: `evaluated: ${code}` };
},
};
},
};
expect(registration.metadata.runtimeId).toBe("code-interpreter");
expect(typeof registration.factory).toBe("function");
});
it("accepts synchronous factory function", () => {
const registration = {
metadata: {
runtimeId: "sync-runtime",
name: "Sync Runtime",
},
factory: () => ({ execute: () => {} }),
};
expect(typeof registration.factory).toBe("function");
});
it("factory can return null or void", () => {
const registration = {
metadata: {
runtimeId: "null-runtime",
name: "Null Runtime",
},
factory: async () => {
return null;
},
};
expect(typeof registration.factory).toBe("function");
});
});

View File

@@ -0,0 +1,652 @@
import { describe, it, expect, beforeEach, afterEach, vi, beforeAll } from "vitest";
import { mkdir, rm, writeFile, unlink } from "node:fs/promises";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
getDefaultMemoryScaffold,
ensureMemoryFile,
ensureMemoryFileWithBackend,
buildTriageMemoryInstructions,
buildExecutionMemoryInstructions,
buildReviewerMemoryInstructions,
readProjectMemory,
readProjectMemoryWithBackend,
searchProjectMemory,
resolveMemoryInstructionContext,
} from "../project-memory.js";
describe("project-memory", () => {
let testDir: string;
let memoryPath: string;
let legacyMemoryPath: string;
beforeEach(async () => {
testDir = join(tmpdir(), `kb-memory-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
memoryPath = join(testDir, ".fusion", "memory", "MEMORY.md");
legacyMemoryPath = join(testDir, ".fusion", "memory.md");
// Create the test directory but not the .fusion subdirectory
// Individual tests can create .fusion as needed
await mkdir(testDir, { recursive: true });
});
afterEach(async () => {
// Clean up entire test directory
rmSync(testDir, { recursive: true, force: true });
});
// ── Default Scaffold ──────────────────────────────────────────────
describe("getDefaultMemoryScaffold", () => {
it("returns non-empty markdown content", () => {
const scaffold = getDefaultMemoryScaffold();
expect(scaffold.length).toBeGreaterThan(0);
});
it("contains expected section headings", () => {
const scaffold = getDefaultMemoryScaffold();
expect(scaffold).toContain("## Architecture");
expect(scaffold).toContain("## Conventions");
expect(scaffold).toContain("## Pitfalls");
expect(scaffold).toContain("## Context");
});
it("starts with a top-level heading", () => {
const scaffold = getDefaultMemoryScaffold();
expect(scaffold).toMatch(/^# Project Memory/);
});
});
describe("buildReviewerMemoryInstructions", () => {
it("gives reviewers read-only project memory guidance", () => {
const instructions = buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" });
expect(instructions).toContain("## Project Memory");
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
expect(instructions).toContain("review evidence");
expect(instructions).toContain("Do not update memory during review");
});
it("omits reviewer memory guidance when memory is disabled", () => {
expect(buildReviewerMemoryInstructions(testDir, { memoryEnabled: false })).toBe("");
});
});
// ── ensureMemoryFile ──────────────────────────────────────────────
describe("ensureMemoryFile", () => {
it("creates the memory file when it does not exist", async () => {
const created = await ensureMemoryFile(testDir);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
});
it("writes the long-term scaffold content", async () => {
await ensureMemoryFile(testDir);
const content = await readProjectMemory(testDir);
expect(content).toContain("# Project Memory");
expect(content).toContain("## Decisions");
expect(content).toContain("## Conventions");
});
it("creates long-term memory scaffold even when legacy memory.md exists", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
await writeFile(legacyMemoryPath, "# Legacy Memory\n\nPreserve me", "utf-8");
const created = await ensureMemoryFile(testDir);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
const content = await readProjectMemory(testDir);
expect(content).toContain("# Project Memory");
expect(content).toContain("## Decisions");
});
it("creates the .fusion directory if missing", async () => {
expect(existsSync(join(testDir, ".fusion"))).toBe(false);
await ensureMemoryFile(testDir);
expect(existsSync(join(testDir, ".fusion"))).toBe(true);
});
it("does not overwrite existing content", async () => {
// Create initial file
await ensureMemoryFile(testDir);
// Manually edit the content
const { writeFile } = await import("node:fs/promises");
const customContent = "# Custom Memory\n\nMy custom content";
await writeFile(memoryPath, customContent, "utf-8");
// Ensure again — should NOT overwrite
const created = await ensureMemoryFile(testDir);
expect(created).toBe(false);
const content = await readProjectMemory(testDir);
expect(content).toBe(customContent);
});
it("returns false when file already exists with scaffold", async () => {
await ensureMemoryFile(testDir);
const created = await ensureMemoryFile(testDir);
expect(created).toBe(false);
});
it("is idempotent — multiple calls produce same result", async () => {
await ensureMemoryFile(testDir);
await ensureMemoryFile(testDir);
await ensureMemoryFile(testDir);
const content = await readProjectMemory(testDir);
expect(content).toContain("# Project Memory");
expect(content).toContain("## Decisions");
});
});
// ── readProjectMemory ─────────────────────────────────────────────
describe("readProjectMemory", () => {
it("returns empty string when file does not exist", async () => {
const content = await readProjectMemory(testDir);
expect(content).toBe("");
});
it("returns file content when file exists", async () => {
await ensureMemoryFile(testDir);
const content = await readProjectMemory(testDir);
expect(content).toContain("# Project Memory");
});
it("returns empty content when only the legacy memory file exists", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
await writeFile(legacyMemoryPath, "legacy content", "utf-8");
const content = await readProjectMemory(testDir);
expect(content).toBe("");
});
it("reads only from .fusion/memory/MEMORY.md, ignoring legacy path", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
const legacyContent = "# Legacy Content\n\nOld stuff";
await writeFile(legacyMemoryPath, legacyContent, "utf-8");
await mkdir(join(testDir, ".fusion", "memory"), { recursive: true });
const newContent = "# New Content\n\nNew stuff";
await writeFile(memoryPath, newContent, "utf-8");
const content = await readProjectMemory(testDir);
expect(content).toBe(newContent);
});
});
// ── buildTriageMemoryInstructions ─────────────────────────────────
describe("buildTriageMemoryInstructions", () => {
it("returns non-empty string", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions.length).toBeGreaterThan(0);
});
it("does not inject a raw memory file path by default", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
});
it("instructs agent to search memory first", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("instructs agent to incorporate learnings", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions).toMatch(/incorporate.*learning|reference.*pattern/i);
});
});
// ── buildExecutionMemoryInstructions ──────────────────────────────
describe("buildExecutionMemoryInstructions", () => {
it("returns non-empty string", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions.length).toBeGreaterThan(0);
});
it("does not inject a raw memory file path by default", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
});
it("instructs agent to search memory at start", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).toMatch(/start of execution/i);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("instructs agent to selectively write learnings at end", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).toMatch(/end of execution|before calling.*task_done/i);
// Should mention selective/skip behavior, not just append
expect(instructions).toMatch(/skip.*memory.*update|selectively|durable.*learnings/i);
});
it("instructs agent to skip when nothing durable was learned", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
// Should explicitly allow skipping when nothing durable was learned
expect(instructions).toMatch(/skip.*memory.*update|nothing durable|if nothing/i);
});
it("instructs agent to avoid task-specific trivia", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
// Should explicitly forbid task-specific trivia
expect(instructions).toMatch(/avoid.*trivia|task-specific.*trivia|per-task.*log|changelog/i);
});
it("allows editing/consolidating existing entries", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
// Should allow consolidation/editing, not forbid it
expect(instructions).toMatch(/consolidate|update.*refine.*existing|edit.*existing/i);
});
it("keeps qmd default path-agnostic", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).not.toContain("`.fusion/memory/MEMORY.md`");
});
});
// ── ensureMemoryFileWithBackend ─────────────────────────────────────
describe("ensureMemoryFileWithBackend", () => {
it("creates memory file with default backend when memory does not exist", async () => {
// Ensure clean state - create .fusion dir if needed
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
expect(existsSync(memoryPath)).toBe(false);
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(getDefaultMemoryScaffold());
});
it("does not overwrite existing memory content", async () => {
// Create initial file with custom content
await ensureMemoryFile(testDir);
const customContent = "# Custom Memory\n\nMy custom content";
await writeFile(memoryPath, customContent, "utf-8");
// Ensure again with backend - should NOT overwrite
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(false);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(customContent);
});
it("initializes canonical long-term memory when only legacy file exists", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
await writeFile(legacyMemoryPath, "# Legacy\n\nUser content", "utf-8");
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(getDefaultMemoryScaffold());
});
it("returns false when file already exists", async () => {
await ensureMemoryFile(testDir);
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(false);
});
it("works with file backend type in settings", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "file" };
const created = await ensureMemoryFileWithBackend(testDir, settings);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
expect(readFileSync(memoryPath, "utf-8")).toBe(getDefaultMemoryScaffold());
});
it("does not throw for readonly backend (non-fatal bootstrap)", async () => {
// Ensure .fusion dir exists but no memory file
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "readonly" };
// Should not throw - readonly backend is non-fatal during bootstrap
const result = await ensureMemoryFileWithBackend(testDir, settings);
// Should return false since readonly can't write
expect(result).toBe(false);
});
it("creates memory file with QMD backend when memory does not exist", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
expect(existsSync(memoryPath)).toBe(false);
const settings = { memoryBackendType: "qmd" };
const created = await ensureMemoryFileWithBackend(testDir, settings);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(getDefaultMemoryScaffold());
});
it("QMD ensureMemoryFileWithBackend is idempotent and does not overwrite", async () => {
// Create memory via QMD backend
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "qmd" };
const firstCreated = await ensureMemoryFileWithBackend(testDir, settings);
expect(firstCreated).toBe(true);
// Manually edit the content
const customContent = "# Custom Memory\n\nI edited this content";
await writeFile(memoryPath, customContent, "utf-8");
// Call ensure again - should NOT overwrite
const secondCreated = await ensureMemoryFileWithBackend(testDir, settings);
expect(secondCreated).toBe(false);
// Content should still be the custom content
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(customContent);
});
});
// ── readProjectMemoryWithBackend ─────────────────────────────────────
describe("readProjectMemoryWithBackend", () => {
it("returns empty string when memory does not exist", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
expect(existsSync(memoryPath)).toBe(false);
const content = await readProjectMemoryWithBackend(testDir);
expect(content).toBe("");
});
it("returns memory content when file exists", async () => {
await ensureMemoryFile(testDir);
const content = await readProjectMemoryWithBackend(testDir);
expect(content).toContain("# Project Memory");
});
it("returns custom content when file has been edited", async () => {
await ensureMemoryFile(testDir);
const customContent = "# Custom Memory\n\nSome custom content";
await writeFile(memoryPath, customContent, "utf-8");
const content = await readProjectMemoryWithBackend(testDir);
expect(content).toBe(customContent);
});
it("works with file backend type in settings", async () => {
await ensureMemoryFile(testDir);
const settings = { memoryBackendType: "file" };
const content = await readProjectMemoryWithBackend(testDir, settings);
expect(content).toContain("# Project Memory");
});
it("returns empty string for readonly backend", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "readonly" };
const content = await readProjectMemoryWithBackend(testDir, settings);
// Readonly backend always returns empty content
expect(content).toBe("");
});
it("returns empty string on read error (graceful degradation)", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "nonexistent" };
// Unknown backend should fall back gracefully
const content = await readProjectMemoryWithBackend(testDir, settings);
expect(content).toBe("");
});
it("returns memory content when using QMD backend", async () => {
// Create the memory file directly (simulating prior creation)
await mkdir(join(testDir, ".fusion", "memory"), { recursive: true });
await writeFile(memoryPath, "# QMD Memory\n\nSome content", "utf-8");
const settings = { memoryBackendType: "qmd" };
const content = await readProjectMemoryWithBackend(testDir, settings);
expect(content).toBe("# QMD Memory\n\nSome content");
});
});
// ── Backend-aware bootstrap integration ─────────────────────────────
describe("backend-aware bootstrap integration", () => {
it("idempotent bootstrap preserves user edits regardless of backend", async () => {
// Create file with default backend
await ensureMemoryFile(testDir);
// Edit the content
const customContent = "# User Edit\n\nI modified this";
await writeFile(memoryPath, customContent, "utf-8");
// Bootstrap again with different backends - none should overwrite
await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "file" });
expect(readFileSync(memoryPath, "utf-8")).toBe(customContent);
// Readonly should also preserve (even though it can't write)
await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "readonly" });
expect(readFileSync(memoryPath, "utf-8")).toBe(customContent);
});
it("backend selection is honored for new memory creation with file backend", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
// Create with file backend - should work reliably
const created = await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "file" });
expect(created).toBe(true);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(getDefaultMemoryScaffold());
});
});
// ── resolveMemoryInstructionContext ─────────────────────────────────────
describe("resolveMemoryInstructionContext", () => {
it("returns qmd backend context by default", () => {
const ctx = resolveMemoryInstructionContext();
expect(ctx.backendType).toBe("qmd");
expect(ctx.backendName).toBe("QMD (Quantized Memory Distillation)");
expect(ctx.capabilities.readable).toBe(true);
expect(ctx.capabilities.writable).toBe(true);
expect(ctx.instructionPathHint).toBeNull();
});
it("returns file backend context when explicitly set", () => {
const ctx = resolveMemoryInstructionContext({ memoryBackendType: "file" });
expect(ctx.backendType).toBe("file");
expect(ctx.instructionPathHint).toBe(".fusion/memory/MEMORY.md");
});
it("returns readonly backend context", () => {
const ctx = resolveMemoryInstructionContext({ memoryBackendType: "readonly" });
expect(ctx.backendType).toBe("readonly");
expect(ctx.backendName).toBe("Read-Only");
expect(ctx.capabilities.readable).toBe(true);
expect(ctx.capabilities.writable).toBe(false);
expect(ctx.instructionPathHint).toBeNull();
});
it("returns qmd backend context", () => {
const ctx = resolveMemoryInstructionContext({ memoryBackendType: "qmd" });
expect(ctx.backendType).toBe("qmd");
expect(ctx.backendName).toBe("QMD (Quantized Memory Distillation)");
expect(ctx.capabilities.readable).toBe(true);
expect(ctx.capabilities.writable).toBe(true);
expect(ctx.instructionPathHint).toBeNull();
});
it("returns qmd backend for unknown backend type", () => {
const ctx = resolveMemoryInstructionContext({ memoryBackendType: "unknown" });
expect(ctx.backendType).toBe("qmd");
expect(ctx.instructionPathHint).toBeNull();
});
});
// ── Backend-aware buildTriageMemoryInstructions ─────────────────────────────────
describe("buildTriageMemoryInstructions with backend settings", () => {
it("includes .fusion/memory/MEMORY.md for file backend", () => {
const settings = { memoryBackendType: "file" };
const instructions = buildTriageMemoryInstructions(testDir, settings);
expect(instructions).toContain(".fusion/memory/MEMORY.md");
expect(instructions).toContain("## Project Memory");
});
it("includes read-only wording for readonly backend without write directives", () => {
const settings = { memoryBackendType: "readonly" };
const instructions = buildTriageMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
// Should NOT contain write/update directives
expect(instructions).not.toMatch(/write|update/i);
// Should NOT contain the specific file path
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
// Should instruct to consult memory
expect(instructions).toMatch(/consult.*memory|memory.*context/i);
});
it("does not include .fusion/memory/MEMORY.md for qmd backend", () => {
const settings = { memoryBackendType: "qmd" };
const instructions = buildTriageMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
// QMD should NOT unconditionally reference .fusion/memory/MEMORY.md
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("QMD triage instructions completeness - contains consult guidance", () => {
const settings = { memoryBackendType: "qmd" };
const instructions = buildTriageMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
expect(instructions).toContain("memory_search");
});
it("does not include .fusion/memory/MEMORY.md for non-file backends without instructionPathHint", () => {
const settings = { memoryBackendType: "some-custom-backend" };
const instructions = buildTriageMemoryInstructions(testDir, settings);
expect(instructions).toContain("memory_search");
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
});
it("defaults to qmd guidance when settings are omitted", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
});
});
// ── Backend-aware buildExecutionMemoryInstructions ─────────────────────────────────
describe("buildExecutionMemoryInstructions with backend settings", () => {
it("includes .fusion/memory/MEMORY.md for file backend", () => {
const settings = { memoryBackendType: "file" };
const instructions = buildExecutionMemoryInstructions(testDir, settings);
expect(instructions).toContain(".fusion/memory/MEMORY.md");
expect(instructions).toContain("## Project Memory");
// Should have write instructions
expect(instructions).toMatch(/end of execution|before calling.*task_done/i);
});
it("includes read-only wording for readonly backend without write directives", () => {
const settings = { memoryBackendType: "readonly" };
const instructions = buildExecutionMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
// Should NOT contain write/update directives
expect(instructions).not.toMatch(/write.*memory|update.*memory/i);
// Should NOT contain the specific file path
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
// Should instruct to consult memory at start
expect(instructions).toMatch(/consult.*memory/i);
});
it("does not include .fusion/memory/MEMORY.md for qmd backend", () => {
const settings = { memoryBackendType: "qmd" };
const instructions = buildExecutionMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
// QMD should NOT unconditionally reference .fusion/memory/MEMORY.md
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("QMD execution instructions completeness", () => {
const settings = { memoryBackendType: "qmd" };
const instructions = buildExecutionMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
expect(instructions).toContain("memory_search");
// Contains "end of execution" write guidance
expect(instructions).toMatch(/end of execution/i);
// Contains "skip" wording for when nothing durable learned
expect(instructions).toMatch(/skip.*memory.*update|nothing durable/i);
// Contains "avoid" / "trivia" guidance
expect(instructions).toMatch(/trivia|avoid/i);
});
it("defaults to qmd guidance when settings are omitted", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
expect(instructions).toMatch(/end of execution|before calling.*task_done/i);
});
it("readonly backend does not include format/formatting guidance", () => {
const settings = { memoryBackendType: "readonly" };
const instructions = buildExecutionMemoryInstructions(testDir, settings);
// Should NOT contain the format guidance section
expect(instructions).not.toContain("Format for additions");
expect(instructions).not.toContain("\\`- \\`");
});
});
describe("searchProjectMemory", () => {
it("uses qmd backend by default and searches all memory files", async () => {
const memoryDir = join(testDir, ".fusion", "memory");
await mkdir(memoryDir, { recursive: true });
const token = `qmdindexunique${Date.now()}`;
await writeFile(join(memoryDir, "DREAMS.md"), `# Dreams\n\n- The scheduler retries ${token} failures.`, "utf-8");
await writeFile(join(memoryDir, "MEMORY.md"), "# Memory\n\n- Durable API decisions live here.", "utf-8");
const results = await searchProjectMemory(testDir, { query: token, limit: 5 });
expect(results.length).toBeGreaterThan(0);
expect(results[0].backend).toBe("qmd");
expect(results.some((result) => result.path === ".fusion/memory/DREAMS.md")).toBe(true);
});
});
});

View File

@@ -0,0 +1,383 @@
import { describe, it, expect } from "vitest";
import {
PROMPT_KEY_CATALOG,
PromptKey,
PromptOverrideMap,
resolvePrompt,
resolveRolePrompts,
hasRoleOverrides,
getOverriddenKeys,
clearOverrides,
getPromptKeyMetadata,
getPromptKeysForRole,
isValidPromptKey,
isValidPromptOverrideMap,
assertValidPromptOverrideMap,
} from "../prompt-overrides.js";
describe("prompt-overrides", () => {
describe("PROMPT_KEY_CATALOG", () => {
it("should contain all expected prompt keys", () => {
const expectedKeys: PromptKey[] = [
"executor-welcome",
"executor-guardrails",
"executor-spawning",
"executor-completion",
"triage-welcome",
"triage-context",
"reviewer-verdict",
"merger-conflicts",
];
for (const key of expectedKeys) {
expect(PROMPT_KEY_CATALOG).toHaveProperty(key);
expect(PROMPT_KEY_CATALOG[key].key).toBe(key);
}
});
it("should have valid metadata for each key", () => {
for (const [key, meta] of Object.entries(PROMPT_KEY_CATALOG)) {
expect(meta.key).toBe(key);
expect(typeof meta.name).toBe("string");
expect(meta.name.length).toBeGreaterThan(0);
expect(Array.isArray(meta.roles)).toBe(true);
expect(meta.roles.length).toBeGreaterThan(0);
expect(typeof meta.description).toBe("string");
expect(typeof meta.defaultContent).toBe("string");
expect(meta.defaultContent.length).toBeGreaterThan(0);
}
});
it("should have appropriate roles for each key", () => {
// Executor keys should only be for executor role
expect(PROMPT_KEY_CATALOG["executor-welcome"].roles).toContain("executor");
expect(PROMPT_KEY_CATALOG["executor-guardrails"].roles).toContain("executor");
expect(PROMPT_KEY_CATALOG["executor-spawning"].roles).toContain("executor");
expect(PROMPT_KEY_CATALOG["executor-completion"].roles).toContain("executor");
// Triage keys should only be for triage role
expect(PROMPT_KEY_CATALOG["triage-welcome"].roles).toContain("triage");
expect(PROMPT_KEY_CATALOG["triage-context"].roles).toContain("triage");
// Reviewer and merger keys
expect(PROMPT_KEY_CATALOG["reviewer-verdict"].roles).toContain("reviewer");
expect(PROMPT_KEY_CATALOG["merger-conflicts"].roles).toContain("merger");
});
});
describe("getPromptKeyMetadata", () => {
it("should return metadata for valid keys", () => {
const meta = getPromptKeyMetadata("executor-welcome");
expect(meta).toBeDefined();
expect(meta?.key).toBe("executor-welcome");
expect(meta?.name).toBe("Executor Welcome");
});
it("should return undefined for invalid keys", () => {
expect(getPromptKeyMetadata("invalid-key" as PromptKey)).toBeUndefined();
expect(getPromptKeyMetadata("" as PromptKey)).toBeUndefined();
});
});
describe("getPromptKeysForRole", () => {
it("should return all keys for executor role", () => {
const keys = getPromptKeysForRole("executor");
expect(keys).toHaveLength(8);
expect(keys.map((k) => k.key)).toContain("executor-welcome");
expect(keys.map((k) => k.key)).toContain("executor-guardrails");
expect(keys.map((k) => k.key)).toContain("executor-spawning");
expect(keys.map((k) => k.key)).toContain("executor-completion");
expect(keys.map((k) => k.key)).toContain("agent-generation-system");
expect(keys.map((k) => k.key)).toContain("workflow-step-refine");
expect(keys.map((k) => k.key)).toContain("subtask-breakdown-system");
expect(keys.map((k) => k.key)).toContain("ai-refine-system");
});
it("should return all keys for triage role", () => {
const keys = getPromptKeysForRole("triage");
expect(keys).toHaveLength(4);
expect(keys.map((k) => k.key)).toContain("triage-welcome");
expect(keys.map((k) => k.key)).toContain("triage-context");
expect(keys.map((k) => k.key)).toContain("planning-system");
expect(keys.map((k) => k.key)).toContain("mission-interview-system");
});
it("should return single key for reviewer role", () => {
const keys = getPromptKeysForRole("reviewer");
expect(keys).toHaveLength(1);
expect(keys[0].key).toBe("reviewer-verdict");
});
it("should return single key for merger role", () => {
const keys = getPromptKeysForRole("merger");
expect(keys).toHaveLength(1);
expect(keys[0].key).toBe("merger-conflicts");
});
});
describe("resolvePrompt", () => {
it("should return override when present and non-empty", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom executor welcome",
};
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe("Custom executor welcome");
});
it("should return default when no override present", () => {
const overrides: PromptOverrideMap = {};
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe(defaultContent);
});
it("should return default when override is empty string", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "",
};
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe(defaultContent);
});
it("should return default when override is undefined", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": undefined,
};
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", overrides);
expect(result).toBe(defaultContent);
});
it("should return default when overrides is undefined", () => {
const defaultContent = PROMPT_KEY_CATALOG["executor-welcome"].defaultContent;
const result = resolvePrompt("executor-welcome", undefined);
expect(result).toBe(defaultContent);
});
it("should return default for unrecognized key", () => {
const result = resolvePrompt("invalid-key" as PromptKey, {});
expect(result).toBe("");
});
it("should return empty string for unrecognized key with no defaults", () => {
const result = resolvePrompt("invalid-key" as PromptKey, undefined);
expect(result).toBe("");
});
});
describe("resolveRolePrompts", () => {
it("should resolve all prompts for executor role", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom welcome",
};
const result = resolveRolePrompts("executor", overrides);
// Custom override
expect(result["executor-welcome"]).toBe("Custom welcome");
// Defaults for others
expect(result["executor-guardrails"]).toBe(PROMPT_KEY_CATALOG["executor-guardrails"].defaultContent);
expect(result["executor-spawning"]).toBe(PROMPT_KEY_CATALOG["executor-spawning"].defaultContent);
expect(result["executor-completion"]).toBe(PROMPT_KEY_CATALOG["executor-completion"].defaultContent);
});
it("should resolve all prompts for triage role", () => {
const overrides: PromptOverrideMap = {
"triage-welcome": "Custom triage welcome",
};
const result = resolveRolePrompts("triage", overrides);
expect(result["triage-welcome"]).toBe("Custom triage welcome");
expect(result["triage-context"]).toBe(PROMPT_KEY_CATALOG["triage-context"].defaultContent);
});
it("should return all defaults when no overrides", () => {
const result = resolveRolePrompts("executor", undefined);
for (const [key, content] of Object.entries(result)) {
expect(content).toBe(PROMPT_KEY_CATALOG[key as PromptKey].defaultContent);
}
});
it("should return empty object for roles with no prompts", () => {
// Scheduler and custom roles have no defined prompts
const result = resolveRolePrompts("scheduler" as any, {});
expect(result).toEqual({});
});
});
describe("hasRoleOverrides", () => {
it("should return true when at least one override is set", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
};
expect(hasRoleOverrides("executor", overrides)).toBe(true);
});
it("should return false when no overrides set", () => {
expect(hasRoleOverrides("executor", {})).toBe(false);
expect(hasRoleOverrides("executor", undefined)).toBe(false);
});
it("should return false when overrides are empty strings", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "",
};
expect(hasRoleOverrides("executor", overrides)).toBe(false);
});
it("should return false when only other role has overrides", () => {
const overrides: PromptOverrideMap = {
"triage-welcome": "Custom",
};
expect(hasRoleOverrides("executor", overrides)).toBe(false);
});
});
describe("getOverriddenKeys", () => {
it("should return keys with non-empty overrides", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
"triage-welcome": "Custom triage",
"merger-conflicts": "",
};
const result = getOverriddenKeys(overrides);
expect(result).toContain("executor-welcome");
expect(result).toContain("triage-welcome");
expect(result).not.toContain("merger-conflicts");
});
it("should return empty array for undefined", () => {
expect(getOverriddenKeys(undefined)).toEqual([]);
});
it("should return empty array for empty object", () => {
expect(getOverriddenKeys({})).toEqual([]);
});
});
describe("clearOverrides", () => {
it("should remove specified keys", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
"executor-guardrails": "Custom guardrails",
};
const result = clearOverrides(overrides, ["executor-welcome"]);
expect(result).not.toHaveProperty("executor-welcome");
expect(result?.["executor-guardrails"]).toBe("Custom guardrails");
});
it("should return undefined when all keys are cleared", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
};
const result = clearOverrides(overrides, ["executor-welcome"]);
expect(result).toBeUndefined();
});
it("should handle undefined input", () => {
const result = clearOverrides(undefined, ["executor-welcome"]);
expect(result).toBeUndefined();
});
it("should preserve other keys when clearing", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Custom",
"triage-welcome": "Custom triage",
};
const result = clearOverrides(overrides, ["executor-welcome"]);
expect(result?.["triage-welcome"]).toBe("Custom triage");
});
});
describe("isValidPromptKey", () => {
it("should return true for valid keys", () => {
expect(isValidPromptKey("executor-welcome")).toBe(true);
expect(isValidPromptKey("merger-conflicts")).toBe(true);
});
it("should return false for invalid keys", () => {
expect(isValidPromptKey("invalid")).toBe(false);
expect(isValidPromptKey("")).toBe(false);
expect(isValidPromptKey(123 as any)).toBe(false);
expect(isValidPromptKey(null)).toBe(false);
});
});
describe("isValidPromptOverrideMap", () => {
it("should return true for valid maps", () => {
expect(isValidPromptOverrideMap({})).toBe(true);
expect(isValidPromptOverrideMap({ "executor-welcome": "Custom" })).toBe(true);
expect(isValidPromptOverrideMap({ "executor-welcome": undefined })).toBe(true);
expect(isValidPromptOverrideMap({ "executor-welcome": "", "triage-welcome": "Custom" })).toBe(true);
});
it("should return false for invalid values", () => {
expect(isValidPromptOverrideMap(null)).toBe(false);
expect(isValidPromptOverrideMap("string")).toBe(false);
expect(isValidPromptOverrideMap(123)).toBe(false);
expect(isValidPromptOverrideMap({ "invalid-key": "value" })).toBe(false);
expect(isValidPromptOverrideMap({ "executor-welcome": 123 as any })).toBe(false);
});
});
describe("assertValidPromptOverrideMap", () => {
it("should not throw for valid maps", () => {
expect(() => assertValidPromptOverrideMap({})).not.toThrow();
expect(() => assertValidPromptOverrideMap({ "executor-welcome": "Custom" })).not.toThrow();
});
it("should throw for invalid maps", () => {
expect(() => assertValidPromptOverrideMap(null)).toThrow();
expect(() => assertValidPromptOverrideMap({ "invalid": "value" })).toThrow();
});
});
describe("fallback behavior", () => {
it("should always return a string (never undefined or throw)", () => {
// All valid keys should return strings
for (const key of Object.keys(PROMPT_KEY_CATALOG) as PromptKey[]) {
expect(typeof resolvePrompt(key, undefined)).toBe("string");
expect(typeof resolvePrompt(key, {})).toBe("string");
expect(typeof resolvePrompt(key, { [key]: undefined })).toBe("string");
}
});
it("should handle partial overrides gracefully", () => {
const overrides: PromptOverrideMap = {
"executor-welcome": "Only this is overridden",
// Other keys intentionally not set
};
// Should not throw
const result = resolveRolePrompts("executor", overrides);
// Overridden key should have custom value
expect(result["executor-welcome"]).toBe("Only this is overridden");
// Other keys should have defaults
expect(result["executor-guardrails"]).toBe(PROMPT_KEY_CATALOG["executor-guardrails"].defaultContent);
expect(result["executor-spawning"]).toBe(PROMPT_KEY_CATALOG["executor-spawning"].defaultContent);
expect(result["executor-completion"]).toBe(PROMPT_KEY_CATALOG["executor-completion"].defaultContent);
});
});
});

View File

@@ -0,0 +1,511 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ReflectionStore } from "../reflection-store.js";
import type { AgentReflection, ReflectionTrigger } from "../types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-reflection-store-test-"));
}
function makeReflection(
agentId: string,
overrides: Partial<AgentReflection> = {},
): AgentReflection {
return {
id: `reflection-${Math.random().toString(16).slice(2, 10)}`,
agentId,
timestamp: new Date().toISOString(),
trigger: "manual",
metrics: {},
insights: [],
suggestedImprovements: [],
summary: "summary",
...overrides,
};
}
describe("ReflectionStore", () => {
let rootDir: string;
let store: ReflectionStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new ReflectionStore({ rootDir });
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
describe("init", () => {
it("creates the agents/ directory inside rootDir", async () => {
const agentsDir = join(rootDir, "agents");
expect(existsSync(agentsDir)).toBe(true);
});
it("is idempotent", async () => {
await store.init();
await store.init();
expect(existsSync(join(rootDir, "agents"))).toBe(true);
});
});
describe("createReflection", () => {
it("creates a reflection with expected fields", async () => {
const reflection = await store.createReflection({
agentId: "agent-001",
trigger: "post-task",
triggerDetail: "after task FN-042 completion",
taskId: "FN-042",
metrics: { tasksCompleted: 1, avgDurationMs: 4200 },
insights: ["Strong planning reduced context switching"],
suggestedImprovements: ["Improve edge-case validation"],
summary: "Solid execution with one validation gap.",
});
expect(reflection.id).toMatch(/^reflection-/);
expect(reflection.agentId).toBe("agent-001");
expect(reflection.trigger).toBe("post-task");
expect(reflection.triggerDetail).toBe("after task FN-042 completion");
expect(reflection.taskId).toBe("FN-042");
expect(reflection.metrics).toEqual({ tasksCompleted: 1, avgDurationMs: 4200 });
expect(reflection.insights).toEqual(["Strong planning reduced context switching"]);
expect(reflection.suggestedImprovements).toEqual(["Improve edge-case validation"]);
expect(reflection.summary).toBe("Solid execution with one validation gap.");
expect(Number.isNaN(Date.parse(reflection.timestamp))).toBe(false);
});
it("generates unique reflection IDs", async () => {
const first = await store.createReflection({
agentId: "agent-001",
trigger: "manual",
metrics: {},
insights: [],
suggestedImprovements: [],
summary: "first",
});
const second = await store.createReflection({
agentId: "agent-001",
trigger: "manual",
metrics: {},
insights: [],
suggestedImprovements: [],
summary: "second",
});
expect(first.id).toMatch(/^reflection-/);
expect(second.id).toMatch(/^reflection-/);
expect(first.id).not.toBe(second.id);
});
it("appends reflections to the JSONL log", async () => {
const first = await store.createReflection({
agentId: "agent-append",
trigger: "manual",
metrics: {},
insights: ["first"],
suggestedImprovements: ["first improvement"],
summary: "first",
});
const second = await store.createReflection({
agentId: "agent-append",
trigger: "periodic",
metrics: {},
insights: ["second"],
suggestedImprovements: ["second improvement"],
summary: "second",
});
const filePath = join(rootDir, "agents", "agent-append-reflections.jsonl");
const lines = readFileSync(filePath, "utf-8").trim().split("\n");
expect(lines).toHaveLength(2);
expect((JSON.parse(lines[0]) as AgentReflection).id).toBe(first.id);
expect((JSON.parse(lines[1]) as AgentReflection).id).toBe(second.id);
});
it("emits reflection:created event", async () => {
const handler = vi.fn();
store.on("reflection:created", handler);
const reflection = await store.createReflection({
agentId: "agent-events",
trigger: "manual",
metrics: {},
insights: [],
suggestedImprovements: [],
summary: "event",
});
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(reflection);
});
it("throws when agentId is empty", async () => {
await expect(
store.createReflection({
agentId: " ",
trigger: "manual",
metrics: {},
insights: [],
suggestedImprovements: [],
summary: "invalid",
}),
).rejects.toThrow("agentId is required");
});
});
describe("getReflections", () => {
it("returns reflections in reverse chronological (newest-first) order", async () => {
const first = await store.createReflection({
agentId: "agent-order",
trigger: "manual",
metrics: {},
insights: ["one"],
suggestedImprovements: [],
summary: "one",
});
const second = await store.createReflection({
agentId: "agent-order",
trigger: "manual",
metrics: {},
insights: ["two"],
suggestedImprovements: [],
summary: "two",
});
const third = await store.createReflection({
agentId: "agent-order",
trigger: "manual",
metrics: {},
insights: ["three"],
suggestedImprovements: [],
summary: "three",
});
const reflections = await store.getReflections("agent-order");
expect(reflections.map((reflection) => reflection.id)).toEqual([
third.id,
second.id,
first.id,
]);
});
it("respects the limit parameter", async () => {
for (let i = 0; i < 4; i += 1) {
await store.createReflection({
agentId: "agent-limit",
trigger: "manual",
metrics: {},
insights: [`insight-${i}`],
suggestedImprovements: [],
summary: `summary-${i}`,
});
}
const reflections = await store.getReflections("agent-limit", 2);
expect(reflections).toHaveLength(2);
});
it("returns an empty array when no reflection file exists", async () => {
const reflections = await store.getReflections("agent-missing");
expect(reflections).toEqual([]);
});
it("skips malformed JSONL lines gracefully", async () => {
const agentId = "agent-malformed";
const filePath = join(rootDir, "agents", `${agentId}-reflections.jsonl`);
const goodOne = makeReflection(agentId, { id: "reflection-good-1", summary: "good-1" });
const goodTwo = makeReflection(agentId, { id: "reflection-good-2", summary: "good-2" });
writeFileSync(
filePath,
`${JSON.stringify(goodOne)}\n{not-json\n${JSON.stringify(goodTwo)}\n`,
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const reflections = await store.getReflections(agentId, 10);
expect(reflections).toHaveLength(2);
expect(reflections.map((reflection) => reflection.id)).toEqual([
"reflection-good-2",
"reflection-good-1",
]);
expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore();
});
it("returns all reflections when limit exceeds total count", async () => {
await store.createReflection({
agentId: "agent-all",
trigger: "manual",
metrics: {},
insights: ["one"],
suggestedImprovements: [],
summary: "one",
});
await store.createReflection({
agentId: "agent-all",
trigger: "manual",
metrics: {},
insights: ["two"],
suggestedImprovements: [],
summary: "two",
});
const reflections = await store.getReflections("agent-all", 100);
expect(reflections).toHaveLength(2);
});
});
describe("getLatestReflection", () => {
it("returns the most recent reflection", async () => {
await store.createReflection({
agentId: "agent-latest",
trigger: "manual",
metrics: {},
insights: ["older"],
suggestedImprovements: [],
summary: "older",
});
const newest = await store.createReflection({
agentId: "agent-latest",
trigger: "manual",
metrics: {},
insights: ["newer"],
suggestedImprovements: [],
summary: "newer",
});
const latest = await store.getLatestReflection("agent-latest");
expect(latest?.id).toBe(newest.id);
});
it("returns null when no reflections exist", async () => {
const latest = await store.getLatestReflection("agent-empty");
expect(latest).toBeNull();
});
});
describe("getPerformanceSummary", () => {
it("aggregates metrics and derives strengths/weaknesses", async () => {
await store.createReflection({
agentId: "agent-summary",
trigger: "post-task",
taskId: "FN-100",
metrics: {
tasksCompleted: 2,
tasksFailed: 1,
avgDurationMs: 1000,
commonErrors: ["timeout", "validation"],
},
insights: ["Great at debugging", "Clear task decomposition"],
suggestedImprovements: ["Handle retries better", "Improve test coverage"],
summary: "Older reflection",
});
await store.createReflection({
agentId: "agent-summary",
trigger: "post-task",
taskId: "FN-101",
metrics: {
tasksCompleted: 3,
tasksFailed: 0,
avgDurationMs: 3000,
commonErrors: ["timeout", "rate limit"],
},
insights: ["Great at debugging", "Strong communication"],
suggestedImprovements: ["Improve test coverage", "Tune model temperature"],
summary: "Newer reflection",
});
const summary = await store.getPerformanceSummary("agent-summary");
expect(summary.agentId).toBe("agent-summary");
expect(summary.totalTasksCompleted).toBe(5);
expect(summary.totalTasksFailed).toBe(1);
expect(summary.avgDurationMs).toBe(2000);
expect(summary.successRate).toBeCloseTo(5 / 6, 10);
expect(summary.commonErrors).toEqual(["timeout", "rate limit", "validation"]);
expect(summary.strengths).toEqual([
"Great at debugging",
"Strong communication",
"Clear task decomposition",
]);
expect(summary.weaknesses).toEqual([
"Improve test coverage",
"Tune model temperature",
"Handle retries better",
]);
expect(summary.recentReflectionCount).toBe(2);
expect(Number.isNaN(Date.parse(summary.computedAt))).toBe(false);
});
it("returns a zeroed summary when no reflections exist", async () => {
const summary = await store.getPerformanceSummary("agent-none");
expect(summary).toMatchObject({
agentId: "agent-none",
totalTasksCompleted: 0,
totalTasksFailed: 0,
avgDurationMs: 0,
successRate: 0,
commonErrors: [],
strengths: [],
weaknesses: [],
recentReflectionCount: 0,
});
expect(Number.isNaN(Date.parse(summary.computedAt))).toBe(false);
});
it("excludes reflections outside the default 7-day window", async () => {
const agentId = "agent-window-default";
const filePath = join(rootDir, "agents", `${agentId}-reflections.jsonl`);
const now = Date.now();
const oldReflection = makeReflection(agentId, {
id: "reflection-old",
timestamp: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
metrics: { tasksCompleted: 10 },
});
const recentReflection = makeReflection(agentId, {
id: "reflection-recent",
timestamp: new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(),
metrics: { tasksCompleted: 2, tasksFailed: 1 },
});
writeFileSync(filePath, `${JSON.stringify(oldReflection)}\n${JSON.stringify(recentReflection)}\n`, "utf-8");
const summary = await store.getPerformanceSummary(agentId);
expect(summary.totalTasksCompleted).toBe(2);
expect(summary.totalTasksFailed).toBe(1);
expect(summary.recentReflectionCount).toBe(1);
});
it("respects a custom windowMs option", async () => {
const agentId = "agent-window-custom";
const filePath = join(rootDir, "agents", `${agentId}-reflections.jsonl`);
const now = Date.now();
const older = makeReflection(agentId, {
id: "reflection-older",
timestamp: new Date(now - 10_000).toISOString(),
metrics: { tasksCompleted: 1 },
});
const newest = makeReflection(agentId, {
id: "reflection-newest",
timestamp: new Date(now - 200).toISOString(),
metrics: { tasksCompleted: 2 },
});
writeFileSync(filePath, `${JSON.stringify(older)}\n${JSON.stringify(newest)}\n`, "utf-8");
const summary = await store.getPerformanceSummary(agentId, { windowMs: 1000 });
expect(summary.totalTasksCompleted).toBe(2);
expect(summary.recentReflectionCount).toBe(1);
});
it("emits reflection:summary-computed", async () => {
const handler = vi.fn();
store.on("reflection:summary-computed", handler);
const summary = await store.getPerformanceSummary("agent-summary-event");
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(summary);
});
});
describe("deleteReflections", () => {
it("removes the agent reflection file", async () => {
const agentId = "agent-delete";
await store.createReflection({
agentId,
trigger: "manual",
metrics: {},
insights: [],
suggestedImprovements: [],
summary: "to delete",
});
const filePath = join(rootDir, "agents", `${agentId}-reflections.jsonl`);
expect(existsSync(filePath)).toBe(true);
await store.deleteReflections(agentId);
expect(existsSync(filePath)).toBe(false);
});
it("no-ops when the file does not exist", async () => {
await expect(store.deleteReflections("agent-missing-delete")).resolves.toBeUndefined();
});
});
describe("concurrency", () => {
it("allows concurrent createReflection calls for the same agent", async () => {
const agentId = "agent-concurrent";
const triggers: ReflectionTrigger[] = ["manual", "periodic", "post-task", "user-requested"];
await Promise.all(
Array.from({ length: 25 }, (_, i) =>
store.createReflection({
agentId,
trigger: triggers[i % triggers.length],
metrics: { tasksCompleted: 1 },
insights: [`insight-${i}`],
suggestedImprovements: [`improvement-${i}`],
summary: `summary-${i}`,
}),
),
);
const reflections = await store.getReflections(agentId, 100);
expect(reflections).toHaveLength(25);
const filePath = join(rootDir, "agents", `${agentId}-reflections.jsonl`);
const lines = readFileSync(filePath, "utf-8").trim().split("\n");
expect(lines).toHaveLength(25);
});
});
describe("append-only behavior", () => {
it("preserves all reflections for the same agent in file order", async () => {
const first = await store.createReflection({
agentId: "agent-append-order",
trigger: "manual",
metrics: {},
insights: ["first"],
suggestedImprovements: [],
summary: "first",
});
const second = await store.createReflection({
agentId: "agent-append-order",
trigger: "manual",
metrics: {},
insights: ["second"],
suggestedImprovements: [],
summary: "second",
});
const third = await store.createReflection({
agentId: "agent-append-order",
trigger: "manual",
metrics: {},
insights: ["third"],
suggestedImprovements: [],
summary: "third",
});
const filePath = join(rootDir, "agents", "agent-append-order-reflections.jsonl");
const ids = readFileSync(filePath, "utf-8")
.trim()
.split("\n")
.map((line) => (JSON.parse(line) as AgentReflection).id);
expect(ids).toEqual([first.id, second.id, third.id]);
});
});
});

View File

@@ -0,0 +1,445 @@
/**
* Tests for roadmap handoff mapping helpers.
*/
import { describe, it, expect } from "vitest";
import {
mapFeatureToTaskHandoff,
mapRoadmapToMissionHandoff,
mapRoadmapWithHierarchyToMissionHandoff,
mapAllFeaturesToTaskHandoffs,
} from "../roadmap-handoff.js";
import { normalizeRoadmapMilestoneOrder } from "../roadmap-ordering.js";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapWithHierarchy,
RoadmapFeatureTaskPlanningHandoff,
RoadmapMissionPlanningHandoff,
} from "../roadmap-types.js";
// ── Test Fixtures ─────────────────────────────────────────────────────────────
function createRoadmap(overrides: Partial<Roadmap> = {}): Roadmap {
return {
id: "RM-001",
title: "Test Roadmap",
description: "A test roadmap",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
};
}
function createMilestone(id: string, roadmapId: string, orderIndex: number, overrides: Partial<RoadmapMilestone> = {}): RoadmapMilestone {
return {
id,
roadmapId,
title: `Milestone ${id}`,
description: `Description for ${id}`,
orderIndex,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
};
}
function createFeature(id: string, milestoneId: string, orderIndex: number, overrides: Partial<RoadmapFeature> = {}): RoadmapFeature {
return {
id,
milestoneId,
title: `Feature ${id}`,
description: `Description for ${id}`,
orderIndex,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
};
}
// ── Tests for mapFeatureToTaskHandoff ─────────────────────────────────────────
describe("mapFeatureToTaskHandoff", () => {
it("maps a feature to a task planning handoff with all fields", () => {
const roadmap = createRoadmap();
const milestone = createMilestone("MS-001", "RM-001", 0);
const feature = createFeature("F-001", "MS-001", 0);
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
expect(handoff.title).toBe("Feature F-001");
expect(handoff.description).toBe("Description for F-001");
expect(handoff.source.roadmapId).toBe("RM-001");
expect(handoff.source.milestoneId).toBe("MS-001");
expect(handoff.source.featureId).toBe("F-001");
expect(handoff.source.roadmapTitle).toBe("Test Roadmap");
expect(handoff.source.milestoneTitle).toBe("Milestone MS-001");
expect(handoff.source.milestoneOrderIndex).toBe(0);
expect(handoff.source.featureOrderIndex).toBe(0);
});
it("handles features without descriptions", () => {
const roadmap = createRoadmap();
const milestone = createMilestone("MS-001", "RM-001", 0);
const feature = createFeature("F-001", "MS-001", 0, { description: undefined });
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
expect(handoff.title).toBe("Feature F-001");
expect(handoff.description).toBeUndefined();
});
it("preserves exact IDs from source entities", () => {
const roadmap = createRoadmap({ id: "RM-SPECIAL-123" });
const milestone = createMilestone("RMS-SPECIAL-456", "RM-SPECIAL-123", 5);
const feature = createFeature("RF-SPECIAL-789", "RMS-SPECIAL-456", 3);
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
expect(handoff.source.roadmapId).toBe("RM-SPECIAL-123");
expect(handoff.source.milestoneId).toBe("RMS-SPECIAL-456");
expect(handoff.source.featureId).toBe("RF-SPECIAL-789");
});
});
// ── Tests for mapRoadmapToMissionHandoff ─────────────────────────────────────
describe("mapRoadmapToMissionHandoff", () => {
it("maps a roadmap with milestones and features to mission handoff", () => {
const roadmap = createRoadmap({ title: "Q1 Planning", description: "Quarterly goals" });
const milestones = [
createMilestone("MS-001", "RM-001", 0, { title: "Phase 1" }),
createMilestone("MS-002", "RM-001", 1, { title: "Phase 2" }),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 0, { title: "Auth Feature" }),
createFeature("F-002", "MS-001", 1, { title: "Dashboard Feature" }),
]],
["MS-002", [
createFeature("F-003", "MS-002", 0, { title: "Reporting Feature" }),
]],
]);
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(handoff.sourceRoadmapId).toBe("RM-001");
expect(handoff.title).toBe("Q1 Planning");
expect(handoff.description).toBe("Quarterly goals");
expect(handoff.milestones).toHaveLength(2);
// Verify milestone ordering
expect(handoff.milestones[0].title).toBe("Phase 1");
expect(handoff.milestones[0].orderIndex).toBe(0);
expect(handoff.milestones[1].title).toBe("Phase 2");
expect(handoff.milestones[1].orderIndex).toBe(1);
// Verify feature ordering within milestones
expect(handoff.milestones[0].features).toHaveLength(2);
expect(handoff.milestones[0].features[0].title).toBe("Auth Feature");
expect(handoff.milestones[0].features[0].orderIndex).toBe(0);
expect(handoff.milestones[0].features[1].title).toBe("Dashboard Feature");
expect(handoff.milestones[0].features[1].orderIndex).toBe(1);
expect(handoff.milestones[1].features).toHaveLength(1);
expect(handoff.milestones[1].features[0].title).toBe("Reporting Feature");
});
it("handles empty milestones array", () => {
const roadmap = createRoadmap();
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, [], featuresByMilestoneId);
expect(handoff.sourceRoadmapId).toBe("RM-001");
expect(handoff.milestones).toHaveLength(0);
});
it("handles milestones with empty features", () => {
const roadmap = createRoadmap();
const milestones = [
createMilestone("MS-001", "RM-001", 0),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(handoff.milestones).toHaveLength(1);
expect(handoff.milestones[0].features).toHaveLength(0);
});
it("normalizes deterministic ordering when order indices are out of sequence", () => {
const roadmap = createRoadmap();
// Simulate out-of-sequence order indices
const milestones = [
createMilestone("MS-001", "RM-001", 10), // Out of sequence
createMilestone("MS-002", "RM-001", 5), // Out of sequence
createMilestone("MS-003", "RM-001", 20), // Out of sequence
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
// Should be normalized to 0, 1, 2
expect(handoff.milestones[0].orderIndex).toBe(0);
expect(handoff.milestones[1].orderIndex).toBe(1);
expect(handoff.milestones[2].orderIndex).toBe(2);
});
});
// ── Tests for mapRoadmapWithHierarchyToMissionHandoff ────────────────────────
describe("mapRoadmapWithHierarchyToMissionHandoff", () => {
it("maps RoadmapWithHierarchy to mission handoff", () => {
const roadmapWithHierarchy: RoadmapWithHierarchy = {
id: "RM-001",
title: "Hierarchy Roadmap",
description: "With full hierarchy",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
milestones: [
{
...createMilestone("MS-001", "RM-001", 0, { title: "Alpha Phase" }),
features: [
createFeature("F-001", "MS-001", 0, { title: "Alpha Feature 1" }),
createFeature("F-002", "MS-001", 1, { title: "Alpha Feature 2" }),
],
},
{
...createMilestone("MS-002", "RM-001", 1, { title: "Beta Phase" }),
features: [
createFeature("F-003", "MS-002", 0, { title: "Beta Feature" }),
],
},
],
};
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
expect(handoff.sourceRoadmapId).toBe("RM-001");
expect(handoff.title).toBe("Hierarchy Roadmap");
expect(handoff.milestones).toHaveLength(2);
expect(handoff.milestones[0].title).toBe("Alpha Phase");
expect(handoff.milestones[0].features).toHaveLength(2);
expect(handoff.milestones[1].title).toBe("Beta Phase");
expect(handoff.milestones[1].features).toHaveLength(1);
});
it("handles empty milestone hierarchy", () => {
const roadmapWithHierarchy: RoadmapWithHierarchy = {
...createRoadmap(),
milestones: [],
};
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
expect(handoff.milestones).toHaveLength(0);
});
});
// ── Tests for mapAllFeaturesToTaskHandoffs ────────────────────────────────────
describe("mapAllFeaturesToTaskHandoffs", () => {
it("flattens all features from a roadmap into individual handoffs", () => {
const roadmap = createRoadmap();
const milestones = [
createMilestone("MS-001", "RM-001", 0),
createMilestone("MS-002", "RM-001", 1),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 0),
createFeature("F-002", "MS-001", 1),
]],
["MS-002", [
createFeature("F-003", "MS-002", 0),
]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(3);
expect(handoffs[0].source.featureId).toBe("F-001");
expect(handoffs[0].source.milestoneOrderIndex).toBe(0);
expect(handoffs[0].source.featureOrderIndex).toBe(0);
expect(handoffs[1].source.featureId).toBe("F-002");
expect(handoffs[1].source.milestoneOrderIndex).toBe(0);
expect(handoffs[1].source.featureOrderIndex).toBe(1);
expect(handoffs[2].source.featureId).toBe("F-003");
expect(handoffs[2].source.milestoneOrderIndex).toBe(1);
expect(handoffs[2].source.featureOrderIndex).toBe(0);
});
it("returns empty array when no milestones exist", () => {
const roadmap = createRoadmap();
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, [], featuresByMilestoneId);
expect(handoffs).toHaveLength(0);
});
it("returns empty array when milestones have no features", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(0);
});
it("preserves feature titles and descriptions", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 0, { title: "Core Feature", description: "Main functionality" }),
createFeature("F-002", "MS-001", 1, { title: "Secondary Feature", description: undefined }),
]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs[0].title).toBe("Core Feature");
expect(handoffs[0].description).toBe("Main functionality");
expect(handoffs[1].title).toBe("Secondary Feature");
expect(handoffs[1].description).toBeUndefined();
});
it("normalizes ordering when feature order indices are out of sequence", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
// Simulate out-of-sequence feature order indices
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [
createFeature("F-001", "MS-001", 100),
createFeature("F-002", "MS-001", 50),
createFeature("F-003", "MS-001", 75),
]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(3);
// Should be normalized to 0, 1, 2
expect(handoffs[0].source.featureOrderIndex).toBe(0);
expect(handoffs[1].source.featureOrderIndex).toBe(1);
expect(handoffs[2].source.featureOrderIndex).toBe(2);
});
it("skips features from unknown milestone IDs", () => {
const roadmap = createRoadmap();
const milestones = [createMilestone("MS-001", "RM-001", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [createFeature("F-001", "MS-001", 0)]],
// MS-999 is not in milestones, so its features should be ignored
["MS-999", [createFeature("F-999", "MS-999", 0)]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(1);
expect(handoffs[0].source.featureId).toBe("F-001");
});
});
// ── Deterministic Ordering Tests ───────────────────────────────────────────────
describe("deterministic ordering", () => {
it("uses stable ordering when order indices are equal", () => {
const roadmap = createRoadmap();
// Same order index for all milestones - should sort by createdAt then id
const rawMilestones = [
createMilestone("MS-001", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
createMilestone("MS-002", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
createMilestone("MS-003", "RM-001", 0, { createdAt: "2024-01-02T00:00:00.000Z" }),
];
// Normalize before passing to handoff function (mirrors store behavior)
const milestones = normalizeRoadmapMilestoneOrder(rawMilestones);
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
// MS-001 and MS-002 have same orderIndex and createdAt, so should sort by id
// MS-003 has later createdAt
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-001");
expect(handoff.milestones[1].sourceMilestoneId).toBe("MS-002");
expect(handoff.milestones[2].sourceMilestoneId).toBe("MS-003");
});
it("produces consistent output across multiple calls with same input", () => {
const roadmap = createRoadmap({ id: "RM-STABLE" });
const milestones = [
createMilestone("MS-001", "RM-STABLE", 1),
createMilestone("MS-002", "RM-STABLE", 0),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-001", [createFeature("F-001", "MS-001", 1)]],
["MS-002", [createFeature("F-002", "MS-002", 0)]],
]);
const first = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
const second = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(first).toEqual(second);
expect(first.milestones[0].sourceMilestoneId).toBe(second.milestones[0].sourceMilestoneId);
expect(first.milestones[1].sourceMilestoneId).toBe(second.milestones[1].sourceMilestoneId);
});
});
// ── Source Lineage Preservation Tests ─────────────────────────────────────────
describe("source lineage preservation", () => {
it("preserves roadmap context in all feature handoffs", () => {
const roadmap = createRoadmap({ id: "RM-LINEAGE", title: "Lineage Test" });
const milestones = [createMilestone("MS-LINEAGE", "RM-LINEAGE", 0)];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-LINEAGE", [createFeature("F-LINEAGE", "MS-LINEAGE", 0)]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs[0].source.roadmapId).toBe("RM-LINEAGE");
expect(handoffs[0].source.roadmapTitle).toBe("Lineage Test");
});
it("preserves milestone context in all feature handoffs", () => {
const roadmap = createRoadmap();
const milestones = [
createMilestone("MS-ALPHA", "RM-001", 0, { title: "Alpha Milestone" }),
createMilestone("MS-BETA", "RM-001", 1, { title: "Beta Milestone" }),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-ALPHA", [createFeature("F-001", "MS-ALPHA", 0)]],
["MS-BETA", [createFeature("F-002", "MS-BETA", 0)]],
]);
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
expect(handoffs).toHaveLength(2);
expect(handoffs[0].source.milestoneId).toBe("MS-ALPHA");
expect(handoffs[0].source.milestoneTitle).toBe("Alpha Milestone");
expect(handoffs[1].source.milestoneId).toBe("MS-BETA");
expect(handoffs[1].source.milestoneTitle).toBe("Beta Milestone");
});
it("mission handoff preserves source IDs on all entities", () => {
const roadmap = createRoadmap({ id: "RM-MISSION" });
const milestones = [
createMilestone("MS-MISSION-1", "RM-MISSION", 0, { title: "First Phase" }),
];
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
["MS-MISSION-1", [
createFeature("RF-MISSION-1", "MS-MISSION-1", 0, { title: "Mission Feature" }),
]],
]);
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
expect(handoff.sourceRoadmapId).toBe("RM-MISSION");
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-MISSION-1");
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe("RF-MISSION-1");
});
});

View File

@@ -0,0 +1,334 @@
import { describe, expect, it } from "vitest";
import {
applyRoadmapFeatureReorder,
applyRoadmapMilestoneReorder,
moveRoadmapFeature,
normalizeRoadmapFeatureOrder,
normalizeRoadmapMilestoneOrder,
} from "../roadmap-ordering.js";
import type { RoadmapFeature, RoadmapMilestone } from "../roadmap-types.js";
function createMilestone(
id: string,
roadmapId: string,
orderIndex: number,
createdAt: string,
): RoadmapMilestone {
return {
id,
roadmapId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
function createFeature(
id: string,
milestoneId: string,
orderIndex: number,
createdAt: string,
): RoadmapFeature {
return {
id,
milestoneId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
describe("roadmap-ordering", () => {
describe("normalizeRoadmapMilestoneOrder", () => {
it("repairs milestone ordering deterministically using createdAt and id tiebreakers", () => {
const milestones = [
createMilestone("RMS-C", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
createMilestone("RMS-B", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-A", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapMilestoneOrder(milestones);
expect(normalized.map((milestone) => milestone.id)).toEqual([
"RMS-A",
"RMS-B",
"RMS-C",
]);
expect(normalized.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
expect(milestones.map((milestone) => milestone.orderIndex)).toEqual([2, 1, 1]);
});
it("rejects mixed-roadmap milestone scopes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-2", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() => normalizeRoadmapMilestoneOrder(milestones)).toThrow(
"Milestone RMS-2 does not belong to roadmap RM-1",
);
});
});
describe("applyRoadmapMilestoneReorder", () => {
it("reorders milestones and rewrites contiguous order indexes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-3", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-3", "RMS-1", "RMS-2"],
});
expect(reordered.map((milestone) => milestone.id)).toEqual([
"RMS-3",
"RMS-1",
"RMS-2",
]);
expect(reordered.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects duplicate milestone ids in reorder input", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-2", "RMS-2"],
}),
).toThrow("Duplicate milestone id in requested order: RMS-2");
});
});
describe("normalizeRoadmapFeatureOrder", () => {
it("repairs feature ordering deterministically using createdAt and id tiebreakers", () => {
const features = [
createFeature("RF-C", "RMS-1", 3, "2026-04-13T00:00:03.000Z"),
createFeature("RF-B", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-A", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapFeatureOrder(features);
expect(normalized.map((feature) => feature.id)).toEqual([
"RF-A",
"RF-B",
"RF-C",
]);
expect(normalized.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
});
describe("applyRoadmapFeatureReorder", () => {
it("reorders features within a milestone and rewrites contiguous order indexes", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2", "RF-3", "RF-1"],
});
expect(reordered.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(reordered.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects partial feature reorder payloads", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2"],
}),
).toThrow("Expected 2 feature ids but received 1");
});
});
describe("moveRoadmapFeature", () => {
it("moves a feature across milestones and normalizes both milestone orders", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-TARGET", 0, "2026-04-13T00:00:02.000Z"),
createFeature("RF-4", "RMS-TARGET", 1, "2026-04-13T00:00:03.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 1,
});
expect(result.movedFeature).toMatchObject({
id: "RF-2",
milestoneId: "RMS-TARGET",
orderIndex: 1,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-1",
]);
expect(result.sourceMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([0]);
expect(result.targetMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-3",
"RF-2",
"RF-4",
]);
expect(result.targetMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([
0,
1,
2,
]);
expect(result.affectedFeatures).toHaveLength(4);
});
it("clamps same-milestone moves into range and returns a single normalized list", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: 99,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(result.targetMilestoneFeatures).toEqual(result.sourceMilestoneFeatures);
expect(result.movedFeature.orderIndex).toBe(2);
});
it("rejects features outside the affected milestone scope", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-OTHER", 0, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 0,
}),
).toThrow(
"Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)",
);
});
it("clamps negative targetOrderIndex to 0", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: -5,
});
// RF-2 should be moved to index 0, RF-1 to index 1
expect(result.movedFeature.orderIndex).toBe(0);
expect(result.sourceMilestoneFeatures.map((f) => f.id)).toEqual(["RF-2", "RF-1"]);
});
it("clamps NaN targetOrderIndex to end", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: NaN,
});
// NaN is clamped to the end (length of the remaining list)
expect(result.movedFeature.orderIndex).toBe(1);
});
it("clamps Infinity targetOrderIndex to end", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: Infinity,
});
// Infinity is clamped to the end
expect(result.movedFeature.orderIndex).toBe(1);
});
it("produces strictly contiguous orderIndex values after move", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-SOURCE", 2, "2026-04-13T00:00:02.000Z"),
createFeature("RF-4", "RMS-TARGET", 0, "2026-04-13T00:00:03.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 0,
});
// Verify contiguous orderIndex for source
const sourceOrderIndices = result.sourceMilestoneFeatures.map((f) => f.orderIndex);
expect(sourceOrderIndices).toEqual([0, 1]);
expect(new Set(sourceOrderIndices).size).toBe(sourceOrderIndices.length);
// Verify contiguous orderIndex for target
const targetOrderIndices = result.targetMilestoneFeatures.map((f) => f.orderIndex);
expect(targetOrderIndices).toEqual([0, 1]);
expect(new Set(targetOrderIndices).size).toBe(targetOrderIndices.length);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,863 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { RoutineStore } from "../routine-store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import type {
Routine,
RoutineCreateInput,
RoutineExecutionResult,
RoutineTrigger,
} from "../routine.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-routine-test-"));
}
describe("RoutineStore", () => {
let rootDir: string;
let store: RoutineStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new RoutineStore(rootDir);
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
describe("init", () => {
it("is idempotent", async () => {
await store.init();
await store.init();
// Should not throw
});
});
// ── isValidCron ─────────────────────────────────────────────────
describe("isValidCron", () => {
it("accepts valid cron expressions", () => {
expect(RoutineStore.isValidCron("0 * * * *")).toBe(true);
expect(RoutineStore.isValidCron("*/5 * * * *")).toBe(true);
expect(RoutineStore.isValidCron("0 0 * * 1")).toBe(true);
expect(RoutineStore.isValidCron("0 9 1 * *")).toBe(true);
});
it("rejects invalid cron expressions", () => {
expect(RoutineStore.isValidCron("not a cron")).toBe(false);
expect(RoutineStore.isValidCron("60 * * * *")).toBe(false);
expect(RoutineStore.isValidCron("0 25 * * *")).toBe(false);
});
});
// ── computeNextRun ────────────────────────────────────────────────
describe("computeNextRun", () => {
it("returns a future ISO timestamp", () => {
const fromDate = new Date("2026-01-01T00:00:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getTime()).toBeGreaterThan(fromDate.getTime());
});
it("computes correct next run for hourly", () => {
const fromDate = new Date("2026-01-01T12:30:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getUTCHours()).toBe(13);
expect(new Date(next).getUTCMinutes()).toBe(0);
});
it("computes monthly runs against UTC instead of local machine time", () => {
const fromDate = new Date("2026-04-15T00:00:00Z");
const next = store.computeNextRun("0 0 1 * *", fromDate);
expect(next).toBe("2026-05-01T00:00:00.000Z");
});
});
// ── createRoutine ────────────────────────────────────────────────
describe("createRoutine", () => {
it("creates a routine with cron trigger", async () => {
const input: RoutineCreateInput = {
name: "Hourly check",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
};
const routine = await store.createRoutine(input);
expect(routine.id).toBeTruthy();
expect(routine.name).toBe("Hourly check");
expect(routine.trigger.type).toBe("cron");
expect((routine.trigger as any).cronExpression).toBe("0 * * * *");
expect(routine.catchUpPolicy).toBe("run_one");
expect(routine.executionPolicy).toBe("queue");
expect(routine.enabled).toBe(true);
expect(routine.runCount).toBe(0);
expect(routine.runHistory).toEqual([]);
expect(routine.nextRunAt).toBeTruthy();
expect(routine.createdAt).toBeTruthy();
expect(routine.updatedAt).toBeTruthy();
});
it("creates a routine with webhook trigger", async () => {
const input: RoutineCreateInput = {
name: "Webhook routine",
agentId: "test-agent",
trigger: { type: "webhook", webhookPath: "/trigger/my-routine" },
};
const routine = await store.createRoutine(input);
expect(routine.trigger.type).toBe("webhook");
expect((routine.trigger as any).webhookPath).toBe("/trigger/my-routine");
});
it("creates a routine with api trigger", async () => {
const input: RoutineCreateInput = {
name: "API routine",
agentId: "test-agent",
trigger: { type: "api", endpoint: "/api/routines/run" },
};
const routine = await store.createRoutine(input);
expect(routine.trigger.type).toBe("api");
expect((routine.trigger as any).endpoint).toBe("/api/routines/run");
});
it("creates a routine with manual trigger", async () => {
const input: RoutineCreateInput = {
name: "Manual routine",
agentId: "test-agent",
trigger: { type: "manual" },
};
const routine = await store.createRoutine(input);
expect(routine.trigger.type).toBe("manual");
expect(routine.nextRunAt).toBeUndefined(); // No nextRunAt for manual triggers
});
it("creates disabled routine without nextRunAt", async () => {
const input: RoutineCreateInput = {
name: "Disabled",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
enabled: false,
};
const routine = await store.createRoutine(input);
expect(routine.enabled).toBe(false);
expect(routine.nextRunAt).toBeUndefined();
});
it("creates routine with custom policies", async () => {
const input: RoutineCreateInput = {
name: "Custom policies",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
catchUpPolicy: "skip",
executionPolicy: "parallel",
};
const routine = await store.createRoutine(input);
expect(routine.catchUpPolicy).toBe("skip");
expect(routine.executionPolicy).toBe("parallel");
});
it("rejects empty name", async () => {
const input: RoutineCreateInput = {
name: "",
agentId: "test-agent",
trigger: { type: "manual" },
};
await expect(store.createRoutine(input)).rejects.toThrow("Name is required");
});
it("rejects invalid cron expression", async () => {
const input: RoutineCreateInput = {
name: "Bad cron",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "bad cron" },
};
await expect(store.createRoutine(input)).rejects.toThrow("Invalid cron expression");
});
it("emits routine:created event", async () => {
const listener = vi.fn();
store.on("routine:created", listener);
const routine = await store.createRoutine({
name: "Event test",
agentId: "test-agent",
trigger: { type: "manual" },
});
expect(listener).toHaveBeenCalledWith(routine);
});
});
// ── getRoutine ──────────────────────────────────────────────────
describe("getRoutine", () => {
it("reads a routine by id", async () => {
const created = await store.createRoutine({
name: "Get test",
agentId: "test-agent",
trigger: { type: "manual" },
});
const fetched = await store.getRoutine(created.id);
expect(fetched.id).toBe(created.id);
expect(fetched.name).toBe("Get test");
});
it("throws ENOENT for missing routine", async () => {
await expect(store.getRoutine("nonexistent")).rejects.toThrow("not found");
});
});
// ── listRoutines ─────────────────────────────────────────────────
describe("listRoutines", () => {
it("returns empty array when no routines", async () => {
const list = await store.listRoutines();
expect(list).toEqual([]);
});
it("returns all routines sorted by createdAt", async () => {
await store.createRoutine({ name: "A", agentId: "test-agent", trigger: { type: "manual" } });
await new Promise((r) => setTimeout(r, 5));
await store.createRoutine({ name: "B", agentId: "test-agent", trigger: { type: "manual" } });
const list = await store.listRoutines();
expect(list).toHaveLength(2);
expect(list[0].name).toBe("A");
expect(list[1].name).toBe("B");
});
});
// ── updateRoutine ────────────────────────────────────────────────
describe("updateRoutine", () => {
it("updates name and description", async () => {
const routine = await store.createRoutine({
name: "Original",
agentId: "test-agent",
trigger: { type: "manual" },
});
await new Promise((r) => setTimeout(r, 5));
const updated = await store.updateRoutine(routine.id, {
name: "Updated",
description: "A description",
});
expect(updated.name).toBe("Updated");
expect(updated.description).toBe("A description");
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(routine.updatedAt).getTime(),
);
});
it("updates trigger from manual to cron", async () => {
const routine = await store.createRoutine({
name: "Test",
agentId: "test-agent",
trigger: { type: "manual" },
});
const updated = await store.updateRoutine(routine.id, {
trigger: { type: "cron", cronExpression: "*/10 * * * *" },
});
expect(updated.trigger.type).toBe("cron");
expect((updated.trigger as any).cronExpression).toBe("*/10 * * * *");
expect(updated.nextRunAt).toBeTruthy();
});
it("updates enabled state", async () => {
const routine = await store.createRoutine({
name: "Toggle",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
});
const disabled = await store.updateRoutine(routine.id, { enabled: false });
expect(disabled.enabled).toBe(false);
expect(disabled.nextRunAt).toBeUndefined();
const reenabled = await store.updateRoutine(routine.id, { enabled: true });
expect(reenabled.enabled).toBe(true);
expect(reenabled.nextRunAt).toBeTruthy();
});
it("updates policies", async () => {
const routine = await store.createRoutine({
name: "Policies",
agentId: "test-agent",
trigger: { type: "manual" },
});
const updated = await store.updateRoutine(routine.id, {
catchUpPolicy: "run",
executionPolicy: "parallel",
});
expect(updated.catchUpPolicy).toBe("run");
expect(updated.executionPolicy).toBe("parallel");
});
it("rejects empty name", async () => {
const routine = await store.createRoutine({
name: "Test",
agentId: "test-agent",
trigger: { type: "manual" },
});
await expect(
store.updateRoutine(routine.id, { name: " " }),
).rejects.toThrow("Name cannot be empty");
});
it("rejects invalid cron on update", async () => {
const routine = await store.createRoutine({
name: "Test",
agentId: "test-agent",
trigger: { type: "manual" },
});
await expect(
store.updateRoutine(routine.id, {
trigger: { type: "cron", cronExpression: "bad cron" },
}),
).rejects.toThrow("Invalid cron expression");
});
it("emits routine:updated event", async () => {
const routine = await store.createRoutine({
name: "Event test",
agentId: "test-agent",
trigger: { type: "manual" },
});
const listener = vi.fn();
store.on("routine:updated", listener);
await store.updateRoutine(routine.id, { name: "Updated" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── deleteRoutine ───────────────────────────────────────────────
describe("deleteRoutine", () => {
it("deletes a routine", async () => {
const routine = await store.createRoutine({
name: "Delete me",
agentId: "test-agent",
trigger: { type: "manual" },
});
const deleted = await store.deleteRoutine(routine.id);
expect(deleted.id).toBe(routine.id);
await expect(store.getRoutine(routine.id)).rejects.toThrow("not found");
});
it("throws for missing routine", async () => {
await expect(store.deleteRoutine("nonexistent")).rejects.toThrow("not found");
});
it("emits routine:deleted event", async () => {
const created = await store.createRoutine({
name: "Delete test",
agentId: "test-agent",
trigger: { type: "manual" },
});
const listener = vi.fn();
store.on("routine:deleted", listener);
await store.deleteRoutine(created.id);
// The emitted routine comes from getRoutine() which adds extra fields
expect(listener).toHaveBeenCalledTimes(1);
const emitted = listener.mock.calls[0][0];
expect(emitted.id).toBe(created.id);
expect(emitted.name).toBe("Delete test");
expect(emitted.agentId).toBe("test-agent");
});
});
// ── recordRun ───────────────────────────────────────────────────
describe("recordRun", () => {
it("records a successful run", async () => {
const routine = await store.createRoutine({
name: "Run test",
agentId: "test-agent",
trigger: { type: "manual" },
});
const result: RoutineExecutionResult = {
routineId: routine.id,
success: true,
output: "completed",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(routine.id, result);
expect(updated.lastRunAt).toBe(result.startedAt);
expect(updated.lastRunResult).toEqual(result);
expect(updated.runCount).toBe(1);
expect(updated.runHistory).toHaveLength(1);
expect(updated.runHistory[0]).toEqual(result);
});
it("records a failed run", async () => {
const routine = await store.createRoutine({
name: "Fail test",
agentId: "test-agent",
trigger: { type: "manual" },
});
const result: RoutineExecutionResult = {
routineId: routine.id,
success: false,
output: "",
error: "Something went wrong",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(routine.id, result);
expect(updated.lastRunResult?.success).toBe(false);
expect(updated.lastRunResult?.error).toContain("Something went wrong");
expect(updated.runCount).toBe(1);
});
it("caps run history at MAX_ROUTINE_RUN_HISTORY", async () => {
const routine = await store.createRoutine({
name: "History test",
agentId: "test-agent",
trigger: { type: "manual" },
});
for (let i = 0; i < 55; i++) {
await store.recordRun(routine.id, {
routineId: routine.id,
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
}
const updated = await store.getRoutine(routine.id);
expect(updated.runHistory.length).toBeLessThanOrEqual(50);
expect(updated.runCount).toBe(55);
});
it("emits routine:run event", async () => {
const routine = await store.createRoutine({
name: "Event test",
agentId: "test-agent",
trigger: { type: "manual" },
});
const listener = vi.fn();
store.on("routine:run", listener);
const result: RoutineExecutionResult = {
routineId: routine.id,
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
await store.recordRun(routine.id, result);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].result).toEqual(result);
});
it("recomputes nextRunAt for cron routines after run", async () => {
// Use a cron that fires every minute to ensure different nextRunAt
const routine = await store.createRoutine({
name: "Cron run test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * * *" },
});
const originalNextRun = routine.nextRunAt;
expect(originalNextRun).toBeTruthy();
// Wait a bit to ensure time passes
await new Promise((r) => setTimeout(r, 1000));
const result: RoutineExecutionResult = {
routineId: routine.id,
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(routine.id, result);
expect(updated.nextRunAt).toBeTruthy();
// nextRunAt should be updated (may be same or later depending on timing)
expect(updated.nextRunAt).not.toBeUndefined();
});
});
// ── getDueRoutines ──────────────────────────────────────────────
describe("getDueRoutines", () => {
it("returns empty array when no routines", async () => {
const due = await store.getDueRoutines("project");
expect(due).toEqual([]);
});
it("excludes disabled routines", async () => {
const routine = await store.createRoutine({
name: "Disabled test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
enabled: false,
});
const due = await store.getDueRoutines("project");
expect(due.some((d) => d.id === routine.id)).toBe(false);
});
it("excludes routines with future nextRunAt", async () => {
const routine = await store.createRoutine({
name: "Future test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
});
// nextRunAt is in the future by default
const due = await store.getDueRoutines("project");
expect(due.some((d) => d.id === routine.id)).toBe(false);
});
it("returns routines with past nextRunAt after manual update", async () => {
// Create routine with cron trigger
const routine = await store.createRoutine({
name: "Due test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
});
// Manually set nextRunAt to the past by directly manipulating the database
// This tests the due-routine query logic
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare(
"UPDATE routines SET nextRunAt = ? WHERE id = ?"
).run(pastDate, routine.id);
// Now getDueRoutines should include it
const due = await store.getDueRoutines("project");
expect(due.some((d) => d.id === routine.id)).toBe(true);
});
});
// ── Concurrent write safety ─────────────────────────────────────
describe("concurrency", () => {
it("handles concurrent updates safely", async () => {
const routine = await store.createRoutine({
name: "Concurrent",
agentId: "test-agent",
trigger: { type: "manual" },
});
// Fire multiple concurrent recordRun calls
const updates = Array.from({ length: 10 }, (_, i) =>
store.recordRun(routine.id, {
routineId: routine.id,
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
}),
);
await Promise.all(updates);
const final = await store.getRoutine(routine.id);
expect(final.runCount).toBe(10);
expect(final.runHistory).toHaveLength(10);
});
});
// ── Scope-aware routines ─────────────────────────────────────────
describe("scope-aware routines", () => {
it("createRoutine without scope defaults to 'project'", async () => {
const routine = await store.createRoutine({
name: "Default scope",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
});
expect(routine.scope).toBe("project");
// Verify round-trip persistence
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("project");
});
it("createRoutine with scope='global' persists correctly", async () => {
const routine = await store.createRoutine({
name: "Global scope",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
expect(routine.scope).toBe("global");
// Verify round-trip persistence
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("listRoutines returns both global and project scopes", async () => {
const global = await store.createRoutine({
name: "Global",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const project = await store.createRoutine({
name: "Project",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
const list = await store.listRoutines();
expect(list).toHaveLength(2);
const globalFound = list.find((r) => r.id === global.id);
const projectFound = list.find((r) => r.id === project.id);
expect(globalFound?.scope).toBe("global");
expect(projectFound?.scope).toBe("project");
});
it("getDueRoutines filters by scope - global only", async () => {
const global = await store.createRoutine({
name: "Global due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const project = await store.createRoutine({
name: "Project due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueRoutines("global");
expect(globalDue.some((r) => r.id === global.id)).toBe(true);
expect(globalDue.some((r) => r.id === project.id)).toBe(false);
const projectDue = await store.getDueRoutines("project");
expect(projectDue.some((r) => r.id === project.id)).toBe(true);
expect(projectDue.some((r) => r.id === global.id)).toBe(false);
});
it("getDueRoutinesAllScopes returns routines from both scopes", async () => {
const global = await store.createRoutine({
name: "Global due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const project = await store.createRoutine({
name: "Project due",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
// Set nextRunAt to the past via direct DB update
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const allDue = await store.getDueRoutinesAllScopes();
expect(allDue.some((r) => r.id === global.id)).toBe(true);
expect(allDue.some((r) => r.id === project.id)).toBe(true);
});
it("getDueRoutines does not leak scopes - global not in project", async () => {
const global = await store.createRoutine({
name: "Global only",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, global.id);
const projectDue = await store.getDueRoutines("project");
expect(projectDue.some((r) => r.id === global.id)).toBe(false);
});
it("getDueRoutines does not leak scopes - project not in global", async () => {
const project = await store.createRoutine({
name: "Project only",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
});
// Set nextRunAt to the past
const pastDate = new Date(Date.now() - 60000).toISOString();
store["db"].prepare("UPDATE routines SET nextRunAt = ? WHERE id = ?").run(pastDate, project.id);
const globalDue = await store.getDueRoutines("global");
expect(globalDue.some((r) => r.id === project.id)).toBe(false);
});
it("recordRun preserves scope", async () => {
const routine = await store.createRoutine({
name: "Scope preservation",
agentId: "test-agent",
trigger: { type: "manual" },
scope: "global",
});
await store.recordRun(routine.id, {
routineId: routine.id,
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("trigger type variants with scope persist correctly - cron", async () => {
const routine = await store.createRoutine({
name: "Cron with global",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("cron");
});
it("trigger type variants with scope persist correctly - webhook", async () => {
const routine = await store.createRoutine({
name: "Webhook with global",
agentId: "test-agent",
trigger: { type: "webhook", webhookPath: "/trigger/test" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("webhook");
});
it("trigger type variants with scope persist correctly - api", async () => {
const routine = await store.createRoutine({
name: "API with global",
agentId: "test-agent",
trigger: { type: "api", endpoint: "/api/test" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("api");
});
it("trigger type variants with scope persist correctly - manual", async () => {
const routine = await store.createRoutine({
name: "Manual with global",
agentId: "test-agent",
trigger: { type: "manual" },
scope: "global",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
expect(fetched.trigger.type).toBe("manual");
});
it("startRoutineExecution preserves scope", async () => {
const routine = await store.createRoutine({
name: "Start scope test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
await store.startRoutineExecution(routine.id, {
triggeredAt: new Date().toISOString(),
invocationSource: "test",
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("completeRoutineExecution preserves scope", async () => {
const routine = await store.createRoutine({
name: "Complete scope test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
await store.completeRoutineExecution(routine.id, {
completedAt: new Date().toISOString(),
success: true,
resultJson: { output: "ok" },
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
it("cancelRoutineExecution preserves scope", async () => {
const routine = await store.createRoutine({
name: "Cancel scope test",
agentId: "test-agent",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
});
await store.cancelRoutineExecution(routine.id);
const fetched = await store.getRoutine(routine.id);
expect(fetched.scope).toBe("global");
});
});
});

View File

@@ -0,0 +1,560 @@
/**
* Run-Audit Core Integration Tests
*
* These tests verify end-to-end run-audit functionality across the core API:
* - Multi-domain event correlation under a single runId
* - Complete event shape verification
* - Absent run context handling (backward compatibility)
* - Partial metadata normalization
* - Deterministic duplicate-timestamp ordering
*
* Run with: pnpm --filter @fusion/core exec vitest run src/run-audit.integration.test.ts
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database } from "../db.js";
import { TaskStore } from "../store.js";
import type { RunAuditEventInput, RunAuditEvent } from "../types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-run-audit-integration-test-"));
}
describe("Run Audit Integration", () => {
let rootDir: string;
let fusionDir: string;
let db: Database;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
fusionDir = join(rootDir, ".fusion");
db = new Database(fusionDir);
db.init();
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();
});
afterEach(async () => {
try {
store.close();
} catch {
// ignore
}
try {
db.close();
} catch {
// ignore
}
await rm(rootDir, { recursive: true, force: true });
});
describe("multi-domain event correlation", () => {
it("correlates git, database, and filesystem events under a single runId", () => {
const runId = "integration-test-run-001";
const agentId = "agent-integration";
const taskId = "FN-INTEG-001";
// Record events across all three domains
store.recordRunAuditEvent({
runId,
agentId,
taskId,
domain: "git",
mutationType: "worktree:create",
target: ".worktrees/integration-task",
metadata: { branch: "fusion/integration-task" },
});
store.recordRunAuditEvent({
runId,
agentId,
taskId,
domain: "database",
mutationType: "task:update",
target: taskId,
metadata: { updatedFields: ["status"] },
});
store.recordRunAuditEvent({
runId,
agentId,
taskId,
domain: "filesystem",
mutationType: "file:write",
target: "src/integration.ts",
metadata: { size: 1234 },
});
// Query by runId
const events = store.getRunAuditEvents({ runId });
// All three domains should be present
expect(events).toHaveLength(3);
const domains = events.map((e) => e.domain);
expect(domains).toContain("git");
expect(domains).toContain("database");
expect(domains).toContain("filesystem");
});
it("returns events ordered by timestamp DESC, rowid DESC", () => {
const runId = "integration-test-run-002";
// Insert in reverse order (oldest first in IDs due to autoincrement)
store.recordRunAuditEvent({
timestamp: "2025-01-01T01:00:00.000Z",
runId,
agentId: "agent-x",
domain: "database",
mutationType: "first",
target: "t1",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T01:00:00.000Z", // Same timestamp
runId,
agentId: "agent-y",
domain: "git",
mutationType: "second",
target: "t2",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T02:00:00.000Z",
runId,
agentId: "agent-z",
domain: "filesystem",
mutationType: "third",
target: "t3",
});
const events = store.getRunAuditEvents({ runId });
// Newest first (timestamp DESC)
expect(events[0].mutationType).toBe("third");
expect(events[1].mutationType).toBe("second"); // rowid DESC tiebreaker: second inserted last
expect(events[2].mutationType).toBe("first");
});
it("filters by domain correctly", () => {
const runId = "integration-test-run-003";
store.recordRunAuditEvent({
runId,
agentId: "agent-1",
domain: "git",
mutationType: "commit:create",
target: "main",
});
store.recordRunAuditEvent({
runId,
agentId: "agent-1",
domain: "database",
mutationType: "task:update",
target: "FN-001",
});
store.recordRunAuditEvent({
runId,
agentId: "agent-1",
domain: "filesystem",
mutationType: "file:write",
target: "src/test.ts",
});
const gitEvents = store.getRunAuditEvents({ runId, domain: "git" });
expect(gitEvents).toHaveLength(1);
expect(gitEvents[0].domain).toBe("git");
});
});
describe("complete event shape verification", () => {
it("verifies all required fields are present in persisted events", () => {
const input: RunAuditEventInput = {
taskId: "FN-SHAPE-001",
agentId: "agent-shape",
runId: "run-shape-001",
domain: "database",
mutationType: "task:create",
target: "FN-SHAPE-001",
metadata: { source: "integration-test" },
};
const event = store.recordRunAuditEvent(input);
const events = store.getRunAuditEvents({ runId: input.runId });
expect(events).toHaveLength(1);
const persisted = events[0];
// Verify complete shape
expect(persisted.id).toBeDefined();
expect(typeof persisted.id).toBe("string");
expect(persisted.timestamp).toBeDefined();
expect(typeof persisted.timestamp).toBe("string");
expect(persisted.runId).toBe(input.runId);
expect(persisted.agentId).toBe(input.agentId);
expect(persisted.taskId).toBe(input.taskId);
expect(persisted.domain).toBe(input.domain);
expect(persisted.mutationType).toBe(input.mutationType);
expect(persisted.target).toBe(input.target);
expect(persisted.metadata).toEqual(input.metadata);
});
it("handles events without optional fields gracefully", () => {
const input: RunAuditEventInput = {
agentId: "agent-minimal",
runId: "run-minimal-001",
domain: "database",
mutationType: "task:log",
target: "FN-MINIMAL-001",
// No taskId, no metadata
};
const event = store.recordRunAuditEvent(input);
const events = store.getRunAuditEvents({ runId: input.runId });
expect(events).toHaveLength(1);
const persisted = events[0];
// Required fields present
expect(persisted.id).toBeDefined();
expect(persisted.timestamp).toBeDefined();
expect(persisted.runId).toBe(input.runId);
expect(persisted.agentId).toBe(input.agentId);
expect(persisted.domain).toBe(input.domain);
expect(persisted.mutationType).toBe(input.mutationType);
expect(persisted.target).toBe(input.target);
// Optional fields undefined
expect(persisted.taskId).toBeUndefined();
expect(persisted.metadata).toBeUndefined();
});
it("preserves metadata with nested objects", () => {
const complexMetadata = {
filesChanged: 5,
details: { insertions: 100, deletions: 20 },
array: ["a", "b", "c"],
nested: { deep: { value: 42 } },
};
store.recordRunAuditEvent({
runId: "run-complex-meta",
agentId: "agent-complex",
domain: "git",
mutationType: "commit:create",
target: "feature/test",
metadata: complexMetadata,
});
const events = store.getRunAuditEvents({ runId: "run-complex-meta" });
expect(events[0].metadata).toEqual(complexMetadata);
});
});
describe("absent run context regression", () => {
it("recordRunAuditEvent works with minimal required fields", () => {
// Even without explicit timestamp or full context, should not crash
const event = store.recordRunAuditEvent({
agentId: "agent-regression",
runId: "run-regression-001",
domain: "database",
mutationType: "task:log",
target: "FN-REG-001",
});
expect(event.id).toBeDefined();
expect(event.timestamp).toBeDefined();
expect(event.runId).toBe("run-regression-001");
});
it("getRunAuditEvents with empty filter returns all events", () => {
// No filters should return all events (or empty if none exist)
const events = store.getRunAuditEvents();
expect(Array.isArray(events)).toBe(true);
});
it("getRunAuditEvents with non-existent runId returns empty array", () => {
const events = store.getRunAuditEvents({ runId: "non-existent-run-id" });
expect(events).toHaveLength(0);
});
it("getRunAuditEvents with invalid domain does not crash", () => {
// Should return empty or filter correctly (no throw)
const events = store.getRunAuditEvents({ domain: "invalid-domain" as any });
expect(Array.isArray(events)).toBe(true);
// Empty because domain filter won't match any valid domains
expect(events.length).toBe(0);
});
});
describe("partial metadata normalization", () => {
it("preserves empty string metadata values", () => {
const event = store.recordRunAuditEvent({
runId: "run-normalize-001",
agentId: "agent-norm",
domain: "database",
mutationType: "task:update",
target: "FN-NORM-001",
metadata: { emptyString: "", valid: "value" },
});
const events = store.getRunAuditEvents({ runId: "run-normalize-001" });
// Empty strings are preserved as-is (no automatic normalization to undefined)
expect(events[0].metadata).toEqual({ emptyString: "", valid: "value" });
});
it("handles null metadata gracefully", () => {
const event = store.recordRunAuditEvent({
runId: "run-null-meta",
agentId: "agent-null",
domain: "database",
mutationType: "task:create",
target: "FN-NULL-001",
metadata: null as any, // Intentional: should handle gracefully
});
// Event should be persisted with null metadata
expect(event.id).toBeDefined();
expect(event.metadata).toBeNull();
// Verify event can be queried
const events = store.getRunAuditEvents({ runId: "run-null-meta" });
expect(events).toHaveLength(1);
expect(events[0].id).toBe(event.id);
});
it("records events with undefined metadata", () => {
const event = store.recordRunAuditEvent({
runId: "run-undefined-meta",
agentId: "agent-und",
domain: "git",
mutationType: "commit:create",
target: "main",
// No metadata field at all
});
const events = store.getRunAuditEvents({ runId: "run-undefined-meta" });
expect(events[0].metadata).toBeUndefined();
});
it("preserves metadata with special characters", () => {
const event = store.recordRunAuditEvent({
runId: "run-special",
agentId: "agent-special",
domain: "filesystem",
mutationType: "file:write",
target: "path/with spaces & 'special' chars.txt",
metadata: {
description: "Test with émojis 🎉 and unicode ñ",
path: "C:\\Users\\Test\\file.ts",
},
});
const events = store.getRunAuditEvents({ runId: "run-special" });
expect(events[0].metadata).toEqual({
description: "Test with émojis 🎉 and unicode ñ",
path: "C:\\Users\\Test\\file.ts",
});
});
});
describe("duplicate timestamp ordering regression", () => {
it("orders events with identical timestamps deterministically using rowid", () => {
const runId = "run-duplicate-ts";
const sameTs = "2025-06-15T12:00:00.000Z";
// Insert multiple events with identical timestamps
const ids: string[] = [];
for (let i = 0; i < 5; i++) {
const event = store.recordRunAuditEvent({
timestamp: sameTs,
runId,
agentId: `agent-${i}`,
domain: "database",
mutationType: `event-${i}`,
target: `target-${i}`,
});
ids.push(event.id);
}
// Query and verify deterministic order
const events1 = store.getRunAuditEvents({ runId });
const events2 = store.getRunAuditEvents({ runId }); // Query again
// Same order on repeated queries
expect(events1.map((e) => e.mutationType)).toEqual(events2.map((e) => e.mutationType));
// Rowid DESC means newest row first (later IDs first for autoincrement)
expect(events1[0].mutationType).toBe("event-4"); // Last inserted
expect(events1[4].mutationType).toBe("event-0"); // First inserted
});
it("handles many events with same timestamp stably", () => {
const runId = "run-many-same-ts";
const sameTs = "2025-06-15T12:00:00.000Z";
// Insert 20 events with same timestamp
for (let i = 0; i < 20; i++) {
store.recordRunAuditEvent({
timestamp: sameTs,
runId,
agentId: `agent-${i}`,
domain: "database",
mutationType: `type-${i}`,
target: `FN-${String(i).padStart(3, "0")}`,
});
}
const events = store.getRunAuditEvents({ runId });
// All 20 events present
expect(events).toHaveLength(20);
// Order is stable and deterministic
const order1 = events.map((e) => e.mutationType);
const eventsAgain = store.getRunAuditEvents({ runId });
const order2 = eventsAgain.map((e) => e.mutationType);
expect(order1).toEqual(order2);
// Each mutation type appears exactly once
const uniqueTypes = new Set(events.map((e) => e.mutationType));
expect(uniqueTypes.size).toBe(20);
});
it("maintains ordering across query limit", () => {
const runId = "run-limit-order";
const sameTs = "2025-06-15T12:00:00.000Z";
// Insert 10 events with same timestamp
for (let i = 0; i < 10; i++) {
store.recordRunAuditEvent({
timestamp: sameTs,
runId,
agentId: `agent-${i}`,
domain: "database",
mutationType: `type-${i}`,
target: `FN-${i}`,
});
}
// Query with limit - should get the newest first (rowid DESC)
const limited = store.getRunAuditEvents({ runId, limit: 5 });
expect(limited).toHaveLength(5);
expect(limited[0].mutationType).toBe("type-9"); // Newest first
expect(limited[4].mutationType).toBe("type-5");
// Query all and verify order consistency
const all = store.getRunAuditEvents({ runId });
expect(all[0].mutationType).toBe("type-9");
expect(all[9].mutationType).toBe("type-0");
});
});
describe("event metadata completeness", () => {
it("asserts non-empty mutationType in results", () => {
const eventTypes = [
"task:create",
"task:update",
"task:move",
"git:commit",
"file:write",
"worktree:create",
];
const runId = "run-complete-001";
eventTypes.forEach((type) => {
store.recordRunAuditEvent({
runId,
agentId: "agent-check",
domain: type.startsWith("git") ? "git" : type.startsWith("file") || type.startsWith("worktree") ? "filesystem" : "database",
mutationType: type,
target: "test-target",
});
});
const events = store.getRunAuditEvents({ runId });
events.forEach((event) => {
expect(event.mutationType).toBeTruthy();
expect(event.mutationType.length).toBeGreaterThan(0);
});
});
it("asserts non-empty target in results", () => {
const runId = "run-target-001";
store.recordRunAuditEvent({
runId,
agentId: "agent-target",
domain: "database",
mutationType: "task:create",
target: "FN-TARGET-001",
});
const events = store.getRunAuditEvents({ runId });
events.forEach((event) => {
expect(event.target).toBeTruthy();
expect(typeof event.target).toBe("string");
});
});
it("verifies domain is one of valid values", () => {
const validDomains = ["database", "git", "filesystem"];
const runId = "run-domain-valid";
validDomains.forEach((domain) => {
store.recordRunAuditEvent({
runId,
agentId: "agent-domain",
domain: domain as any,
mutationType: "test",
target: "test",
});
});
const events = store.getRunAuditEvents({ runId });
events.forEach((event) => {
expect(validDomains).toContain(event.domain);
});
});
});
describe("integration with TaskStore operations", () => {
it("task operations can emit correlated audit events", async () => {
const task = await store.createTask({ description: "Integration test task" });
const runId = "run-store-integration";
// Simulate engine operations with run context
await store.logEntry(task.id, "Test action", undefined, { runId, agentId: "agent-test" });
await store.addComment(task.id, "Test comment", "user", undefined, { runId, agentId: "agent-test" });
const events = store.getRunAuditEvents({ runId });
// Should have logged events from both operations
expect(events.length).toBeGreaterThanOrEqual(2);
// All events should have the runId
events.forEach((event) => {
expect(event.runId).toBe(runId);
});
// Events should have domain and mutationType
const domains = events.map((e) => e.domain);
expect(domains).toContain("database");
});
it("pauseTask emits correlated audit event", async () => {
const task = await store.createTask({ description: "Pause test task" });
const runId = "run-pause-integration";
await store.pauseTask(task.id, true, { runId, agentId: "agent-pause" });
const events = store.getRunAuditEvents({ runId });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:pause");
expect(events[0].target).toBe(task.id);
});
});
});

View File

@@ -0,0 +1,471 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database } from "../db.js";
import { TaskStore } from "../store.js";
import type { RunAuditEventInput, RunAuditEventFilter } from "../types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-run-audit-test-"));
}
describe("Run Audit", () => {
let rootDir: string;
let fusionDir: string;
let db: Database;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
fusionDir = join(rootDir, ".fusion");
db = new Database(fusionDir);
db.init();
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
await store.init();
});
afterEach(async () => {
try {
store.close();
} catch {
// ignore
}
try {
db.close();
} catch {
// ignore
}
await rm(rootDir, { recursive: true, force: true });
});
describe("recordRunAuditEvent", () => {
it("records a basic audit event with required fields", () => {
const input: RunAuditEventInput = {
agentId: "agent-001",
runId: "run-abc",
domain: "database",
mutationType: "task:update",
target: "FN-001",
};
const event = store.recordRunAuditEvent(input);
expect(event.id).toBeDefined();
expect(event.timestamp).toBeDefined();
expect(event.agentId).toBe("agent-001");
expect(event.runId).toBe("run-abc");
expect(event.domain).toBe("database");
expect(event.mutationType).toBe("task:update");
expect(event.target).toBe("FN-001");
expect(event.taskId).toBeUndefined();
expect(event.metadata).toBeUndefined();
});
it("records an audit event with optional fields", () => {
const input: RunAuditEventInput = {
timestamp: "2025-01-15T10:30:00.000Z",
taskId: "FN-001",
agentId: "agent-001",
runId: "run-xyz",
domain: "git",
mutationType: "git:commit",
target: "feature/fix-bug",
metadata: { filesChanged: 5, insertions: 100, deletions: 20 },
};
const event = store.recordRunAuditEvent(input);
expect(event.id).toBeDefined();
expect(event.timestamp).toBe("2025-01-15T10:30:00.000Z");
expect(event.taskId).toBe("FN-001");
expect(event.agentId).toBe("agent-001");
expect(event.runId).toBe("run-xyz");
expect(event.domain).toBe("git");
expect(event.mutationType).toBe("git:commit");
expect(event.target).toBe("feature/fix-bug");
expect(event.metadata).toEqual({ filesChanged: 5, insertions: 100, deletions: 20 });
});
it("generates a new id and timestamp when not provided", () => {
const input: RunAuditEventInput = {
agentId: "agent-001",
runId: "run-001",
domain: "filesystem",
mutationType: "file:write",
target: "src/index.ts",
};
const before = Date.now();
const event = store.recordRunAuditEvent(input);
const after = Date.now();
expect(event.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
const eventTime = new Date(event.timestamp).getTime();
expect(eventTime).toBeGreaterThanOrEqual(before);
expect(eventTime).toBeLessThanOrEqual(after);
});
it("persists the event to the database", () => {
const input: RunAuditEventInput = {
agentId: "agent-002",
runId: "run-002",
domain: "database",
mutationType: "task:log",
target: "FN-002",
taskId: "FN-002",
};
const event = store.recordRunAuditEvent(input);
// Query using getRunAuditEvents
const events = store.getRunAuditEvents({ runId: "run-002" });
expect(events).toHaveLength(1);
expect(events[0].id).toBe(event.id);
expect(events[0].runId).toBe("run-002");
});
});
describe("getRunAuditEvents", () => {
beforeEach(() => {
// Set up test data with known timestamps
store.recordRunAuditEvent({
timestamp: "2025-01-01T00:00:00.000Z",
taskId: "FN-001",
agentId: "agent-a",
runId: "run-001",
domain: "database",
mutationType: "task:create",
target: "FN-001",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T01:00:00.000Z",
taskId: "FN-001",
agentId: "agent-a",
runId: "run-001",
domain: "database",
mutationType: "task:update",
target: "FN-001",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T02:00:00.000Z",
agentId: "agent-a",
runId: "run-001",
domain: "git",
mutationType: "git:commit",
target: "main",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T03:00:00.000Z",
taskId: "FN-002",
agentId: "agent-b",
runId: "run-002",
domain: "database",
mutationType: "task:create",
target: "FN-002",
});
store.recordRunAuditEvent({
timestamp: "2025-01-01T04:00:00.000Z",
taskId: "FN-003",
agentId: "agent-c",
runId: "run-003",
domain: "filesystem",
mutationType: "file:write",
target: "src/utils.ts",
});
});
it("returns all events when no filters provided", () => {
const events = store.getRunAuditEvents();
expect(events).toHaveLength(5);
});
it("filters by runId", () => {
const events = store.getRunAuditEvents({ runId: "run-001" });
expect(events).toHaveLength(3);
events.forEach((event) => {
expect(event.runId).toBe("run-001");
});
});
it("filters by taskId", () => {
const events = store.getRunAuditEvents({ taskId: "FN-001" });
expect(events).toHaveLength(2);
events.forEach((event) => {
expect(event.taskId).toBe("FN-001");
});
});
it("filters by agentId", () => {
const events = store.getRunAuditEvents({ agentId: "agent-b" });
expect(events).toHaveLength(1);
expect(events[0].agentId).toBe("agent-b");
});
it("filters by domain", () => {
const events = store.getRunAuditEvents({ domain: "git" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("git");
});
it("filters by mutationType", () => {
const events = store.getRunAuditEvents({ mutationType: "task:create" });
expect(events).toHaveLength(2);
events.forEach((event) => {
expect(event.mutationType).toBe("task:create");
});
});
it("applies limit correctly", () => {
const events = store.getRunAuditEvents({ limit: 2 });
expect(events).toHaveLength(2);
});
it("returns empty array for no matches", () => {
const events = store.getRunAuditEvents({ runId: "nonexistent" });
expect(events).toHaveLength(0);
});
it("combines multiple filters with AND logic", () => {
const events = store.getRunAuditEvents({
runId: "run-001",
domain: "database",
});
expect(events).toHaveLength(2);
events.forEach((event) => {
expect(event.runId).toBe("run-001");
expect(event.domain).toBe("database");
});
});
describe("atomic writes with task mutations", () => {
it("logEntry() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-1", agentId: "agent-atomic" };
await store.logEntry(task.id, "Test action", undefined, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-1" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:log");
expect(events[0].target).toBe(task.id);
expect(events[0].metadata).toEqual({ action: "Test action", outcome: undefined });
// Verify the log entry was also added
const updatedTask = await store.getTask(task.id);
expect(updatedTask.log).toHaveLength(2); // "Task created" + "Test action"
});
it("addComment() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-2", agentId: "agent-atomic" };
await store.addComment(task.id, "Test comment", "user", undefined, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-2" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:comment");
expect(events[0].target).toBe(task.id);
// Verify the comment was also added
const updatedTask = await store.getTask(task.id);
expect(updatedTask.comments).toHaveLength(1);
expect(updatedTask.comments![0].text).toBe("Test comment");
});
it("pauseTask() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-3", agentId: "agent-atomic" };
await store.pauseTask(task.id, true, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-3" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:pause");
expect(events[0].target).toBe(task.id);
// Verify the task was paused
const updatedTask = await store.getTask(task.id);
expect(updatedTask.paused).toBe(true);
});
it("updateTask() with runContext records audit event atomically", async () => {
const task = await store.createTask({ description: "Test task for audit" });
const runContext = { runId: "run-atomic-4", agentId: "agent-atomic" };
await store.updateTask(task.id, { title: "Updated title" }, runContext);
// Verify the audit event was recorded
const events = store.getRunAuditEvents({ runId: "run-atomic-4" });
expect(events).toHaveLength(1);
expect(events[0].domain).toBe("database");
expect(events[0].mutationType).toBe("task:update");
expect(events[0].target).toBe(task.id);
expect(events[0].metadata).toEqual({ updatedFields: ["title"] });
// Verify the title was updated
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("Updated title");
});
it("methods without runContext do not record audit events (backward compat)", async () => {
// Use a unique description to identify our task's audit events
const uniqueDesc = "Test task backward compat unique " + Date.now();
const task = await store.createTask({ description: uniqueDesc });
// Get the current count of audit events before our operations
const eventsBefore = store.getRunAuditEvents();
const eventCountBefore = eventsBefore.length;
// No audit events should be recorded without runContext
await store.logEntry(task.id, "Test action without audit");
await store.addComment(task.id, "Test comment without audit", "user");
await store.pauseTask(task.id, true);
await store.updateTask(task.id, { title: "Updated without audit" });
// Verify no new audit events were recorded
const eventsAfter = store.getRunAuditEvents();
expect(eventsAfter.length).toBe(eventCountBefore);
// Verify the task operations succeeded
const updatedTask = await store.getTask(task.id);
expect(updatedTask.title).toBe("Updated without audit");
expect(updatedTask.comments).toHaveLength(1);
expect(updatedTask.paused).toBe(true);
});
it("rollback coverage: audit failure rolls back task mutation", () => {
// This test verifies that if audit recording fails, the task mutation is rolled back.
// We simulate this by directly testing the atomicWriteTaskJsonWithAudit behavior.
const invalidInput = {
agentId: "agent-1",
runId: "run-1",
domain: "invalid-domain" as any, // This will cause a constraint failure
mutationType: "test",
target: "test",
};
// Creating a task
const task = store.recordRunAuditEvent({
agentId: "agent-1",
runId: "run-rollback",
domain: "database",
mutationType: "task:create",
target: "test",
});
expect(task.id).toBeDefined();
});
});
describe("time-range filtering (inclusive bounds)", () => {
it("filters by startTime (inclusive)", () => {
const events = store.getRunAuditEvents({
startTime: "2025-01-01T02:00:00.000Z",
});
// Should include events at 02:00:00 and later
expect(events.length).toBeGreaterThan(0);
events.forEach((event) => {
const eventTime = new Date(event.timestamp).getTime();
const startTime = new Date("2025-01-01T02:00:00.000Z").getTime();
expect(eventTime).toBeGreaterThanOrEqual(startTime);
});
});
it("filters by endTime (inclusive)", () => {
const events = store.getRunAuditEvents({
endTime: "2025-01-01T02:00:00.000Z",
});
// Should include events at 02:00:00 and earlier
expect(events.length).toBeGreaterThan(0);
events.forEach((event) => {
const eventTime = new Date(event.timestamp).getTime();
const endTime = new Date("2025-01-01T02:00:00.000Z").getTime();
expect(eventTime).toBeLessThanOrEqual(endTime);
});
});
it("filters by startTime and endTime (inclusive range)", () => {
const events = store.getRunAuditEvents({
startTime: "2025-01-01T01:00:00.000Z",
endTime: "2025-01-01T03:00:00.000Z",
});
// Should include events at 01:00:00 through 03:00:00
expect(events.length).toBeGreaterThan(0);
events.forEach((event) => {
const eventTime = new Date(event.timestamp).getTime();
const startTime = new Date("2025-01-01T01:00:00.000Z").getTime();
const endTime = new Date("2025-01-01T03:00:00.000Z").getTime();
expect(eventTime).toBeGreaterThanOrEqual(startTime);
expect(eventTime).toBeLessThanOrEqual(endTime);
});
});
});
describe("deterministic ordering", () => {
it("orders by timestamp DESC, rowid DESC (newest first)", () => {
const events = store.getRunAuditEvents();
// Verify timestamps are in descending order
for (let i = 0; i < events.length - 1; i++) {
const current = new Date(events[i].timestamp).getTime();
const next = new Date(events[i + 1].timestamp).getTime();
expect(current).toBeGreaterThanOrEqual(next);
}
});
it("uses rowid as stable tiebreaker for same-timestamp events", () => {
// Insert two events with the same timestamp
store.recordRunAuditEvent({
timestamp: "2025-01-15T12:00:00.000Z",
agentId: "agent-x",
runId: "run-tie",
domain: "database",
mutationType: "event:first",
target: "t1",
});
store.recordRunAuditEvent({
timestamp: "2025-01-15T12:00:00.000Z",
agentId: "agent-y",
runId: "run-tie",
domain: "database",
mutationType: "event:second",
target: "t2",
});
const events = store.getRunAuditEvents({ runId: "run-tie" });
// Should be ordered by rowid DESC (second event first due to autoincrement)
expect(events[0].mutationType).toBe("event:second");
expect(events[1].mutationType).toBe("event:first");
});
});
});
describe("database schema", () => {
it("creates runAuditEvents table and indexes", () => {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>;
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("runAuditEvents");
const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%'")
.all() as Array<{ name: string }>;
const indexNames = indexes.map((i) => i.name);
expect(indexNames).toContain("idxRunAuditEventsRunIdTimestamp");
expect(indexNames).toContain("idxRunAuditEventsTaskIdTimestamp");
expect(indexNames).toContain("idxRunAuditEventsTimestamp");
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(45);
});
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { runCommandAsync } from "../run-command.js";
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("runCommandAsync", () => {
it("terminates background children left in the command process group", async () => {
if (process.platform === "win32") {
return;
}
const childScript = "setInterval(() => {}, 1000)";
const parentScript = [
"const { spawn } = require('node:child_process');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
"console.log(child.pid);",
"child.unref();",
].join(" ");
const result = await runCommandAsync(
`${process.execPath} -e ${JSON.stringify(parentScript)}`,
{ timeoutMs: 5_000 },
);
expect(result.exitCode).toBe(0);
const leakedPid = Number.parseInt(result.stdout.trim(), 10);
expect(Number.isFinite(leakedPid)).toBe(true);
for (let i = 0; i < 10 && isProcessAlive(leakedPid); i++) {
await sleep(100);
}
expect(isProcessAlive(leakedPid)).toBe(false);
});
});

View File

@@ -0,0 +1,549 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TaskStore } from "../store.js";
import type { GlobalSettingsStore } from "../global-settings.js";
import type { Settings, GlobalSettings, ProjectSettings } from "../types.js";
import {
exportSettings,
importSettings,
validateImportData,
generateExportFilename,
readExportFile,
writeExportFile,
type SettingsExportData,
type ExportSettingsOptions,
type ImportSettingsOptions,
} from "../settings-export.js";
// Helper to create a temporary test environment
function createTestEnv() {
const tempDir = mkdtempSync(join(tmpdir(), "kb-settings-test-"));
const fusionDir = join(tempDir, ".fusion");
const tasksDir = join(fusionDir, "tasks");
const globalSettingsDir = join(tempDir, "global-settings");
mkdirSync(tasksDir, { recursive: true });
mkdirSync(globalSettingsDir, { recursive: true });
// Create initial config.json
writeFileSync(
join(fusionDir, "config.json"),
JSON.stringify({ nextId: 1, settings: {} }),
);
// Create initial global settings
writeFileSync(
join(globalSettingsDir, "settings.json"),
JSON.stringify({}),
);
return { tempDir, fusionDir, tasksDir, globalSettingsDir };
}
// Helper to clean up test environment
function cleanupTestEnv(tempDir: string) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
describe("settings-export", () => {
let env: ReturnType<typeof createTestEnv>;
let store: TaskStore;
beforeEach(async () => {
env = createTestEnv();
const { TaskStore } = await import("../store.js");
store = new TaskStore(env.tempDir, env.globalSettingsDir);
await store.init();
});
afterEach(() => {
cleanupTestEnv(env.tempDir);
});
describe("generateExportFilename", () => {
it("should generate filename with correct format", () => {
const date = new Date("2026-03-31T12:34:56Z");
const filename = generateExportFilename(date);
expect(filename).toBe("fusion-settings-2026-03-31-123456.json");
});
it("should use current date by default", () => {
const before = new Date();
const filename = generateExportFilename();
const after = new Date();
expect(filename).toMatch(/^fusion-settings-\d{4}-\d{2}-\d{2}-\d{6}\.json$/);
// Parse the timestamp from filename
const match = filename.match(/(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})/);
expect(match).not.toBeNull();
if (match) {
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10) - 1;
const day = parseInt(match[3], 10);
const hour = parseInt(match[4], 10);
const minute = parseInt(match[5], 10);
const second = parseInt(match[6], 10);
const fileDate = new Date(Date.UTC(year, month, day, hour, minute, second));
expect(fileDate.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
expect(fileDate.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
}
});
});
describe("validateImportData", () => {
it("should return empty array for valid data with both scopes", () => {
const data: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "dark", ntfyEnabled: true },
project: { maxConcurrent: 4 },
};
expect(validateImportData(data)).toEqual([]);
});
it("should return empty array for valid data with only global", () => {
const data: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "light" },
};
expect(validateImportData(data)).toEqual([]);
});
it("should return empty array for valid data with only project", () => {
const data: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: { maxWorktrees: 8 },
};
expect(validateImportData(data)).toEqual([]);
});
it("should return error for null data", () => {
expect(validateImportData(null)).toEqual([
"Import data must be a valid JSON object",
]);
});
it("should return error for non-object data", () => {
expect(validateImportData("string")).toEqual([
"Import data must be a valid JSON object",
]);
});
it("should return error for wrong version", () => {
const data = {
version: 2,
exportedAt: new Date().toISOString(),
global: {},
};
expect(validateImportData(data)).toContain(
"Unsupported export version: 2. Expected: 1"
);
});
it("should return error for missing exportedAt", () => {
const data = {
version: 1,
global: {},
};
expect(validateImportData(data)).toContain(
"Missing or invalid 'exportedAt' field"
);
});
it("should return error when both scopes are missing", () => {
const data = {
version: 1,
exportedAt: new Date().toISOString(),
};
expect(validateImportData(data)).toContain(
"Export data must contain at least one of 'global' or 'project' settings"
);
});
it("should return error for invalid global type", () => {
const data = {
version: 1,
exportedAt: new Date().toISOString(),
global: "invalid",
};
expect(validateImportData(data)).toContain(
"'global' field must be an object if provided"
);
});
it("should return error for invalid project type", () => {
const data = {
version: 1,
exportedAt: new Date().toISOString(),
project: "invalid",
};
expect(validateImportData(data)).toContain(
"'project' field must be an object if provided"
);
});
});
describe("exportSettings", () => {
it("should export both scopes by default", async () => {
// Set up some test settings
await store.updateGlobalSettings({ themeMode: "dark", ntfyEnabled: true });
await store.updateSettings({ maxConcurrent: 4, maxWorktrees: 6 });
const result = await exportSettings(store);
expect(result.version).toBe(1);
expect(result.exportedAt).toBeDefined();
expect(result.global).toBeDefined();
expect(result.global?.themeMode).toBe("dark");
expect(result.global?.ntfyEnabled).toBe(true);
expect(result.project).toBeDefined();
expect(result.project?.maxConcurrent).toBe(4);
expect(result.project?.maxWorktrees).toBe(6);
});
it("should export only global scope when specified", async () => {
await store.updateGlobalSettings({ themeMode: "light" });
await store.updateSettings({ maxConcurrent: 2 });
const result = await exportSettings(store, { scope: "global" });
expect(result.global).toBeDefined();
expect(result.global?.themeMode).toBe("light");
expect(result.project).toBeUndefined();
});
it("should export only project scope when specified", async () => {
await store.updateGlobalSettings({ themeMode: "light" });
await store.updateSettings({ maxConcurrent: 3 });
const result = await exportSettings(store, { scope: "project" });
expect(result.project).toBeDefined();
expect(result.project?.maxConcurrent).toBe(3);
expect(result.global).toBeUndefined();
});
it("should include source in export metadata", async () => {
const result = await exportSettings(store, { source: "my-laptop" });
expect(result.source).toBe("my-laptop");
});
});
describe("importSettings", () => {
it("should import global settings in merge mode", async () => {
// Set initial settings
await store.updateGlobalSettings({ themeMode: "dark" });
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "light", ntfyEnabled: true },
};
const result = await importSettings(store, importData, { scope: "global", merge: true });
expect(result.success).toBe(true);
expect(result.globalCount).toBe(2);
expect(result.projectCount).toBe(0);
// Verify settings were applied
const globalSettings = await store.getGlobalSettingsStore().getSettings();
expect(globalSettings.themeMode).toBe("light");
expect(globalSettings.ntfyEnabled).toBe(true);
});
it("should import project settings in merge mode", async () => {
// Set initial settings
await store.updateSettings({ maxConcurrent: 2 });
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: { maxConcurrent: 6, maxWorktrees: 10 },
};
const result = await importSettings(store, importData, { scope: "project", merge: true });
expect(result.success).toBe(true);
expect(result.globalCount).toBe(0);
expect(result.projectCount).toBe(2);
// Verify settings were applied
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(6);
expect(settings.maxWorktrees).toBe(10);
});
it("should import both scopes", async () => {
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "dark" },
project: { maxConcurrent: 5 },
};
const result = await importSettings(store, importData, { scope: "both" });
expect(result.success).toBe(true);
expect(result.globalCount).toBe(1);
expect(result.projectCount).toBe(1);
});
it("should skip undefined values in merge mode", async () => {
await store.updateGlobalSettings({ themeMode: "dark", ntfyEnabled: true });
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "light", ntfyTopic: undefined },
};
const result = await importSettings(store, importData, { scope: "global", merge: true });
expect(result.success).toBe(true);
expect(result.globalCount).toBe(1); // Only themeMode is defined
const settings = await store.getGlobalSettingsStore().getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.ntfyEnabled).toBe(true); // Preserved from original
});
it("should handle replace mode", async () => {
// Set initial settings
await store.updateGlobalSettings({ themeMode: "dark", ntfyEnabled: true });
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "light" },
};
const result = await importSettings(store, importData, { scope: "global", merge: false });
expect(result.success).toBe(true);
expect(result.globalCount).toBe(1);
const settings = await store.getGlobalSettingsStore().getSettings();
expect(settings.themeMode).toBe("light");
});
it("should fail with validation errors for invalid data", async () => {
const importData = {
version: 2,
exportedAt: new Date().toISOString(),
global: {},
} as unknown as SettingsExportData;
const result = await importSettings(store, importData);
expect(result.success).toBe(false);
expect(result.error).toContain("Unsupported export version: 2");
});
it("should handle import errors gracefully", async () => {
// Close the store to simulate an error
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "dark" },
};
// Force an error by passing a closed/invalid store
// This should be caught and returned as an error result
const result = await importSettings(store, importData, { scope: "global" });
// The operation should complete (success depends on store state)
expect(result).toHaveProperty("success");
expect(result).toHaveProperty("globalCount");
expect(result).toHaveProperty("projectCount");
});
it("should respect scope option", async () => {
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "light" },
project: { maxConcurrent: 8 },
};
// Import only global
const globalResult = await importSettings(store, importData, { scope: "global" });
expect(globalResult.globalCount).toBe(1);
expect(globalResult.projectCount).toBe(0);
// Reset and import only project
const projectResult = await importSettings(store, importData, { scope: "project" });
expect(projectResult.globalCount).toBe(0);
expect(projectResult.projectCount).toBe(1);
});
});
describe("promptOverrides export/import", () => {
it("should export promptOverrides when set", async () => {
await store.updateSettings({
promptOverrides: { "executor-welcome": "Custom welcome" },
});
const result = await exportSettings(store, { scope: "project" });
expect(result.project?.promptOverrides).toEqual({ "executor-welcome": "Custom welcome" });
});
it("should not export promptOverrides when not set", async () => {
const result = await exportSettings(store, { scope: "project" });
expect(result.project?.promptOverrides).toBeUndefined();
});
it("should import promptOverrides in merge mode", async () => {
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: { "executor-welcome": "Imported welcome" },
},
};
const result = await importSettings(store, importData, { scope: "project", merge: true });
expect(result.success).toBe(true);
expect(result.projectCount).toBe(1);
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({ "executor-welcome": "Imported welcome" });
});
it("should merge promptOverrides with existing overrides", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: { "executor-welcome": "Original" },
});
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: { "triage-welcome": "Imported triage" },
},
};
await importSettings(store, importData, { scope: "project", merge: true });
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({
"executor-welcome": "Original",
"triage-welcome": "Imported triage",
});
});
it("should clear promptOverrides when importing null", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: { "executor-welcome": "Original", "triage-welcome": "Triage" },
});
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: null as any,
},
};
await importSettings(store, importData, { scope: "project", merge: true });
const settings = await store.getSettings();
expect(settings.promptOverrides).toBeUndefined();
});
it("should clear specific promptOverride key when importing null value", async () => {
// Set initial overrides
await store.updateSettings({
promptOverrides: { "executor-welcome": "Original", "triage-welcome": "Triage" },
});
const importData: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
project: {
promptOverrides: { "executor-welcome": null as unknown as string },
},
};
await importSettings(store, importData, { scope: "project", merge: true });
const settings = await store.getSettings();
expect(settings.promptOverrides).toEqual({ "triage-welcome": "Triage" });
});
});
describe("readExportFile", () => {
it("should read and parse valid export file", async () => {
const filePath = join(env.tempDir, "test-export.json");
const data: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
global: { themeMode: "dark" },
};
await writeExportFile(filePath, data);
const result = await readExportFile(filePath);
expect(result.version).toBe(1);
expect(result.global?.themeMode).toBe("dark");
});
it("should throw error for invalid JSON", async () => {
const filePath = join(env.tempDir, "invalid.json");
writeFileSync(filePath, "not valid json");
await expect(readExportFile(filePath)).rejects.toThrow("Failed to parse JSON");
});
it("should throw error for non-existent file", async () => {
const filePath = join(env.tempDir, "non-existent.json");
await expect(readExportFile(filePath)).rejects.toThrow();
});
});
describe("writeExportFile", () => {
it("should write data to file atomically", async () => {
const filePath = join(env.tempDir, "export-test.json");
const data: SettingsExportData = {
version: 1,
exportedAt: "2026-03-31T12:00:00Z",
global: { themeMode: "dark" },
};
await writeExportFile(filePath, data);
const content = await readExportFile(filePath);
expect(content.version).toBe(1);
expect(content.exportedAt).toBe("2026-03-31T12:00:00Z");
});
it("should create parent directories if needed", async () => {
const filePath = join(env.tempDir, "subdir", "export-test.json");
const data: SettingsExportData = {
version: 1,
exportedAt: new Date().toISOString(),
};
mkdirSync(join(env.tempDir, "subdir"), { recursive: true });
await writeExportFile(filePath, data);
expect(existsSync(filePath)).toBe(true);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { collectSystemMetrics } from "../system-metrics.js";
const { checkDiskSpaceMock, cpusMock, totalmemMock, freememMock, uptimeMock } = vi.hoisted(() => ({
checkDiskSpaceMock: vi.fn(),
cpusMock: vi.fn(),
totalmemMock: vi.fn(),
freememMock: vi.fn(),
uptimeMock: vi.fn(),
}));
vi.mock("check-disk-space", () => ({
default: checkDiskSpaceMock,
}));
vi.mock("node:os", () => ({
cpus: cpusMock,
totalmem: totalmemMock,
freemem: freememMock,
uptime: uptimeMock,
}));
describe("collectSystemMetrics", () => {
beforeEach(() => {
vi.clearAllMocks();
cpusMock.mockReturnValue([
{
times: {
user: 100,
nice: 0,
sys: 50,
idle: 150,
irq: 0,
},
},
]);
totalmemMock.mockReturnValue(16_000);
freememMock.mockReturnValue(6_000);
uptimeMock.mockReturnValue(12.345);
checkDiskSpaceMock.mockResolvedValue({
diskPath: "/",
free: 250_000,
size: 1_000_000,
});
});
it("returns a valid SystemMetrics object", async () => {
const metrics = await collectSystemMetrics();
expect(metrics).toEqual(
expect.objectContaining({
cpuUsage: expect.any(Number),
memoryUsed: expect.any(Number),
memoryTotal: expect.any(Number),
storageUsed: expect.any(Number),
storageTotal: expect.any(Number),
uptime: expect.any(Number),
reportedAt: expect.any(String),
}),
);
});
it("returns cpuUsage between 0 and 100", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.cpuUsage).toBeGreaterThanOrEqual(0);
expect(metrics.cpuUsage).toBeLessThanOrEqual(100);
});
it("returns memoryUsed less than or equal to memoryTotal", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.memoryUsed).toBeLessThanOrEqual(metrics.memoryTotal);
});
it("returns storageUsed less than or equal to storageTotal", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.storageUsed).toBeLessThanOrEqual(metrics.storageTotal);
});
it("returns uptime greater than 0", async () => {
const metrics = await collectSystemMetrics();
expect(metrics.uptime).toBeGreaterThan(0);
});
it("returns a valid ISO timestamp in reportedAt", async () => {
const metrics = await collectSystemMetrics();
expect(new Date(metrics.reportedAt).toISOString()).toBe(metrics.reportedAt);
});
it("passes dbPath through to check-disk-space", async () => {
const customPath = "/tmp/kb-metrics-db";
await collectSystemMetrics(customPath);
expect(checkDiskSpaceMock).toHaveBeenCalledWith(customPath);
});
});

View File

@@ -0,0 +1,327 @@
import { describe, it, expect } from "vitest";
import type { StepStatus } from "../types.js";
import { getTaskCompletionBlocker, getTaskMergeBlocker, isTaskReadyForMerge } from "../task-merge.js";
const baseTask = {
column: "in-review" as const,
paused: false,
status: undefined as string | undefined,
error: undefined as string | undefined,
steps: [] as Array<{ name: string; status: StepStatus }>,
workflowStepResults: undefined as any,
};
const baseCompletionTask = {
dependencies: [] as string[],
blockedBy: undefined as string | undefined,
};
describe("getTaskMergeBlocker", () => {
it("returns undefined for a clean task in review", () => {
expect(getTaskMergeBlocker(baseTask)).toBeUndefined();
});
it("returns reason when task is not in review", () => {
expect(getTaskMergeBlocker({ ...baseTask, column: "todo" }))
.toContain("must be in 'in-review'");
});
it("returns reason when task is paused", () => {
expect(getTaskMergeBlocker({ ...baseTask, paused: true }))
.toBe("task is paused");
});
it("returns reason when task has failed status", () => {
expect(getTaskMergeBlocker({ ...baseTask, status: "failed" }))
.toContain("failed");
});
it("returns reason when task has awaiting-user-review status", () => {
expect(getTaskMergeBlocker({ ...baseTask, status: "awaiting-user-review" }))
.toContain("awaiting-user-review");
});
it("returns reason when task has awaiting-inspection status", () => {
expect(getTaskMergeBlocker({ ...baseTask, status: "awaiting-inspection" }))
.toContain("awaiting-inspection");
});
it("returns reason when task has incomplete steps", () => {
expect(getTaskMergeBlocker({
...baseTask,
steps: [{ name: "Step 1", status: "in-progress" }],
})).toBe("task has incomplete steps");
});
// ── Workflow Step Phase Awareness ──────────────────────────────────────
it("blocks merge when pre-merge workflow step has failed", () => {
const result = getTaskMergeBlocker({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "failed",
output: "Check failed",
}],
});
expect(result).toContain("pre-merge workflow steps");
});
it("blocks merge when legacy workflow step (no phase) has failed", () => {
const result = getTaskMergeBlocker({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Legacy Check",
// phase is undefined → treated as pre-merge
status: "failed",
output: "Check failed",
}],
});
expect(result).toContain("pre-merge workflow steps");
});
it("does NOT block merge when only post-merge workflow step has failed", () => {
const result = getTaskMergeBlocker({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Post-merge Notify",
phase: "post-merge",
status: "failed",
output: "Notification failed",
}],
});
expect(result).toBeUndefined();
});
it("does NOT block merge when pre-merge passed and post-merge failed", () => {
const result = getTaskMergeBlocker({
...baseTask,
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "passed",
},
{
workflowStepId: "WS-002",
workflowStepName: "Post-merge Notify",
phase: "post-merge",
status: "failed",
output: "Failed",
},
],
});
expect(result).toBeUndefined();
});
it("blocks merge when pre-merge step is still pending", () => {
const result = getTaskMergeBlocker({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "pending",
}],
});
expect(result).toContain("pre-merge workflow steps");
});
it("does NOT block merge when only post-merge step is pending", () => {
const result = getTaskMergeBlocker({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Post-merge Notify",
phase: "post-merge",
status: "pending",
}],
});
expect(result).toBeUndefined();
});
it("allows merge when all pre-merge steps passed regardless of post-merge status", () => {
const result = getTaskMergeBlocker({
...baseTask,
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "passed",
},
{
workflowStepId: "WS-002",
workflowStepName: "Post-merge Verify",
phase: "post-merge",
status: "skipped",
},
],
});
expect(result).toBeUndefined();
});
});
describe("isTaskReadyForMerge", () => {
it("returns true for a clean task in review", () => {
expect(isTaskReadyForMerge(baseTask)).toBe(true);
});
it("returns false when pre-merge step failed", () => {
expect(isTaskReadyForMerge({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Check",
phase: "pre-merge",
status: "failed",
}],
})).toBe(false);
});
it("returns true when only post-merge step failed", () => {
expect(isTaskReadyForMerge({
...baseTask,
workflowStepResults: [{
workflowStepId: "WS-001",
workflowStepName: "Notify",
phase: "post-merge",
status: "failed",
}],
})).toBe(true);
});
});
describe("getTaskCompletionBlocker", () => {
it("returns undefined for a task with no blockers", async () => {
await expect(getTaskCompletionBlocker(baseCompletionTask)).resolves.toBeUndefined();
});
it("returns a reason when task has blockedBy", async () => {
await expect(getTaskCompletionBlocker({ ...baseCompletionTask, blockedBy: "FN-123" }))
.resolves.toBe("task is blocked by FN-123");
});
it("returns a reason when a dependency is unresolved", async () => {
const resolveTask = async (taskId: string) => {
if (taskId === "FN-001") {
return { id: "FN-001", column: "done" as const };
}
if (taskId === "FN-002") {
return { id: "FN-002", column: "in-progress" as const };
}
return null;
};
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-001", "FN-002"],
}, { resolveTask }))
.resolves.toBe("task has unresolved dependencies: FN-002");
});
it("returns undefined when all dependencies are resolved", async () => {
const resolveTask = async (taskId: string) => ({ id: taskId, column: "done" as const });
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-001", "FN-002"],
}, { resolveTask }))
.resolves.toBeUndefined();
});
// ── in-review as resolved dependency ───────────────────────────────────
it("returns undefined when a dependency is in-review", async () => {
const resolveTask = async (taskId: string) => {
if (taskId === "FN-001") {
return { id: "FN-001", column: "in-review" as const };
}
return null;
};
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-001"],
}, { resolveTask }))
.resolves.toBeUndefined();
});
it("returns undefined when dependencies are a mix of done and in-review", async () => {
const resolveTask = async (taskId: string) => {
if (taskId === "FN-001") {
return { id: "FN-001", column: "done" as const };
}
if (taskId === "FN-002") {
return { id: "FN-002", column: "in-review" as const };
}
return null;
};
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-001", "FN-002"],
}, { resolveTask }))
.resolves.toBeUndefined();
});
it("returns a reason when a dependency is in-progress", async () => {
const resolveTask = async (taskId: string) => {
if (taskId === "FN-001") {
return { id: "FN-001", column: "in-progress" as const };
}
return null;
};
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-001"],
}, { resolveTask }))
.resolves.toBe("task has unresolved dependencies: FN-001");
});
it("returns a reason when a dependency is in triage", async () => {
const resolveTask = async (taskId: string) => {
if (taskId === "FN-001") {
return { id: "FN-001", column: "triage" as const };
}
return null;
};
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-001"],
}, { resolveTask }))
.resolves.toBe("task has unresolved dependencies: FN-001");
});
it("returns a reason when a dependency is in todo", async () => {
const resolveTask = async (taskId: string) => {
if (taskId === "FN-001") {
return { id: "FN-001", column: "todo" as const };
}
return null;
};
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-001"],
}, { resolveTask }))
.resolves.toBe("task has unresolved dependencies: FN-001");
});
it("returns a reason when a dependency task does not exist", async () => {
const resolveTask = async (_taskId: string) => null;
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
dependencies: ["FN-999"],
}, { resolveTask }))
.resolves.toBe("task has unresolved dependencies: FN-999");
});
});

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
compareTaskPriority,
compareTasksByPriorityThenAgeAndId,
getTaskPriorityRank,
isTaskPriority,
normalizeTaskPriority,
sortTasksByPriorityThenAgeAndId,
} from "../task-priority.js";
import {
DEFAULT_TASK_PRIORITY,
TASK_PRIORITIES,
type TaskPriority,
} from "../types.js";
import * as core from "../index.js";
describe("task-priority", () => {
it("defines the bounded priority contract in order", () => {
expect(TASK_PRIORITIES).toEqual(["low", "normal", "high", "urgent"]);
expect(DEFAULT_TASK_PRIORITY).toBe("normal");
});
it("normalizes missing or invalid values to default", () => {
expect(normalizeTaskPriority(undefined)).toBe("normal");
expect(normalizeTaskPriority(null)).toBe("normal");
expect(normalizeTaskPriority("")).toBe("normal");
});
it("identifies valid task priorities", () => {
for (const value of TASK_PRIORITIES) {
expect(isTaskPriority(value)).toBe(true);
}
expect(isTaskPriority("in_progress")).toBe(false);
});
it("provides deterministic ranks and priority comparator", () => {
const orderedByRank: TaskPriority[] = ["low", "normal", "high", "urgent"];
expect(orderedByRank.map((priority) => getTaskPriorityRank(priority))).toEqual([0, 1, 2, 3]);
expect(compareTaskPriority("urgent", "low")).toBeLessThan(0);
expect(compareTaskPriority(undefined, "normal")).toBe(0);
});
it("sorts tasks by priority desc then createdAt asc then id asc", () => {
const tasks = [
{ id: "FN-002", createdAt: "2026-01-01T00:00:00.000Z", priority: "high" as TaskPriority },
{ id: "FN-001", createdAt: "2026-01-01T00:00:00.000Z", priority: "high" as TaskPriority },
{ id: "FN-009", createdAt: "2026-01-02T00:00:00.000Z", priority: "urgent" as TaskPriority },
{ id: "FN-003", createdAt: "2026-01-01T00:00:00.000Z", priority: undefined },
];
const sorted = sortTasksByPriorityThenAgeAndId(tasks);
expect(sorted.map((task) => task.id)).toEqual(["FN-009", "FN-001", "FN-002", "FN-003"]);
// comparator function should match sorted behavior
expect(compareTasksByPriorityThenAgeAndId(tasks[0], tasks[1])).toBeGreaterThan(0);
});
it("re-exports priority helpers from the core index", () => {
expect(core.TASK_PRIORITIES).toEqual(TASK_PRIORITIES);
expect(core.DEFAULT_TASK_PRIORITY).toBe("normal");
expect(core.normalizeTaskPriority("bogus")).toBe(DEFAULT_TASK_PRIORITY);
});
});