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:
255
packages/core/src/__tests__/agent-companies-exporter.test.ts
Normal file
255
packages/core/src/__tests__/agent-companies-exporter.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
701
packages/core/src/__tests__/agent-companies-parser.test.ts
Normal file
701
packages/core/src/__tests__/agent-companies-parser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
115
packages/core/src/__tests__/agent-companies-types.test.ts
Normal file
115
packages/core/src/__tests__/agent-companies-types.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
181
packages/core/src/__tests__/agent-permissions.test.ts
Normal file
181
packages/core/src/__tests__/agent-permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
407
packages/core/src/__tests__/agent-prompts.test.ts
Normal file
407
packages/core/src/__tests__/agent-prompts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
2566
packages/core/src/__tests__/agent-store.test.ts
Normal file
2566
packages/core/src/__tests__/agent-store.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
211
packages/core/src/__tests__/ai-summarize.test.ts
Normal file
211
packages/core/src/__tests__/ai-summarize.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
126
packages/core/src/__tests__/app-version.test.ts
Normal file
126
packages/core/src/__tests__/app-version.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
949
packages/core/src/__tests__/automation-store.test.ts
Normal file
949
packages/core/src/__tests__/automation-store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
340
packages/core/src/__tests__/automation.test.ts
Normal file
340
packages/core/src/__tests__/automation.test.ts
Normal 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());
|
||||
}
|
||||
});
|
||||
});
|
||||
643
packages/core/src/__tests__/backup.test.ts
Normal file
643
packages/core/src/__tests__/backup.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
278
packages/core/src/__tests__/board.test.ts
Normal file
278
packages/core/src/__tests__/board.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
2761
packages/core/src/__tests__/central-core.test.ts
Normal file
2761
packages/core/src/__tests__/central-core.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
545
packages/core/src/__tests__/central-db.test.ts
Normal file
545
packages/core/src/__tests__/central-db.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
293
packages/core/src/__tests__/daemon-token.test.ts
Normal file
293
packages/core/src/__tests__/daemon-token.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
648
packages/core/src/__tests__/db-migrate.test.ts
Normal file
648
packages/core/src/__tests__/db-migrate.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
1509
packages/core/src/__tests__/db.test.ts
Normal file
1509
packages/core/src/__tests__/db.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
283
packages/core/src/__tests__/fts5-guard.test.ts
Normal file
283
packages/core/src/__tests__/fts5-guard.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
243
packages/core/src/__tests__/gh-cli.test.ts
Normal file
243
packages/core/src/__tests__/gh-cli.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
771
packages/core/src/__tests__/global-settings.test.ts
Normal file
771
packages/core/src/__tests__/global-settings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
856
packages/core/src/__tests__/insight-store.test.ts
Normal file
856
packages/core/src/__tests__/insight-store.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
43
packages/core/src/__tests__/logger.test.ts
Normal file
43
packages/core/src/__tests__/logger.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1187
packages/core/src/__tests__/memory-backend.test.ts
Normal file
1187
packages/core/src/__tests__/memory-backend.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
327
packages/core/src/__tests__/memory-compaction.test.ts
Normal file
327
packages/core/src/__tests__/memory-compaction.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
132
packages/core/src/__tests__/memory-dreams.test.ts
Normal file
132
packages/core/src/__tests__/memory-dreams.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
1479
packages/core/src/__tests__/memory-insights.test.ts
Normal file
1479
packages/core/src/__tests__/memory-insights.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
661
packages/core/src/__tests__/message-store.test.ts
Normal file
661
packages/core/src/__tests__/message-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
745
packages/core/src/__tests__/migration.test.ts
Normal file
745
packages/core/src/__tests__/migration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
572
packages/core/src/__tests__/mission-integration.test.ts
Normal file
572
packages/core/src/__tests__/mission-integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
3038
packages/core/src/__tests__/mission-store.test.ts
Normal file
3038
packages/core/src/__tests__/mission-store.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
409
packages/core/src/__tests__/node-connection.test.ts
Normal file
409
packages/core/src/__tests__/node-connection.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
445
packages/core/src/__tests__/node-discovery.test.ts
Normal file
445
packages/core/src/__tests__/node-discovery.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1563
packages/core/src/__tests__/plugin-loader.test.ts
Normal file
1563
packages/core/src/__tests__/plugin-loader.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
695
packages/core/src/__tests__/plugin-store.test.ts
Normal file
695
packages/core/src/__tests__/plugin-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
993
packages/core/src/__tests__/plugin-types.test.ts
Normal file
993
packages/core/src/__tests__/plugin-types.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
652
packages/core/src/__tests__/project-memory.test.ts
Normal file
652
packages/core/src/__tests__/project-memory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
383
packages/core/src/__tests__/prompt-overrides.test.ts
Normal file
383
packages/core/src/__tests__/prompt-overrides.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
511
packages/core/src/__tests__/reflection-store.test.ts
Normal file
511
packages/core/src/__tests__/reflection-store.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
445
packages/core/src/__tests__/roadmap-handoff.test.ts
Normal file
445
packages/core/src/__tests__/roadmap-handoff.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
334
packages/core/src/__tests__/roadmap-ordering.test.ts
Normal file
334
packages/core/src/__tests__/roadmap-ordering.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1313
packages/core/src/__tests__/roadmap-store.test.ts
Normal file
1313
packages/core/src/__tests__/roadmap-store.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
863
packages/core/src/__tests__/routine-store.test.ts
Normal file
863
packages/core/src/__tests__/routine-store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
560
packages/core/src/__tests__/run-audit.integration.test.ts
Normal file
560
packages/core/src/__tests__/run-audit.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
471
packages/core/src/__tests__/run-audit.test.ts
Normal file
471
packages/core/src/__tests__/run-audit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/core/src/__tests__/run-command.test.ts
Normal file
46
packages/core/src/__tests__/run-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
549
packages/core/src/__tests__/settings-export.test.ts
Normal file
549
packages/core/src/__tests__/settings-export.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
9823
packages/core/src/__tests__/store.test.ts
Normal file
9823
packages/core/src/__tests__/store.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
96
packages/core/src/__tests__/system-metrics.test.ts
Normal file
96
packages/core/src/__tests__/system-metrics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
327
packages/core/src/__tests__/task-merge.test.ts
Normal file
327
packages/core/src/__tests__/task-merge.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
63
packages/core/src/__tests__/task-priority.test.ts
Normal file
63
packages/core/src/__tests__/task-priority.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user