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:
@@ -9,10 +9,10 @@ import {
|
||||
pullNodeSettings,
|
||||
fetchNodeSettingsSyncStatus,
|
||||
syncNodeAuth,
|
||||
} from "./api-node";
|
||||
import * as apiModule from "./api";
|
||||
} from "../api-node";
|
||||
import * as apiModule from "../api";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
vi.mock("../api", () => ({
|
||||
proxyApi: vi.fn(),
|
||||
api: vi.fn(),
|
||||
}));
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type DiscoveredSkill,
|
||||
type CatalogFetchResult,
|
||||
type ToggleSkillResult,
|
||||
} from "./api";
|
||||
} from "../api";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
@@ -73,9 +73,9 @@ import {
|
||||
type GlobalConcurrencyState,
|
||||
type ExecutorStats,
|
||||
type ExecutorState,
|
||||
} from "./api";
|
||||
} from "../api";
|
||||
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
|
||||
import { clearAuthToken } from "./auth";
|
||||
import { clearAuthToken } from "../auth";
|
||||
|
||||
const FAKE_DETAIL: TaskDetail = {
|
||||
id: "FN-001",
|
||||
@@ -688,7 +688,7 @@ describe("batchUpdateTaskModels", () => {
|
||||
mockFetchResponse(true, mockResponse)
|
||||
);
|
||||
|
||||
const { batchUpdateTaskModels } = await import("./api");
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
const result = await batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o");
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
@@ -713,7 +713,7 @@ describe("batchUpdateTaskModels", () => {
|
||||
mockFetchResponse(true, mockResponse)
|
||||
);
|
||||
|
||||
const { batchUpdateTaskModels } = await import("./api");
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
await batchUpdateTaskModels(
|
||||
["FN-001", "FN-002"],
|
||||
undefined,
|
||||
@@ -740,7 +740,7 @@ describe("batchUpdateTaskModels", () => {
|
||||
mockFetchResponse(true, mockResponse)
|
||||
);
|
||||
|
||||
const { batchUpdateTaskModels } = await import("./api");
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
await batchUpdateTaskModels(["FN-001"], null, null);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
@@ -760,7 +760,7 @@ describe("batchUpdateTaskModels", () => {
|
||||
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)
|
||||
);
|
||||
|
||||
const { batchUpdateTaskModels } = await import("./api");
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
await expect(batchUpdateTaskModels([], "openai", "gpt-4o")).rejects.toThrow(
|
||||
"taskIds must be an array"
|
||||
);
|
||||
@@ -771,7 +771,7 @@ describe("batchUpdateTaskModels", () => {
|
||||
mockFetchResponse(false, { error: "Task KB-999 not found" }, 404)
|
||||
);
|
||||
|
||||
const { batchUpdateTaskModels } = await import("./api");
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
await expect(batchUpdateTaskModels(["KB-999"], "openai", "gpt-4o")).rejects.toThrow(
|
||||
"Task KB-999 not found"
|
||||
);
|
||||
@@ -780,7 +780,7 @@ describe("batchUpdateTaskModels", () => {
|
||||
it("throws on network error", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network failed"));
|
||||
|
||||
const { batchUpdateTaskModels } = await import("./api");
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
await expect(batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o")).rejects.toThrow(
|
||||
"Network failed"
|
||||
);
|
||||
@@ -961,7 +961,7 @@ import {
|
||||
removeGitRemote,
|
||||
renameGitRemote,
|
||||
updateGitRemoteUrl,
|
||||
} from "./api";
|
||||
} from "../api";
|
||||
|
||||
describe("fetchGitRemotesDetailed", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -1147,7 +1147,7 @@ describe("updateGitRemoteUrl", () => {
|
||||
|
||||
// --- Plan approval API tests ---
|
||||
|
||||
import { approvePlan, rejectPlan } from "./api";
|
||||
import { approvePlan, rejectPlan } from "../api";
|
||||
|
||||
describe("approvePlan", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -1289,7 +1289,7 @@ import {
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "./api";
|
||||
} from "../api";
|
||||
|
||||
describe("agent API wrappers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -1418,7 +1418,7 @@ describe("fetchAgentChildren", () => {
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockChildren));
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
const { fetchAgentChildren } = await import("../api");
|
||||
const result = await fetchAgentChildren("agent-001");
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
@@ -1431,7 +1431,7 @@ describe("fetchAgentChildren", () => {
|
||||
it("passes projectId as query param", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
const { fetchAgentChildren } = await import("../api");
|
||||
await fetchAgentChildren("agent-001", "proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children?projectId=proj_123", {
|
||||
@@ -1444,7 +1444,7 @@ describe("fetchAgentChildren", () => {
|
||||
mockFetchResponse(false, { error: "Agent not found" }, 404),
|
||||
);
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
const { fetchAgentChildren } = await import("../api");
|
||||
const result = await fetchAgentChildren("agent-999");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
@@ -1455,7 +1455,7 @@ describe("fetchAgentChildren", () => {
|
||||
mockFetchResponse(false, { error: "Internal server error" }, 500),
|
||||
);
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
const { fetchAgentChildren } = await import("../api");
|
||||
await expect(fetchAgentChildren("agent-001")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
});
|
||||
@@ -2135,7 +2135,7 @@ describe("Git Management API", () => {
|
||||
|
||||
// --- Planning Mode API Tests ---
|
||||
|
||||
import { startPlanning, respondToPlanning, cancelPlanning, createTaskFromPlanning } from "./api";
|
||||
import { startPlanning, respondToPlanning, cancelPlanning, createTaskFromPlanning } from "../api";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
|
||||
describe("Planning Mode API", () => {
|
||||
@@ -2445,7 +2445,7 @@ describe("API Error Handling", () => {
|
||||
|
||||
// ── AI Text Refinement API Tests ───────────────────────────────────────────
|
||||
|
||||
import { refineText, getRefineErrorMessage, REFINE_ERROR_MESSAGES, type RefinementType } from "./api";
|
||||
import { refineText, getRefineErrorMessage, REFINE_ERROR_MESSAGES, type RefinementType } from "../api";
|
||||
|
||||
describe("refineText", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -3273,7 +3273,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const { deleteMission } = await import("./api");
|
||||
const { deleteMission } = await import("../api");
|
||||
const result = await deleteMission("M-LZ7DN0-A2B5");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -3286,7 +3286,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const { deleteMilestone } = await import("./api");
|
||||
const { deleteMilestone } = await import("../api");
|
||||
const result = await deleteMilestone("MS-M3N8QR-C9F1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -3299,7 +3299,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const { deleteSlice } = await import("./api");
|
||||
const { deleteSlice } = await import("../api");
|
||||
const result = await deleteSlice("SL-P4T2WX-D5E8");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -3312,7 +3312,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const { deleteFeature } = await import("./api");
|
||||
const { deleteFeature } = await import("../api");
|
||||
const result = await deleteFeature("F-J6K9AB-G7H3");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -3325,7 +3325,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const { reorderMilestones } = await import("./api");
|
||||
const { reorderMilestones } = await import("../api");
|
||||
const result = await reorderMilestones("M-LZ7DN0-A2B5", ["MS-1", "MS-2"]);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -3338,7 +3338,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const { reorderSlices } = await import("./api");
|
||||
const { reorderSlices } = await import("../api");
|
||||
const result = await reorderSlices("MS-M3N8QR-C9F1", ["SL-1", "SL-2"]);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -3351,7 +3351,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const { deleteMission } = await import("./api");
|
||||
const { deleteMission } = await import("../api");
|
||||
const result = await deleteMission("M-LZ7DN0-A2B5", "my-project");
|
||||
expect(result).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
@@ -3365,7 +3365,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
mockFetchResponse(false, { error: "Mission not found" }, 404)
|
||||
);
|
||||
|
||||
const { deleteMission } = await import("./api");
|
||||
const { deleteMission } = await import("../api");
|
||||
await expect(deleteMission("M-999")).rejects.toThrow("Mission not found");
|
||||
});
|
||||
|
||||
@@ -3374,7 +3374,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
||||
mockFetchResponse(false, { error: "Invalid mission ID format" }, 400)
|
||||
);
|
||||
|
||||
const { deleteMission } = await import("./api");
|
||||
const { deleteMission } = await import("../api");
|
||||
await expect(deleteMission("bad-id")).rejects.toThrow("Invalid mission ID format");
|
||||
});
|
||||
});
|
||||
@@ -4073,7 +4073,7 @@ describe("fetchMemoryBackendStatus", () => {
|
||||
});
|
||||
|
||||
it("fetches memory backend status without projectId", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
const { fetchMemoryBackendStatus } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4096,7 +4096,7 @@ describe("fetchMemoryBackendStatus", () => {
|
||||
});
|
||||
|
||||
it("fetches memory backend status with projectId", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
const { fetchMemoryBackendStatus } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4120,7 +4120,7 @@ describe("fetchMemoryBackendStatus", () => {
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
const { fetchMemoryBackendStatus } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -4138,7 +4138,7 @@ describe("fetchMemoryBackendStatus", () => {
|
||||
});
|
||||
|
||||
it("handles readonly backend response", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
const { fetchMemoryBackendStatus } = await import("../api");
|
||||
|
||||
const readonlyStatus = {
|
||||
currentBackend: "readonly",
|
||||
@@ -4171,7 +4171,7 @@ describe("fetchMemoryBackendStatus", () => {
|
||||
});
|
||||
|
||||
it("handles qmd backend response", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
const { fetchMemoryBackendStatus } = await import("../api");
|
||||
|
||||
const qmdStatus = {
|
||||
currentBackend: "qmd",
|
||||
@@ -4217,7 +4217,7 @@ describe("installQmd", () => {
|
||||
});
|
||||
|
||||
it("calls POST /api/memory/install-qmd without projectId", async () => {
|
||||
const { installQmd } = await import("./api");
|
||||
const { installQmd } = await import("../api");
|
||||
const response = {
|
||||
success: true,
|
||||
qmdAvailable: true,
|
||||
@@ -4246,7 +4246,7 @@ describe("installQmd", () => {
|
||||
});
|
||||
|
||||
it("includes projectId when installing qmd for a project context", async () => {
|
||||
const { installQmd } = await import("./api");
|
||||
const { installQmd } = await import("../api");
|
||||
const response = {
|
||||
success: true,
|
||||
qmdAvailable: true,
|
||||
@@ -4285,7 +4285,7 @@ describe("compactMemory", () => {
|
||||
});
|
||||
|
||||
it("calls POST /api/memory/compact without projectId", async () => {
|
||||
const { compactMemory } = await import("./api");
|
||||
const { compactMemory } = await import("../api");
|
||||
|
||||
const mockResponse = {
|
||||
path: ".fusion/memory/DREAMS.md",
|
||||
@@ -4314,7 +4314,7 @@ describe("compactMemory", () => {
|
||||
});
|
||||
|
||||
it("calls POST /api/memory/compact with projectId", async () => {
|
||||
const { compactMemory } = await import("./api");
|
||||
const { compactMemory } = await import("../api");
|
||||
|
||||
const mockResponse = {
|
||||
path: ".fusion/memory/MEMORY.md",
|
||||
@@ -4344,7 +4344,7 @@ describe("compactMemory", () => {
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
const { compactMemory } = await import("./api");
|
||||
const { compactMemory } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -4364,7 +4364,7 @@ describe("compactMemory", () => {
|
||||
|
||||
describe("fetchMemoryInsights", () => {
|
||||
it("calls GET /api/memory/insights without projectId", async () => {
|
||||
const { fetchMemoryInsights } = await import("./api");
|
||||
const { fetchMemoryInsights } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4385,7 +4385,7 @@ describe("fetchMemoryInsights", () => {
|
||||
});
|
||||
|
||||
it("calls GET /api/memory/insights with projectId", async () => {
|
||||
const { fetchMemoryInsights } = await import("./api");
|
||||
const { fetchMemoryInsights } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4409,7 +4409,7 @@ describe("fetchMemoryInsights", () => {
|
||||
|
||||
describe("saveMemoryInsights", () => {
|
||||
it("calls PUT /api/memory/insights without projectId", async () => {
|
||||
const { saveMemoryInsights } = await import("./api");
|
||||
const { saveMemoryInsights } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4432,7 +4432,7 @@ describe("saveMemoryInsights", () => {
|
||||
});
|
||||
|
||||
it("calls PUT /api/memory/insights with projectId", async () => {
|
||||
const { saveMemoryInsights } = await import("./api");
|
||||
const { saveMemoryInsights } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4457,7 +4457,7 @@ describe("saveMemoryInsights", () => {
|
||||
|
||||
describe("triggerInsightExtraction", () => {
|
||||
it("calls POST /api/memory/extract without projectId", async () => {
|
||||
const { triggerInsightExtraction } = await import("./api");
|
||||
const { triggerInsightExtraction } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4479,7 +4479,7 @@ describe("triggerInsightExtraction", () => {
|
||||
});
|
||||
|
||||
it("calls POST /api/memory/extract with projectId", async () => {
|
||||
const { triggerInsightExtraction } = await import("./api");
|
||||
const { triggerInsightExtraction } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4504,7 +4504,7 @@ describe("triggerInsightExtraction", () => {
|
||||
|
||||
describe("fetchMemoryAudit", () => {
|
||||
it("calls GET /api/memory/audit without projectId", async () => {
|
||||
const { fetchMemoryAudit } = await import("./api");
|
||||
const { fetchMemoryAudit } = await import("../api");
|
||||
const mockReport = {
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
||||
@@ -4534,7 +4534,7 @@ describe("fetchMemoryAudit", () => {
|
||||
});
|
||||
|
||||
it("calls GET /api/memory/audit with projectId", async () => {
|
||||
const { fetchMemoryAudit } = await import("./api");
|
||||
const { fetchMemoryAudit } = await import("../api");
|
||||
const mockReport = {
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: false, size: 0, sectionCount: 0 },
|
||||
@@ -4567,7 +4567,7 @@ describe("fetchMemoryAudit", () => {
|
||||
|
||||
describe("fetchMemoryStats", () => {
|
||||
it("calls GET /api/memory/stats without projectId", async () => {
|
||||
const { fetchMemoryStats } = await import("./api");
|
||||
const { fetchMemoryStats } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4588,7 +4588,7 @@ describe("fetchMemoryStats", () => {
|
||||
});
|
||||
|
||||
it("calls GET /api/memory/stats with projectId", async () => {
|
||||
const { fetchMemoryStats } = await import("./api");
|
||||
const { fetchMemoryStats } = await import("../api");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -4668,7 +4668,7 @@ describe("Roadmap API wrappers", () => {
|
||||
};
|
||||
|
||||
it("fetchRoadmaps sends GET and propagates projectId", async () => {
|
||||
const { fetchRoadmaps } = await import("./api");
|
||||
const { fetchRoadmaps } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4691,7 +4691,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("createRoadmap sends POST with input payload", async () => {
|
||||
const { createRoadmap } = await import("./api");
|
||||
const { createRoadmap } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4716,7 +4716,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("fetchRoadmap returns roadmap with hierarchy", async () => {
|
||||
const { fetchRoadmap } = await import("./api");
|
||||
const { fetchRoadmap } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4739,7 +4739,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("updateRoadmap sends PATCH with updates", async () => {
|
||||
const { updateRoadmap } = await import("./api");
|
||||
const { updateRoadmap } = await import("../api");
|
||||
|
||||
const updatedRoadmap = { ...mockRoadmap, title: "Updated Roadmap" };
|
||||
|
||||
@@ -4763,7 +4763,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("deleteRoadmap sends DELETE and returns void", async () => {
|
||||
const { deleteRoadmap } = await import("./api");
|
||||
const { deleteRoadmap } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4785,7 +4785,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("createRoadmapMilestone sends POST with milestone input", async () => {
|
||||
const { createRoadmapMilestone } = await import("./api");
|
||||
const { createRoadmapMilestone } = await import("../api");
|
||||
|
||||
const mockMilestone = {
|
||||
id: "RMS-001",
|
||||
@@ -4817,7 +4817,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("updateRoadmapMilestone sends PATCH", async () => {
|
||||
const { updateRoadmapMilestone } = await import("./api");
|
||||
const { updateRoadmapMilestone } = await import("../api");
|
||||
|
||||
const updatedMilestone = {
|
||||
id: "RMS-001",
|
||||
@@ -4848,7 +4848,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("deleteRoadmapMilestone sends DELETE", async () => {
|
||||
const { deleteRoadmapMilestone } = await import("./api");
|
||||
const { deleteRoadmapMilestone } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4870,7 +4870,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("createRoadmapFeature sends POST with feature input", async () => {
|
||||
const { createRoadmapFeature } = await import("./api");
|
||||
const { createRoadmapFeature } = await import("../api");
|
||||
|
||||
const mockFeature = {
|
||||
id: "RF-001",
|
||||
@@ -4902,7 +4902,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("updateRoadmapFeature sends PATCH", async () => {
|
||||
const { updateRoadmapFeature } = await import("./api");
|
||||
const { updateRoadmapFeature } = await import("../api");
|
||||
|
||||
const updatedFeature = {
|
||||
id: "RF-001",
|
||||
@@ -4933,7 +4933,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("deleteRoadmapFeature sends DELETE", async () => {
|
||||
const { deleteRoadmapFeature } = await import("./api");
|
||||
const { deleteRoadmapFeature } = await import("../api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -4955,7 +4955,7 @@ describe("Roadmap API wrappers", () => {
|
||||
});
|
||||
|
||||
it("fetchRoadmapFeatures returns features for a milestone", async () => {
|
||||
const { fetchRoadmapFeatures } = await import("./api");
|
||||
const { fetchRoadmapFeatures } = await import("../api");
|
||||
|
||||
const mockFeatures = [
|
||||
{
|
||||
@@ -5013,7 +5013,7 @@ describe("Settings API wrappers", () => {
|
||||
|
||||
describe("fetchSettingsByScope", () => {
|
||||
it("calls /api/settings/scopes with no query string when projectId is omitted", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
const { fetchSettingsByScope } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5039,7 +5039,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("calls /api/settings/scopes?projectId=proj_123 when projectId is provided", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
const { fetchSettingsByScope } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5065,7 +5065,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("returns the { global, project } shape", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
const { fetchSettingsByScope } = await import("../api");
|
||||
const mockResponse = {
|
||||
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||||
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
||||
@@ -5093,7 +5093,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("throws with server error message on failure", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
const { fetchSettingsByScope } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -5113,7 +5113,7 @@ describe("Settings API wrappers", () => {
|
||||
|
||||
describe("updateGlobalSettings", () => {
|
||||
it("sends PUT to /api/settings/global with the provided payload", async () => {
|
||||
const { updateGlobalSettings } = await import("./api");
|
||||
const { updateGlobalSettings } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5137,7 +5137,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("returns the settings object on success", async () => {
|
||||
const { updateGlobalSettings } = await import("./api");
|
||||
const { updateGlobalSettings } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5158,7 +5158,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("throws with server error message on failure", async () => {
|
||||
const { updateGlobalSettings } = await import("./api");
|
||||
const { updateGlobalSettings } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -5178,7 +5178,7 @@ describe("Settings API wrappers", () => {
|
||||
|
||||
describe("updateSettings scope rejection", () => {
|
||||
it("forwards payload to PUT /api/settings and surfaces resulting 400 error", async () => {
|
||||
const { updateSettings } = await import("./api");
|
||||
const { updateSettings } = await import("../api");
|
||||
|
||||
// The backend rejects global keys on PUT /api/settings
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
@@ -5204,7 +5204,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("sends PUT to /api/settings with project-scoped payload on success", async () => {
|
||||
const { updateSettings } = await import("./api");
|
||||
const { updateSettings } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5233,7 +5233,7 @@ describe("Settings API wrappers", () => {
|
||||
|
||||
describe("fetchGlobalSettings", () => {
|
||||
it("calls GET /api/settings/global with no query string", async () => {
|
||||
const { fetchGlobalSettings } = await import("./api");
|
||||
const { fetchGlobalSettings } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5259,7 +5259,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("returns GlobalSettings with known keys like themeMode", async () => {
|
||||
const { fetchGlobalSettings } = await import("./api");
|
||||
const { fetchGlobalSettings } = await import("../api");
|
||||
const mockSettings = {
|
||||
themeMode: "light",
|
||||
defaultProvider: "anthropic",
|
||||
@@ -5285,7 +5285,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("throws with server error message on failure", async () => {
|
||||
const { fetchGlobalSettings } = await import("./api");
|
||||
const { fetchGlobalSettings } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -5305,7 +5305,7 @@ describe("Settings API wrappers", () => {
|
||||
|
||||
describe("roadmap reorder APIs", () => {
|
||||
it("reorderRoadmapMilestones sends POST with orderedMilestoneIds", async () => {
|
||||
const { reorderRoadmapMilestones } = await import("./api");
|
||||
const { reorderRoadmapMilestones } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5329,7 +5329,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("reorderRoadmapMilestones includes projectId when provided", async () => {
|
||||
const { reorderRoadmapMilestones } = await import("./api");
|
||||
const { reorderRoadmapMilestones } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5348,7 +5348,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("reorderRoadmapFeatures sends POST with orderedFeatureIds", async () => {
|
||||
const { reorderRoadmapFeatures } = await import("./api");
|
||||
const { reorderRoadmapFeatures } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5372,7 +5372,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("moveRoadmapFeature sends POST with targetMilestoneId and targetIndex", async () => {
|
||||
const { moveRoadmapFeature } = await import("./api");
|
||||
const { moveRoadmapFeature } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5397,7 +5397,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("moveRoadmapFeature includes projectId when provided", async () => {
|
||||
const { moveRoadmapFeature } = await import("./api");
|
||||
const { moveRoadmapFeature } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5416,7 +5416,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
|
||||
const { generateFeatureSuggestions } = await import("./api");
|
||||
const { generateFeatureSuggestions } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5439,7 +5439,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("generateFeatureSuggestions includes input parameters in body", async () => {
|
||||
const { generateFeatureSuggestions } = await import("./api");
|
||||
const { generateFeatureSuggestions } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5462,7 +5462,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("generateFeatureSuggestions includes projectId when provided", async () => {
|
||||
const { generateFeatureSuggestions } = await import("./api");
|
||||
const { generateFeatureSuggestions } = await import("../api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -5486,7 +5486,7 @@ describe("Settings API wrappers", () => {
|
||||
|
||||
describe("roadmap export/handoff APIs", () => {
|
||||
it("exportRoadmap sends GET to export endpoint", async () => {
|
||||
const { exportRoadmap } = await import("./api");
|
||||
const { exportRoadmap } = await import("../api");
|
||||
const exportData = {
|
||||
roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" },
|
||||
milestones: [],
|
||||
@@ -5514,7 +5514,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("exportRoadmap includes projectId when provided", async () => {
|
||||
const { exportRoadmap } = await import("./api");
|
||||
const { exportRoadmap } = await import("../api");
|
||||
const exportData = { roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" }, milestones: [], features: [] };
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
@@ -5537,7 +5537,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("getRoadmapMissionHandoff sends GET to mission handoff endpoint", async () => {
|
||||
const { getRoadmapMissionHandoff } = await import("./api");
|
||||
const { getRoadmapMissionHandoff } = await import("../api");
|
||||
const handoffData = {
|
||||
sourceRoadmapId: "RM-001",
|
||||
title: "Test Roadmap",
|
||||
@@ -5565,7 +5565,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("getRoadmapFeatureHandoff sends GET to feature handoff endpoint", async () => {
|
||||
const { getRoadmapFeatureHandoff } = await import("./api");
|
||||
const { getRoadmapFeatureHandoff } = await import("../api");
|
||||
const handoffData = {
|
||||
source: {
|
||||
roadmapId: "RM-001",
|
||||
@@ -5600,7 +5600,7 @@ describe("Settings API wrappers", () => {
|
||||
});
|
||||
|
||||
it("getRoadmapFeatureHandoff includes projectId when provided", async () => {
|
||||
const { getRoadmapFeatureHandoff } = await import("./api");
|
||||
const { getRoadmapFeatureHandoff } = await import("../api");
|
||||
const handoffData = {
|
||||
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "T", milestoneTitle: "M", milestoneOrderIndex: 0, featureOrderIndex: 0 },
|
||||
title: "F",
|
||||
@@ -5657,7 +5657,7 @@ describe("Automation API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("fetchAutomations sends GET to /automations without scope by default", async () => {
|
||||
const { fetchAutomations } = await import("./api");
|
||||
const { fetchAutomations } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||
|
||||
await fetchAutomations();
|
||||
@@ -5668,7 +5668,7 @@ describe("Automation API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("fetchAutomations includes scope=global when specified", async () => {
|
||||
const { fetchAutomations } = await import("./api");
|
||||
const { fetchAutomations } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||
|
||||
await fetchAutomations({ scope: "global" });
|
||||
@@ -5678,7 +5678,7 @@ describe("Automation API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("fetchAutomations includes scope=project and projectId when project-scoped", async () => {
|
||||
const { fetchAutomations } = await import("./api");
|
||||
const { fetchAutomations } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||
|
||||
await fetchAutomations({ scope: "project", projectId: "proj-123" });
|
||||
@@ -5690,7 +5690,7 @@ describe("Automation API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("createAutomation forwards scope context in query params", async () => {
|
||||
const { createAutomation } = await import("./api");
|
||||
const { createAutomation } = await import("../api");
|
||||
const fakeSchedule = {
|
||||
id: "sched-001",
|
||||
name: "Test",
|
||||
@@ -5724,7 +5724,7 @@ describe("Automation API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("createAutomation forwards scope context without projectId for global scope", async () => {
|
||||
const { createAutomation } = await import("./api");
|
||||
const { createAutomation } = await import("../api");
|
||||
const fakeSchedule = {
|
||||
id: "sched-001",
|
||||
name: "Test",
|
||||
@@ -5756,7 +5756,7 @@ describe("Automation API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("runAutomation forwards scope context", async () => {
|
||||
const { runAutomation } = await import("./api");
|
||||
const { runAutomation } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { schedule: {}, result: { success: true } }));
|
||||
|
||||
await runAutomation("sched-001", { scope: "project", projectId: "proj-123" });
|
||||
@@ -5766,7 +5766,7 @@ describe("Automation API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("toggleAutomation forwards scope context", async () => {
|
||||
const { toggleAutomation } = await import("./api");
|
||||
const { toggleAutomation } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "sched-001", enabled: false }));
|
||||
|
||||
await toggleAutomation("sched-001", { scope: "global" });
|
||||
@@ -5784,7 +5784,7 @@ describe("Routine API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("fetchRoutines sends GET to /routines without scope by default", async () => {
|
||||
const { fetchRoutines } = await import("./api");
|
||||
const { fetchRoutines } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||
|
||||
await fetchRoutines();
|
||||
@@ -5795,7 +5795,7 @@ describe("Routine API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("fetchRoutines includes scope=global when specified", async () => {
|
||||
const { fetchRoutines } = await import("./api");
|
||||
const { fetchRoutines } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||
|
||||
await fetchRoutines({ scope: "global" });
|
||||
@@ -5805,7 +5805,7 @@ describe("Routine API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("fetchRoutines includes scope=project and projectId when project-scoped", async () => {
|
||||
const { fetchRoutines } = await import("./api");
|
||||
const { fetchRoutines } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||
|
||||
await fetchRoutines({ scope: "project", projectId: "proj-456" });
|
||||
@@ -5817,7 +5817,7 @@ describe("Routine API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("createRoutine forwards scope context in query params", async () => {
|
||||
const { createRoutine } = await import("./api");
|
||||
const { createRoutine } = await import("../api");
|
||||
const fakeRoutine = {
|
||||
id: "routine-001",
|
||||
name: "Test Routine",
|
||||
@@ -5849,7 +5849,7 @@ describe("Routine API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("updateRoutine forwards scope context", async () => {
|
||||
const { updateRoutine } = await import("./api");
|
||||
const { updateRoutine } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "routine-001", name: "Updated" }));
|
||||
|
||||
await updateRoutine("routine-001", { name: "Updated" }, { scope: "project", projectId: "proj-456" });
|
||||
@@ -5859,7 +5859,7 @@ describe("Routine API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("deleteRoutine forwards scope context", async () => {
|
||||
const { deleteRoutine } = await import("./api");
|
||||
const { deleteRoutine } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
@@ -5876,7 +5876,7 @@ describe("Routine API scope forwarding", () => {
|
||||
});
|
||||
|
||||
it("runRoutine forwards scope context", async () => {
|
||||
const { runRoutine } = await import("./api");
|
||||
const { runRoutine } = await import("../api");
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { routine: {}, result: { success: true } }));
|
||||
|
||||
await runRoutine("routine-001", { scope: "project", projectId: "proj-789" });
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
async function loadAuthModule() {
|
||||
vi.resetModules();
|
||||
return import("./auth");
|
||||
return import("../auth");
|
||||
}
|
||||
|
||||
describe("auth helpers", () => {
|
||||
@@ -1,187 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
|
||||
interface MockResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
function mockFetchResponse({ ok, status, body }: MockResponse): Promise<Response> {
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe("AgentImportModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const onImported = vi.fn();
|
||||
const originalFileReader = globalThis.FileReader;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
class MockFileReader {
|
||||
onload: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
|
||||
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
|
||||
|
||||
readAsText(file: Blob): void {
|
||||
const content = (file as any).__content ?? "";
|
||||
this.onload?.call(this as unknown as FileReader, {
|
||||
target: { result: content },
|
||||
} as ProgressEvent<FileReader>);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.FileReader = MockFileReader as unknown as typeof FileReader;
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.FileReader = originalFileReader;
|
||||
});
|
||||
|
||||
it("renders the input step with file upload, directory button, and textarea", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
expect(screen.getByText("Import Agents")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Choose File" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Select Directory" })).toBeTruthy();
|
||||
expect(screen.getByLabelText("Manifest content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Browse Catalog button", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Browse Catalog" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads selected .md file content into the manifest textarea", async () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
const fileInput = screen.getByLabelText("Upload agent manifest file") as HTMLInputElement;
|
||||
const file = new File(["---\nname: CEO\n---\nLead"], "AGENTS.md", { type: "text/markdown" });
|
||||
(file as any).__content = "---\nname: CEO\n---\nLead";
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
const textarea = screen.getByLabelText("Manifest content") as HTMLTextAreaElement;
|
||||
expect(textarea.value).toContain("name: CEO");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows parse preview using API-provided agents array", async () => {
|
||||
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
dryRun: true,
|
||||
companyName: "Acme Co",
|
||||
agents: [
|
||||
{
|
||||
name: "CEO",
|
||||
role: "executor",
|
||||
title: "Chief Executive",
|
||||
skills: ["review"],
|
||||
},
|
||||
],
|
||||
created: ["CEO"],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Manifest content"), {
|
||||
target: { value: "---\nname: CEO\n---\nLead" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("CEO")).toBeTruthy();
|
||||
expect(screen.getByText(/executor/)).toBeTruthy();
|
||||
expect(screen.getByText(/Chief Executive/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("imports agents from preview step and shows result summary", async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockImplementationOnce(() => mockFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
dryRun: true,
|
||||
companyName: "Acme Co",
|
||||
agents: [{ name: "CEO", role: "executor", title: "Chief Executive", skills: ["review"] }],
|
||||
created: ["CEO"],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
}))
|
||||
.mockImplementationOnce(() => mockFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
companyName: "Acme Co",
|
||||
created: [{ id: "agent-1", name: "CEO" }],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Manifest content"), {
|
||||
target: { value: "---\nname: CEO\n---\nLead" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Import 1 Agent/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Import 1 Agent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Import Complete")).toBeTruthy();
|
||||
expect(screen.getByText(/1 created/)).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(onImported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows API errors to the user", async () => {
|
||||
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
body: { error: "No agents found" },
|
||||
}));
|
||||
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Manifest content"), {
|
||||
target: { value: "invalid content" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No agents found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to browse mode when Browse Catalog button is clicked", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse Catalog" }));
|
||||
|
||||
// The browse mode should render the search input (the fetch for companies is async)
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,295 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MissionInterviewModal } from "./MissionInterviewModal";
|
||||
|
||||
const mockStartMissionInterview = vi.fn();
|
||||
const mockRespondToMissionInterview = vi.fn();
|
||||
const mockRetryMissionInterviewSession = vi.fn();
|
||||
const mockCancelMissionInterview = vi.fn();
|
||||
const mockCreateMissionFromInterview = vi.fn();
|
||||
const mockConnectMissionInterviewStream = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockParseConversationHistory = vi.fn();
|
||||
const mockAcquireSessionLock = vi.fn();
|
||||
const mockReleaseSessionLock = vi.fn();
|
||||
const mockForceAcquireSessionLock = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
||||
respondToMissionInterview: (...args: any[]) => mockRespondToMissionInterview(...args),
|
||||
retryMissionInterviewSession: (...args: any[]) => mockRetryMissionInterviewSession(...args),
|
||||
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
||||
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
||||
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/modalPersistence", () => ({
|
||||
saveMissionGoal: vi.fn(),
|
||||
getMissionGoal: vi.fn(() => ""),
|
||||
clearMissionGoal: vi.fn(),
|
||||
}));
|
||||
|
||||
const SAMPLE_QUESTION = {
|
||||
id: "scope",
|
||||
type: "single_select" as const,
|
||||
question: "What is the target scope?",
|
||||
description: "Pick the size for this mission.",
|
||||
options: [
|
||||
{ id: "mvp", label: "MVP" },
|
||||
{ id: "full", label: "Full" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("MissionInterviewModal", () => {
|
||||
let streamHandlers: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
streamHandlers = undefined;
|
||||
|
||||
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
||||
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
});
|
||||
|
||||
function renderModal() {
|
||||
return render(
|
||||
<MissionInterviewModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onMissionCreated={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows lock overlay and allows take-control", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
||||
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Take Control"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("mission-session-1", "tab-self");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows reconnecting indicator without clearing current question", async () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined, undefined);
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onConnectionStateChange?.("reconnecting");
|
||||
});
|
||||
|
||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onConnectionStateChange?.("connected");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves streaming thinking output while reconnecting", async () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onThinking?.("Analyzing mission goals...");
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Analyzing mission goals...")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onConnectionStateChange?.("reconnecting");
|
||||
});
|
||||
|
||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error panel with retry action when stream fails", async () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onError?.("Temporary outage");
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries interview session from error view", async () => {
|
||||
let attempt = 0;
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
setTimeout(() => handlers.onError?.("Try again"), 10);
|
||||
} else {
|
||||
setTimeout(() => handlers.onQuestion?.(SAMPLE_QUESTION), 10);
|
||||
}
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Try again")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
});
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recovers retry from connection-loss when interview session is still generating", async () => {
|
||||
let attempt = 0;
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
setTimeout(() => handlers.onError?.("Connection lost"), 10);
|
||||
}
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
mockRetryMissionInterviewSession.mockRejectedValueOnce(
|
||||
new Error("Mission interview session mission-session-1 is not in an error state"),
|
||||
);
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "mission-session-1",
|
||||
type: "mission_interview",
|
||||
status: "generating",
|
||||
title: "Build a mission planning workflow",
|
||||
inputPayload: JSON.stringify({ goal: "Build a mission planning workflow" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
thinkingOutput: "Continuing...",
|
||||
error: null,
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
lockedAt: null,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connection lost")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
||||
});
|
||||
|
||||
expect(await screen.findByText("AI is thinking...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Continuing...")).toBeInTheDocument();
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,510 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
|
||||
// Mock the API functions
|
||||
const mockFetchScripts = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
fetchScripts: () => mockFetchScripts(),
|
||||
}));
|
||||
|
||||
const mockOnOpenScripts = vi.fn();
|
||||
const mockOnRunScript = vi.fn();
|
||||
|
||||
function renderDropdown(props = {}) {
|
||||
return render(
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={mockOnOpenScripts}
|
||||
onRunScript={mockOnRunScript}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("QuickScriptsDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the trigger button", () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByTestId("scripts-btn")).toBeDefined();
|
||||
expect(screen.getByTitle("Scripts")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show dropdown menu initially", () => {
|
||||
renderDropdown();
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropdown open/close", () => {
|
||||
it("opens dropdown when trigger is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown when clicking outside", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown on Escape key", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown when trigger is clicked again", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetching and displaying scripts", () => {
|
||||
it("shows loading state while fetching", async () => {
|
||||
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
|
||||
});
|
||||
|
||||
it("fetches and displays scripts", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
test: "npm test",
|
||||
lint: "npm run lint",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
||||
expect(screen.getByTestId("quick-script-item-lint")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays script names and truncated commands", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
"long-command": "this is a very long command that should be truncated",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const item = screen.getByTestId("quick-script-item-long-command");
|
||||
expect(item.textContent).toContain("long-command");
|
||||
expect(item.textContent).toContain("this is a very long command that should be truncat...");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles short commands without truncation", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
short: "echo hi",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const item = screen.getByTestId("quick-script-item-short");
|
||||
expect(item.textContent).toContain("short");
|
||||
expect(item.textContent).toContain("echo hi");
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts scripts alphabetically", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
zebra: "echo zebra",
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByRole("option");
|
||||
expect(items[0].textContent).toContain("alpha");
|
||||
expect(items[1].textContent).toContain("beta");
|
||||
expect(items[2].textContent).toContain("zebra");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("running scripts", () => {
|
||||
it("calls onRunScript when a script is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-script-item-build"));
|
||||
|
||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
|
||||
it("closes dropdown after running script", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
test: "npm test",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-script-item-test"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("manage scripts link", () => {
|
||||
it("shows 'Manage Scripts...' link when scripts exist", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onOpenScripts when 'Manage Scripts...' is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes dropdown when 'Manage Scripts...' is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows empty state when no scripts configured", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("empty state shows 'Add your first script' button", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking 'Add your first script' calls onOpenScripts", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Add your first script"));
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes dropdown when empty state action is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Add your first script"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("supports ArrowDown to highlight items", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// First ArrowDown highlights first item
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
|
||||
// Second ArrowDown highlights second item
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("supports ArrowUp to highlight items", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Go to bottom first with End key
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
|
||||
// ArrowUp moves to previous item
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("wraps around with arrow keys", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// ArrowUp from start wraps to end (Manage Scripts...)
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
||||
|
||||
// ArrowDown from end wraps to start
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("runs script with Enter key", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Highlight and press Enter
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(menu, { key: "Enter" });
|
||||
|
||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
|
||||
it("opens manage scripts with Enter key on manage button", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Navigate to last item (Manage Scripts...) and press Enter
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
fireEvent.keyDown(menu, { key: "Enter" });
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports Home key to go to first item", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Go to end first
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
// Home goes to first
|
||||
fireEvent.keyDown(menu, { key: "Home" });
|
||||
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("supports End key to go to last item", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
|
||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles fetch errors gracefully", async () => {
|
||||
mockFetchScripts.mockRejectedValue(new Error("Failed to fetch"));
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show empty state since scripts will be empty object on error
|
||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus management", () => {
|
||||
it("menu is focusable with tabIndex", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
expect(menu).toHaveAttribute("tabIndex", "-1");
|
||||
});
|
||||
|
||||
it("focus moves to trigger when Escape is pressed", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
// Trigger should have focus
|
||||
expect(document.activeElement).toBe(screen.getByTestId("scripts-btn"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,858 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import type { SettingsExportData } from "../api";
|
||||
|
||||
// --- API mocks ---
|
||||
const mockFetchSettings = vi.fn();
|
||||
const mockFetchSettingsByScope = vi.fn();
|
||||
const mockExportSettings = vi.fn();
|
||||
const mockUpdateSettings = vi.fn();
|
||||
const mockUpdateGlobalSettings = vi.fn();
|
||||
const mockFetchAuthStatus = vi.fn();
|
||||
const mockLoginProvider = vi.fn();
|
||||
const mockLogoutProvider = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
const mockTestNtfyNotification = vi.fn();
|
||||
const mockFetchBackups = vi.fn();
|
||||
const mockCreateBackup = vi.fn();
|
||||
const mockImportSettings = vi.fn();
|
||||
const mockFetchMemoryFiles = vi.fn();
|
||||
const mockFetchMemoryFile = vi.fn();
|
||||
const mockSaveMemoryFile = vi.fn();
|
||||
const mockCompactMemory = vi.fn();
|
||||
const mockFetchGlobalConcurrency = vi.fn();
|
||||
const mockUpdateGlobalConcurrency = vi.fn();
|
||||
const mockFetchMemoryBackendStatus = vi.fn();
|
||||
const mockTestMemoryRetrieval = vi.fn();
|
||||
const mockInstallQmd = vi.fn();
|
||||
const mockFetchGitRemotesDetailed = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||||
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
|
||||
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
|
||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||
exportSettings: (...args: unknown[]) => mockExportSettings(...args),
|
||||
importSettings: (...args: unknown[]) => mockImportSettings(...args),
|
||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
|
||||
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
|
||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
|
||||
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
|
||||
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
|
||||
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
|
||||
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
|
||||
saveMemoryFile: (...args: unknown[]) => mockSaveMemoryFile(...args),
|
||||
compactMemory: (...args: unknown[]) => mockCompactMemory(...args),
|
||||
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
|
||||
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
|
||||
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
|
||||
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
|
||||
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
|
||||
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
|
||||
}));
|
||||
|
||||
// Mock the hook
|
||||
const mockUseMemoryBackendStatus = vi.fn();
|
||||
vi.mock("../hooks/useMemoryBackendStatus", () => ({
|
||||
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const defaultSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
recycleWorktrees: false,
|
||||
worktreeNaming: "random",
|
||||
includeTaskIdInCommit: true,
|
||||
worktreeInitCommand: "",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
};
|
||||
|
||||
function renderModal(props = {}) {
|
||||
return render(
|
||||
<SettingsModal
|
||||
onClose={noop}
|
||||
addToast={noop}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSettings.mockResolvedValue(defaultSettings);
|
||||
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
|
||||
mockFetchMemoryFiles.mockResolvedValue({
|
||||
files: [
|
||||
{
|
||||
path: ".fusion/memory/MEMORY.md",
|
||||
label: "Long-term memory",
|
||||
layer: "long-term",
|
||||
size: 42,
|
||||
updatedAt: "2026-04-17T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
path: ".fusion/memory/DREAMS.md",
|
||||
label: "Dreams",
|
||||
layer: "dreams",
|
||||
size: 21,
|
||||
updatedAt: "2026-04-17T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
mockFetchMemoryFile.mockImplementation((path: string) =>
|
||||
Promise.resolve({
|
||||
path,
|
||||
content: path.endsWith("DREAMS.md")
|
||||
? "## Existing dreams\n- Pattern from daily notes"
|
||||
: "## Existing memory\n- Learned pattern",
|
||||
}),
|
||||
);
|
||||
mockSaveMemoryFile.mockResolvedValue({ success: true });
|
||||
mockCompactMemory.mockResolvedValue({
|
||||
path: ".fusion/memory/DREAMS.md",
|
||||
content: "# Compacted Memory\n\nImportant content.",
|
||||
});
|
||||
mockTestMemoryRetrieval.mockResolvedValue({
|
||||
query: "pattern",
|
||||
qmdAvailable: true,
|
||||
usedFallback: false,
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
results: [],
|
||||
});
|
||||
mockInstallQmd.mockResolvedValue({
|
||||
success: true,
|
||||
qmdAvailable: true,
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
});
|
||||
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
||||
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
|
||||
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
||||
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
||||
mockFetchMemoryBackendStatus.mockResolvedValue({
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
qmdAvailable: true,
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
});
|
||||
mockUseMemoryBackendStatus.mockReturnValue({
|
||||
status: {
|
||||
currentBackend: "qmd",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: false,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
qmdAvailable: true,
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
},
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
// jsdom doesn't provide URL.createObjectURL — polyfill it
|
||||
if (!URL.createObjectURL) {
|
||||
URL.createObjectURL = vi.fn(() => "blob:http://localhost/mock") as any;
|
||||
}
|
||||
if (!URL.revokeObjectURL) {
|
||||
URL.revokeObjectURL = vi.fn() as any;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("settings export filename", () => {
|
||||
it("uses fusion-settings- prefix for exported filename", async () => {
|
||||
const mockExportData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: "2026-04-04T12:00:00.000Z",
|
||||
global: undefined,
|
||||
project: { maxConcurrent: 2 },
|
||||
};
|
||||
mockExportSettings.mockResolvedValue(mockExportData);
|
||||
|
||||
// Spy on createElement to capture the download link's filename
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createdElements: { tagName: string; download: string; href: string }[] = [];
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
||||
const el = originalCreateElement(tagName);
|
||||
if (tagName.toLowerCase() === "a") {
|
||||
// Capture the download attribute when set
|
||||
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLAnchorElement.prototype,
|
||||
"download"
|
||||
);
|
||||
Object.defineProperty(el, "download", {
|
||||
set(v: string) {
|
||||
createdElements.push({ tagName, download: v, href: (el as HTMLAnchorElement).href });
|
||||
origDownloadDescriptor?.set?.call(el, v);
|
||||
},
|
||||
get() {
|
||||
return origDownloadDescriptor?.get?.call(el) ?? "";
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Mock URL.createObjectURL and revokeObjectURL
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
|
||||
renderModal();
|
||||
|
||||
// Wait for settings to load
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the Export button
|
||||
const exportButton = screen.getByTitle("Export settings to JSON file");
|
||||
expect(exportButton).toBeDefined();
|
||||
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Assert the filename uses fusion-settings- prefix
|
||||
expect(createdElements.length).toBeGreaterThanOrEqual(1);
|
||||
const anchorElement = createdElements[0];
|
||||
expect(anchorElement.download).toMatch(/^fusion-settings-/);
|
||||
expect(anchorElement.download).toMatch(/^fusion-settings-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/);
|
||||
});
|
||||
|
||||
it("does not use kb-settings- prefix for exported filename", async () => {
|
||||
const mockExportData: SettingsExportData = {
|
||||
version: 1,
|
||||
exportedAt: "2026-04-04T12:00:00.000Z",
|
||||
global: undefined,
|
||||
project: { maxConcurrent: 2 },
|
||||
};
|
||||
mockExportSettings.mockResolvedValue(mockExportData);
|
||||
|
||||
// Capture filenames set on dynamically-created anchor elements
|
||||
const capturedFilenames: string[] = [];
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
||||
const el = originalCreateElement(tagName);
|
||||
if (tagName.toLowerCase() === "a") {
|
||||
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLAnchorElement.prototype,
|
||||
"download"
|
||||
);
|
||||
Object.defineProperty(el, "download", {
|
||||
set(v: string) {
|
||||
capturedFilenames.push(v);
|
||||
origDownloadDescriptor?.set?.call(el, v);
|
||||
},
|
||||
get() {
|
||||
return origDownloadDescriptor?.get?.call(el) ?? "";
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Export settings to JSON file"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Negative assertion: filename must NOT use the old kb- prefix
|
||||
expect(capturedFilenames.length).toBeGreaterThanOrEqual(1);
|
||||
for (const filename of capturedFilenames) {
|
||||
expect(filename).not.toMatch(/^kb-settings-/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Number input clearing", () => {
|
||||
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Open Scheduling section
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
|
||||
// Clear the input - the input should be empty, not show "0"
|
||||
await userEvent.clear(input);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("allows clearing globalMaxConcurrent without leaving a stuck zero", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Open Scheduling section
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
|
||||
// Clear the input - the input should be empty, not show "0"
|
||||
await userEvent.clear(input);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("allows clearing pollIntervalMs without leaving a stuck zero", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Open Scheduling section
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
|
||||
// Clear the input - the input should be empty, not show "0"
|
||||
await userEvent.clear(input);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("allows clearing maxWorktrees without leaving a stuck zero", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Open Worktrees section
|
||||
fireEvent.click(screen.getByText("Worktrees"));
|
||||
|
||||
const input = screen.getByLabelText("Max Worktrees") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
|
||||
// Clear the input - the input should be empty, not show "0"
|
||||
await userEvent.clear(input);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory section", () => {
|
||||
it("renders the Memory section in the sidebar", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Memory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows the memory toggle with default enabled", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click the Memory section in the sidebar
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
||||
expect(checkbox).toBeDefined();
|
||||
// Default is enabled, so checkbox should be checked
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows memory toggle unchecked when memoryEnabled is false", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
memoryEnabled: false,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click the Memory section in the sidebar
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
||||
expect(checkbox).toBeDefined();
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("toggles the memory setting when checkbox is clicked", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click the Memory section in the sidebar
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
// Uncheck it
|
||||
await userEvent.click(checkbox);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Check it again
|
||||
await userEvent.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("installs qmd from the missing qmd prompt", async () => {
|
||||
const addToast = vi.fn();
|
||||
const refresh = vi.fn(() => Promise.resolve());
|
||||
mockUseMemoryBackendStatus.mockReturnValue({
|
||||
status: {
|
||||
currentBackend: "qmd",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: false,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
qmdAvailable: false,
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
},
|
||||
currentBackend: "qmd",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: false,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh,
|
||||
});
|
||||
|
||||
renderModal({ addToast });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Install qmd" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallQmd).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalled();
|
||||
expect(addToast).toHaveBeenCalledWith("qmd installed successfully", "success");
|
||||
});
|
||||
|
||||
it("loads and shows memory editor content when navigating to Memory", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockFetchMemoryFiles).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchMemoryFiles).toHaveBeenCalledWith(undefined);
|
||||
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
||||
});
|
||||
|
||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
||||
expect(editor.value).toContain("Existing dreams");
|
||||
});
|
||||
|
||||
it("shows loading state while memory is being fetched", async () => {
|
||||
let resolveMemory: ((value: { content: string }) => void) | undefined;
|
||||
mockFetchMemoryFile.mockReturnValueOnce(
|
||||
new Promise<{ content: string }>((resolve) => {
|
||||
resolveMemory = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
expect(screen.getByText("Loading memory…")).toBeDefined();
|
||||
|
||||
resolveMemory?.({ content: "# Loaded" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Editor for .fusion/memory/DREAMS.md")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("supports editing and saving memory content", async () => {
|
||||
const addToast = vi.fn();
|
||||
renderModal({ addToast });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
||||
});
|
||||
|
||||
const select = await screen.findByLabelText("Memory File");
|
||||
await userEvent.selectOptions(select, ".fusion/memory/MEMORY.md");
|
||||
|
||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md");
|
||||
fireEvent.change(editor, { target: { value: "# Updated memory\n- Reusable learning" } });
|
||||
|
||||
const saveButton = await screen.findByRole("button", { name: "Save Memory" });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveMemoryFile).toHaveBeenCalledWith(
|
||||
".fusion/memory/MEMORY.md",
|
||||
"# Updated memory\n- Reusable learning",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
|
||||
});
|
||||
|
||||
it("compacts the selected memory file in the editor", async () => {
|
||||
const addToast = vi.fn();
|
||||
renderModal({ addToast });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
const compactButton = await screen.findByRole("button", { name: "Compact Selected File" });
|
||||
await userEvent.click(compactButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCompactMemory).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
||||
});
|
||||
|
||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
||||
expect(editor.value).toContain("Compacted Memory");
|
||||
expect(addToast).toHaveBeenCalledWith("Memory file compacted", "success");
|
||||
});
|
||||
|
||||
it("handles empty memory content from API", async () => {
|
||||
mockFetchMemoryFile.mockResolvedValueOnce({ path: ".fusion/memory/DREAMS.md", content: "" });
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
||||
expect(editor.value).toBe("");
|
||||
});
|
||||
|
||||
it("switches between memory files in the editor", async () => {
|
||||
mockFetchMemoryFile.mockImplementation((path: string) =>
|
||||
Promise.resolve({
|
||||
path,
|
||||
content: path.endsWith("DREAMS.md") ? "# Dreams\n\n- Pattern" : "# Memory\n\n- Durable",
|
||||
}),
|
||||
);
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Memory"));
|
||||
|
||||
const select = await screen.findByLabelText("Memory File");
|
||||
await userEvent.selectOptions(select, ".fusion/memory/DREAMS.md");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
||||
});
|
||||
|
||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
||||
expect(editor.value).toContain("Dreams");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Merge section", () => {
|
||||
it("shows push-after-merge toggle and keeps Push Remote hidden by default", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
||||
|
||||
const pushAfterMergeToggle = screen.getByRole("checkbox", {
|
||||
name: /push to remote after merge/i,
|
||||
});
|
||||
expect(pushAfterMergeToggle).not.toBeChecked();
|
||||
expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Push Remote input when push-after-merge is enabled", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Push Remote")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes pushAfterMerge and pushRemote in the save payload", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
||||
);
|
||||
|
||||
const pushRemoteInput = screen.getByLabelText("Push Remote");
|
||||
await userEvent.clear(pushRemoteInput);
|
||||
await userEvent.type(pushRemoteInput, "upstream main");
|
||||
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
||||
expect(payload.pushAfterMerge).toBe(true);
|
||||
expect(payload.pushRemote).toBe("upstream main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Experimental Features section", () => {
|
||||
const openExperimentalFeaturesSection = async () => {
|
||||
const sectionLabel = await screen.findByText("Experimental Features");
|
||||
await userEvent.click(sectionLabel);
|
||||
};
|
||||
|
||||
it("renders the Experimental Features section in the sidebar", async () => {
|
||||
renderModal();
|
||||
|
||||
expect(await screen.findByText("Experimental Features")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows known experimental features (Insights, Roadmaps) even when no custom features are configured", async () => {
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Known features should always be shown
|
||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
||||
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows feature flags when experimentalFeatures is set", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { "my-feature": true, "another-feature": false },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
expect(screen.getByText("my-feature")).toBeInTheDocument();
|
||||
expect(screen.getByText("another-feature")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("feature flags are unchecked when value is false", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { "my-feature": false },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("feature flags are checked when value is true", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { "my-feature": true },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("toggling a feature flag updates the form state", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { "my-feature": false },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
// Toggle it
|
||||
await userEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("saving with toggled feature flag includes experimentalFeatures in payload", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { "my-feature": false },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Toggle the feature
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
// Save
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
||||
expect(payload.experimentalFeatures).toEqual({ "my-feature": true });
|
||||
});
|
||||
|
||||
it("shows project scope banner in Experimental Features section", async () => {
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Should show project scope indicator
|
||||
expect(screen.getByText(/only affect this project/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles undefined experimentalFeatures (falls back to empty) but still shows known features", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: undefined,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Known features should always be shown regardless of settings
|
||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
||||
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves experimentalFeatures with multiple toggled flags", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { "feature-a": true, "feature-b": false },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Toggle feature-b to true
|
||||
const checkboxB = screen.getByLabelText("feature-b") as HTMLInputElement;
|
||||
await userEvent.click(checkboxB);
|
||||
|
||||
// Save
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
||||
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,586 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||
vi.mock("lucide-react", () => ({
|
||||
Link: () => null,
|
||||
Clock: () => null,
|
||||
Pencil: () => null,
|
||||
Layers: () => null,
|
||||
ChevronDown: () => null,
|
||||
Folder: () => null,
|
||||
GitPullRequest: () => null,
|
||||
CircleDot: () => null,
|
||||
Target: () => null,
|
||||
Bot: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
fetchMission: vi.fn(),
|
||||
fetchAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Test task",
|
||||
column: "in-progress",
|
||||
status: undefined as any,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
description: "",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("TaskCard", () => {
|
||||
it("renders the card ID text", () => {
|
||||
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the status badge when task.status is set", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ status: "executing" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("executing")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the status badge after the card ID in DOM order", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ status: "executing" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const cardId = container.querySelector(".card-id")!;
|
||||
const badge = container.querySelector(".card-status-badge")!;
|
||||
expect(cardId).toBeDefined();
|
||||
expect(badge).toBeDefined();
|
||||
// Badge should be the next sibling of card-id
|
||||
expect(cardId.nextElementSibling).toBe(badge);
|
||||
});
|
||||
|
||||
it("does not render a status badge when task.status is falsy", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask({ status: undefined as any })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
expect(container.querySelector(".card-status-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders unified progress counts for task steps + workflow checks", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "pending" },
|
||||
],
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Browser Verification",
|
||||
status: "passed",
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Frontend UX Design",
|
||||
status: "failed",
|
||||
},
|
||||
],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("2/5")).toBeDefined();
|
||||
expect(screen.getByText("5 steps")).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses singular step label when unified progress total is one", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
steps: [{ name: "Step 0", status: "done" }],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1 step")).toBeDefined();
|
||||
expect(screen.queryByText("1 steps")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders workflow checks after normal steps with mapped statuses and phase badges", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "pending" },
|
||||
],
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Browser Verification",
|
||||
status: "passed",
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Frontend UX Design",
|
||||
status: "failed",
|
||||
phase: "post-merge",
|
||||
},
|
||||
],
|
||||
})}
|
||||
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
||||
expect(stepNames).toEqual([
|
||||
"Step 0",
|
||||
"Step 1",
|
||||
"Browser Verification",
|
||||
"Frontend UX Design",
|
||||
"Accessibility Audit",
|
||||
]);
|
||||
|
||||
const dots = container.querySelectorAll(".card-step-dot");
|
||||
expect(dots[2]?.className).toContain("card-step-dot--done");
|
||||
expect(dots[3]?.className).toContain("card-step-dot--failed");
|
||||
expect(dots[4]?.className).toContain("card-step-dot--pending");
|
||||
|
||||
const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge");
|
||||
const workflowBadges = Array.from(workflowBadgeElements).map((el) => el.textContent);
|
||||
expect(workflowBadges).toEqual(["workflow", "workflow", "workflow"]);
|
||||
|
||||
expect(workflowBadgeElements[0]?.className).toContain("card-step-workflow-badge--pre-merge");
|
||||
expect(workflowBadgeElements[1]?.className).toContain("card-step-workflow-badge--post-merge");
|
||||
expect(workflowBadgeElements[2]?.className).toContain("card-step-workflow-badge--pre-merge");
|
||||
|
||||
workflowBadgeElements.forEach((badge) => {
|
||||
expect(badge.getAttribute("title")).toBe("Workflow check");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to workflow result name, then raw ID when lookup names are unavailable", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
enabledWorkflowSteps: ["WS-002", "WS-003"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Fallback from result",
|
||||
status: "passed",
|
||||
},
|
||||
],
|
||||
})}
|
||||
workflowStepNameLookup={new Map([["WS-002", " "]])}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
||||
expect(stepNames).toEqual(["Fallback from result", "WS-003"]);
|
||||
});
|
||||
|
||||
it("shows drop indicator on file dragover and removes on dragleave", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
// Simulate file dragover
|
||||
fireEvent.dragOver(card, {
|
||||
dataTransfer: { types: ["Files"], dropEffect: "none" },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(true);
|
||||
|
||||
// Simulate dragleave
|
||||
fireEvent.dragLeave(card, {
|
||||
dataTransfer: { types: ["Files"] },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show drop indicator for non-file drag", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
// Simulate card dragover (not files)
|
||||
fireEvent.dragOver(card, {
|
||||
dataTransfer: { types: ["text/plain"], dropEffect: "none" },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("calls uploadAttachment on file drop", async () => {
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
mockUpload.mockResolvedValue({
|
||||
filename: "abc-test.png",
|
||||
originalName: "test.png",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
const file = new File(["content"], "test.png", { type: "image/png" });
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ["Files"], files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("FN-001", file, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Attached test.png"),
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when upload fails", async () => {
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
mockUpload.mockRejectedValue(new Error("Upload failed"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
const file = new File(["content"], "bad.png", { type: "image/png" });
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ["Files"], files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to attach bad.png"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Size badge positioning regression tests (KB-197)
|
||||
it("renders size badge for sized tasks", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
expect(container.querySelector(".card-size-badge")).not.toBeNull();
|
||||
expect(screen.getByText("S")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render size badge when task has no size", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask({ size: undefined })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
expect(container.querySelector(".card-size-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders all three size values with correct CSS classes", () => {
|
||||
const sizes: Array<"S" | "M" | "L"> = ["S", "M", "L"];
|
||||
const expectedClasses = ["size-s", "size-m", "size-l"];
|
||||
|
||||
sizes.forEach((size, index) => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask({ size })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const badge = container.querySelector(".card-size-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.classList.contains(expectedClasses[index])).toBe(true);
|
||||
// Clean up for next iteration
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it("places size badge inside card-header-actions container", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask({ size: "M" })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const actionsContainer = container.querySelector(".card-header-actions");
|
||||
const sizeBadge = container.querySelector(".card-size-badge");
|
||||
|
||||
expect(actionsContainer).not.toBeNull();
|
||||
expect(sizeBadge).not.toBeNull();
|
||||
expect(actionsContainer?.contains(sizeBadge)).toBe(true);
|
||||
});
|
||||
|
||||
it("places card-header-actions after card-id in DOM order", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const cardId = container.querySelector(".card-id")!;
|
||||
const actionsContainer = container.querySelector(".card-header-actions")!;
|
||||
|
||||
expect(cardId).not.toBeNull();
|
||||
expect(actionsContainer).not.toBeNull();
|
||||
// The actions container should come after card-id
|
||||
expect(
|
||||
cardId.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders edit button inside card-header-actions for editable columns", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "todo", size: "S" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onUpdateTask={async () => makeTask()}
|
||||
/>,
|
||||
);
|
||||
const actionsContainer = container.querySelector(".card-header-actions");
|
||||
const editBtn = container.querySelector(".card-edit-btn");
|
||||
|
||||
expect(actionsContainer).not.toBeNull();
|
||||
expect(editBtn).not.toBeNull();
|
||||
expect(actionsContainer?.contains(editBtn)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders archive button inside card-header-actions for done column", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "done", size: "L" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onArchiveTask={async () => makeTask()}
|
||||
/>,
|
||||
);
|
||||
const actionsContainer = container.querySelector(".card-header-actions");
|
||||
const archiveBtn = container.querySelector(".card-archive-btn");
|
||||
|
||||
expect(actionsContainer).not.toBeNull();
|
||||
expect(archiveBtn).not.toBeNull();
|
||||
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard mission badge", () => {
|
||||
// Access the internal cache reset helper
|
||||
let clearCache: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("./TaskCard");
|
||||
clearCache = (mod as any).__test_clearMissionTitleCache;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache?.();
|
||||
vi.mocked(fetchMission).mockReset();
|
||||
});
|
||||
|
||||
it("displays mission title instead of missionId", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-ABC123",
|
||||
title: "Database Optimization",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-ABC123" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
// MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..."
|
||||
expect(badge?.textContent).toContain("Database ...");
|
||||
});
|
||||
});
|
||||
|
||||
it("abbreviates long mission titles with ellipsis", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-LONG1",
|
||||
title: "This Is A Very Long Mission Title That Exceeds Twenty Characters",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-LONG1" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
// MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..."
|
||||
expect(badge?.textContent).toContain("This Is A...");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to missionId on fetch error", async () => {
|
||||
vi.mocked(fetchMission).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-ERR99" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badge?.textContent).toContain("M-ERR99");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mission title in title attribute", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-TITLE",
|
||||
title: "Refactor Auth",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-TITLE" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badge?.getAttribute("title")).toBe("Mission: Refactor Auth");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows short mission title without abbreviation", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-SHORT",
|
||||
title: "Auth Fix",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-SHORT" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
// "Auth Fix" is 8 chars, well under 20 — no abbreviation needed
|
||||
expect(badge?.textContent).toContain("Auth Fix");
|
||||
expect(badge?.textContent).not.toContain("...");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard agent badge", () => {
|
||||
let clearAgentCache: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("./TaskCard");
|
||||
clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearAgentCache?.();
|
||||
vi.mocked(fetchAgent).mockReset();
|
||||
});
|
||||
|
||||
it("renders agent badge when task has assignedAgentId", async () => {
|
||||
vi.mocked(fetchAgent).mockResolvedValue({
|
||||
id: "agent-001",
|
||||
name: "Task Robot",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
metadata: {},
|
||||
heartbeatHistory: [],
|
||||
completedRuns: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ assignedAgentId: "agent-001" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Assigned to Task Robot")).toBeDefined();
|
||||
expect(screen.getByText("Task Robot")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render agent badge when assignedAgentId is undefined", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTitle(/Assigned to/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||
import { AgentReflectionsTab } from "../AgentReflectionsTab";
|
||||
import {
|
||||
addAgentRating,
|
||||
deleteAgentRating,
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
fetchAgentRatingSummary,
|
||||
fetchAgentReflections,
|
||||
triggerAgentReflection,
|
||||
} from "../api";
|
||||
} from "../../api";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
vi.mock("../../api", () => ({
|
||||
addAgentRating: vi.fn(),
|
||||
deleteAgentRating: vi.fn(),
|
||||
fetchAgentPerformance: vi.fn(),
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DashboardLoader } from "./DashboardLoader";
|
||||
import { DashboardLoader } from "../DashboardLoader";
|
||||
|
||||
function getStep(label: string): HTMLElement {
|
||||
const step = screen.getByText(label).closest("li");
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowserModal } from "./FileBrowserModal";
|
||||
import * as workspaceBrowserHook from "../hooks/useWorkspaceFileBrowser";
|
||||
import * as workspaceEditorHook from "../hooks/useWorkspaceFileEditor";
|
||||
import * as workspacesHook from "../hooks/useWorkspaces";
|
||||
import { FileBrowserModal } from "../FileBrowserModal";
|
||||
import * as workspaceBrowserHook from "../../hooks/useWorkspaceFileBrowser";
|
||||
import * as workspaceEditorHook from "../../hooks/useWorkspaceFileEditor";
|
||||
import * as workspacesHook from "../../hooks/useWorkspaces";
|
||||
|
||||
vi.mock("../hooks/useWorkspaceFileBrowser");
|
||||
vi.mock("../hooks/useWorkspaceFileEditor");
|
||||
vi.mock("../hooks/useWorkspaces");
|
||||
vi.mock("../../hooks/useWorkspaceFileBrowser");
|
||||
vi.mock("../../hooks/useWorkspaceFileEditor");
|
||||
vi.mock("../../hooks/useWorkspaces");
|
||||
|
||||
const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser);
|
||||
const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor);
|
||||
@@ -222,10 +222,8 @@ describe("FileBrowserModal", () => {
|
||||
it("long file path is truncated on mobile", async () => {
|
||||
// Read CSS file directly to verify the overflow/ellipsis rules
|
||||
// (JSDOM doesn't apply stylesheets, so computed style checks won't work)
|
||||
const { readFileSync } = await import("fs");
|
||||
const { resolve } = await import("path");
|
||||
const cssPath = resolve(__dirname, "../styles.css");
|
||||
const cssContent = readFileSync(cssPath, "utf-8");
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const cssContent = loadAllAppCss();
|
||||
|
||||
// Extract mobile media query blocks
|
||||
function extractMobileMediaBlocks(content: string): string {
|
||||
@@ -532,10 +530,8 @@ describe("FileBrowserModal", () => {
|
||||
|
||||
describe("modal height constraint regression", () => {
|
||||
it("max-height uses calc() to stay within viewport padding", async () => {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
||||
const css = fs.readFileSync(cssPath, "utf-8");
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCss();
|
||||
|
||||
// Extract the first .file-browser-modal block (desktop base styles)
|
||||
// Match from ".file-browser-modal {" to its closing "}"
|
||||
@@ -553,10 +549,8 @@ describe("FileBrowserModal", () => {
|
||||
});
|
||||
|
||||
it("height and max-height together do not exceed viewport on desktop", async () => {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
||||
const css = fs.readFileSync(cssPath, "utf-8");
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCss();
|
||||
|
||||
const blockMatch = css.match(
|
||||
/\.file-browser-modal\s*\{([^}]*)\}/,
|
||||
@@ -581,10 +575,8 @@ describe("FileBrowserModal", () => {
|
||||
});
|
||||
|
||||
it("mobile styles use 100dvh for full-screen behavior", async () => {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
||||
const css = fs.readFileSync(cssPath, "utf-8");
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCss();
|
||||
|
||||
// Extract mobile media query blocks (similar to existing pattern)
|
||||
function extractMobileMediaBlocks(content: string): string {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FileEditor } from "../FileEditor";
|
||||
|
||||
describe("FileEditor", () => {
|
||||
it("renders textarea with correct class names", () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
|
||||
import { MilestoneSliceInterviewModal } from "../MilestoneSliceInterviewModal";
|
||||
|
||||
const mockStartMilestoneInterview = vi.fn();
|
||||
const mockStartSliceInterview = vi.fn();
|
||||
@@ -18,7 +18,7 @@ const mockForceAcquireSessionLock = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockParseConversationHistory = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
vi.mock("../../api", () => ({
|
||||
startMilestoneInterview: (...args: any[]) => mockStartMilestoneInterview(...args),
|
||||
startSliceInterview: (...args: any[]) => mockStartSliceInterview(...args),
|
||||
respondToMilestoneInterview: (...args: any[]) => mockRespondToMilestoneInterview(...args),
|
||||
@@ -36,7 +36,7 @@ vi.mock("../api", () => ({
|
||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useSessionLock", () => ({
|
||||
vi.mock("../../hooks/useSessionLock", () => ({
|
||||
useSessionLock: vi.fn(() => ({
|
||||
isLockedByOther: false,
|
||||
takeControl: vi.fn(),
|
||||
@@ -44,7 +44,7 @@ vi.mock("../hooks/useSessionLock", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useAiSessionSync", () => ({
|
||||
vi.mock("../../hooks/useAiSessionSync", () => ({
|
||||
useAiSessionSync: vi.fn(() => ({
|
||||
activeTabMap: new Map(),
|
||||
broadcastUpdate: vi.fn(),
|
||||
@@ -55,7 +55,7 @@ vi.mock("../hooks/useAiSessionSync", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/getSessionTabId", () => ({
|
||||
vi.mock("../../utils/getSessionTabId", () => ({
|
||||
getSessionTabId: vi.fn(() => "test-tab-id"),
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import * as api from "../api";
|
||||
import { PlanningModeModal } from "./PlanningModeModal";
|
||||
import { TaskDetailModal } from "./TaskDetailModal";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
import { useSessionLock } from "../../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../../utils/getSessionTabId";
|
||||
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
|
||||
|
||||
// Mock the API functions
|
||||
@@ -34,7 +34,7 @@ const mockApprovePlan = vi.fn();
|
||||
const mockRejectPlan = vi.fn();
|
||||
const mockRefineTask = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
vi.mock("../../api", () => ({
|
||||
startPlanning: (...args: any[]) => mockStartPlanning(...args),
|
||||
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||
@@ -444,10 +444,8 @@ describe("PlanningModeModal", () => {
|
||||
const modal = container.querySelector(".planning-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
||||
const css = fs.readFileSync(cssPath, "utf-8");
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCss();
|
||||
|
||||
const blockMatch = css.match(
|
||||
/\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import { SessionNotificationBanner, dismissedIds } from "./SessionNotificationBanner";
|
||||
import type { AiSessionSummary } from "../../api";
|
||||
import { SessionNotificationBanner, dismissedIds } from "../SessionNotificationBanner";
|
||||
|
||||
function buildSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
|
||||
return {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
||||
import { SubtaskBreakdownModal } from "../SubtaskBreakdownModal";
|
||||
|
||||
const mockStartSubtaskBreakdown = vi.fn();
|
||||
const mockRetrySubtaskSession = vi.fn();
|
||||
@@ -13,7 +13,7 @@ const mockAcquireSessionLock = vi.fn();
|
||||
const mockReleaseSessionLock = vi.fn();
|
||||
const mockForceAcquireSessionLock = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
vi.mock("../../api", () => ({
|
||||
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
|
||||
retrySubtaskSession: (...args: any[]) => mockRetrySubtaskSession(...args),
|
||||
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
|
||||
@@ -26,7 +26,7 @@ vi.mock("../api", () => ({
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/modalPersistence", () => ({
|
||||
vi.mock("../../hooks/modalPersistence", () => ({
|
||||
saveSubtaskDescription: vi.fn(),
|
||||
getSubtaskDescription: vi.fn(() => ""),
|
||||
clearSubtaskDescription: vi.fn(),
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
import * as useUsageDataModule from "../hooks/useUsageData";
|
||||
import type { ProviderUsage } from "../api";
|
||||
import { scopedKey } from "../utils/projectStorage";
|
||||
import { UsageIndicator } from "../UsageIndicator";
|
||||
import * as useUsageDataModule from "../../hooks/useUsageData";
|
||||
import type { ProviderUsage } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
// Mock the useUsageData hook
|
||||
vi.mock("../hooks/useUsageData", () => ({
|
||||
vi.mock("../../hooks/useUsageData", () => ({
|
||||
useUsageData: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
||||
import { fetchWorkflowSteps } from "../api";
|
||||
import { WorkflowResultsTab } from "../WorkflowResultsTab";
|
||||
import { fetchWorkflowSteps } from "../../api";
|
||||
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -738,7 +738,7 @@ describe("WorkflowResultsTab", () => {
|
||||
// Read the component source file
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const componentPath = path.join(__dirname, "WorkflowResultsTab.tsx");
|
||||
const componentPath = path.join(__dirname, "..", "WorkflowResultsTab.tsx");
|
||||
const componentSource = fs.readFileSync(componentPath, "utf-8");
|
||||
|
||||
// These hardcoded color patterns should NOT appear in the component
|
||||
@@ -758,7 +758,7 @@ describe("WorkflowResultsTab", () => {
|
||||
it("prevents reintroduction of getStatusColor function with hardcoded colors", () => {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const componentPath = path.join(__dirname, "WorkflowResultsTab.tsx");
|
||||
const componentPath = path.join(__dirname, "..", "WorkflowResultsTab.tsx");
|
||||
const componentSource = fs.readFileSync(componentPath, "utf-8");
|
||||
|
||||
// The getStatusColor function should not exist (removed to use CSS classes)
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useCurrentProject } from "./useCurrentProject";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import { useCurrentProject } from "../useCurrentProject";
|
||||
import type { ProjectInfo } from "../../api";
|
||||
|
||||
// Mock the API functions
|
||||
vi.mock("../api", () => ({
|
||||
vi.mock("../../api", () => ({
|
||||
fetchGlobalSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
|
||||
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
|
||||
|
||||
describe("useCurrentProject", () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useExecutorStats } from "./useExecutorStats";
|
||||
import * as apiModule from "../api";
|
||||
import { useExecutorStats } from "../useExecutorStats";
|
||||
import * as apiModule from "../../api";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../api", async () => {
|
||||
const actual = await vi.importActual("../api");
|
||||
vi.mock("../../api", async () => {
|
||||
const actual = await vi.importActual("../../api");
|
||||
return {
|
||||
...actual,
|
||||
fetchExecutorStats: vi.fn(),
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useFlashOnIncrease } from "./useFlashOnIncrease";
|
||||
import { useFlashOnIncrease } from "../useFlashOnIncrease";
|
||||
|
||||
describe("useFlashOnIncrease", () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
import { useTerminal } from "../useTerminal";
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, renderHook, act, screen } from "@testing-library/react";
|
||||
import { ToastProvider, useToast } from "./useToast";
|
||||
import { ToastProvider, useToast } from "../useToast";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
@@ -1,280 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useActivityLog } from "./useActivityLog";
|
||||
import * as apiModule from "../api";
|
||||
import type { ActivityFeedEntry } from "../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../api", () => ({
|
||||
fetchActivityFeed: vi.fn(),
|
||||
fetchActivityLog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
||||
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
||||
|
||||
/** Create ActivityFeedEntry[] entries (unified feed format) */
|
||||
function createFeedEntries(
|
||||
count: number,
|
||||
projectId = "proj_123",
|
||||
projectName = "Test Project",
|
||||
): ActivityFeedEntry[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `feed_entry_${i}`,
|
||||
timestamp: new Date(Date.now() - i * 60000).toISOString(),
|
||||
type: "task:created" as const,
|
||||
projectId,
|
||||
projectName,
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task",
|
||||
details: "Task created",
|
||||
}));
|
||||
}
|
||||
|
||||
describe("useActivityLog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
// Default: both mocks return empty arrays
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Single-project mode (default) ─────────────────────────────────
|
||||
|
||||
it("initializes with empty entries and loads on mount", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.entries).toEqual([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
// Should use per-project log, not unified feed
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches entries from per-project log in single-project mode", async () => {
|
||||
const mockEntries = createFeedEntries(1);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
metadata: e.metadata,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Hook converts ActivityLogEntry to ActivityFeedEntry with empty project fields
|
||||
expect(result.current.entries[0].type).toBe("task:created");
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by type via per-project log", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useActivityLog({ type: "task:created" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:created" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("respects custom limit via per-project log", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useActivityLog({ limit: 100 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-refresh when disabled", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useActivityLog({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Advance time — should not trigger another fetch
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh function manually refreshes data", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("clear removes all entries", async () => {
|
||||
const mockEntries = createFeedEntries(1);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clear();
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
mockFetchActivityLog.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).not.toBeNull();
|
||||
});
|
||||
|
||||
it("sets hasMore when entries equal limit", async () => {
|
||||
const mockEntries = createFeedEntries(50);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(50);
|
||||
});
|
||||
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("sets hasMore to false when fewer entries than limit", async () => {
|
||||
const mockEntries = createFeedEntries(30);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(30);
|
||||
});
|
||||
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
// ── Multi-project mode (useCentralFeed) ───────────────────────────
|
||||
|
||||
it("fetches from unified feed when useCentralFeed is true", async () => {
|
||||
const mockEntries = createFeedEntries(2, "proj_multi", "Multi Project");
|
||||
mockFetchActivityFeed.mockResolvedValue(mockEntries);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActivityLog({ useCentralFeed: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].projectName).toBe("Multi Project");
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
||||
expect(mockFetchActivityLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes projectId to unified feed when useCentralFeed is true", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
|
||||
renderHook(() =>
|
||||
useActivityLog({ projectId: "proj_456", useCentralFeed: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: "proj_456" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes type filter to unified feed when useCentralFeed is true", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
|
||||
renderHook(() =>
|
||||
useActivityLog({ type: "task:failed", useCentralFeed: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:failed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,446 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
fetchProjects,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
fetchProject,
|
||||
updateProject,
|
||||
detectProjects,
|
||||
fetchProjectHealth,
|
||||
fetchActivityFeed,
|
||||
pauseProject,
|
||||
resumeProject,
|
||||
fetchFirstRunStatus,
|
||||
fetchGlobalConcurrency,
|
||||
fetchProjectTasks,
|
||||
fetchProjectConfig,
|
||||
type ProjectInfo,
|
||||
type ProjectHealth,
|
||||
type ActivityFeedEntry,
|
||||
type FirstRunStatus,
|
||||
type GlobalConcurrencyState,
|
||||
type DetectedProject,
|
||||
} from "../api";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
body: unknown,
|
||||
status = ok ? 200 : 500,
|
||||
contentType = "application/json"
|
||||
) {
|
||||
const bodyText = JSON.stringify(body);
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? contentType : null,
|
||||
},
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(bodyText),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
describe("Project Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("fetchProjects", () => {
|
||||
it("returns empty array when no projects", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const result = await fetchProjects();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns projects list when available", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
{
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProjects));
|
||||
|
||||
const result = await fetchProjects();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("proj_123");
|
||||
expect(result[0].name).toBe("Test Project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerProject", () => {
|
||||
it("registers a new project with valid input", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_new",
|
||||
name: "New Project",
|
||||
path: "/absolute/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await registerProject({
|
||||
name: "New Project",
|
||||
path: "/absolute/path",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("proj_new");
|
||||
expect(result.name).toBe("New Project");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregisterProject", () => {
|
||||
it("unregisters a project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
await unregisterProject("proj_test123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_test123",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectHealth", () => {
|
||||
it("returns health metrics for a project", async () => {
|
||||
const mockHealth: ProjectHealth = {
|
||||
projectId: "proj_test123",
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 10,
|
||||
totalTasksFailed: 1,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockHealth));
|
||||
|
||||
const result = await fetchProjectHealth("proj_test123");
|
||||
|
||||
expect(result.projectId).toBe("proj_test123");
|
||||
expect(result.activeTaskCount).toBe(5);
|
||||
expect(result.totalTasksCompleted).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchActivityFeed", () => {
|
||||
it("returns activity feed entries", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
|
||||
const result = await fetchActivityFeed();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("task:created");
|
||||
expect(result[0].projectName).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("supports limit parameter", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchActivityFeed({ limit: 10 });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("limit=10"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("supports projectId filter", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchActivityFeed({ projectId: "proj_123" });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("projectId=proj_123"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchFirstRunStatus", () => {
|
||||
it("returns first run status", async () => {
|
||||
const mockStatus: FirstRunStatus = {
|
||||
hasProjects: false,
|
||||
singleProjectPath: null,
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
||||
|
||||
const result = await fetchFirstRunStatus();
|
||||
|
||||
expect(result.hasProjects).toBe(false);
|
||||
expect(result.singleProjectPath).toBeNull();
|
||||
});
|
||||
|
||||
it("returns single project path when only one project", async () => {
|
||||
const mockStatus: FirstRunStatus = {
|
||||
hasProjects: true,
|
||||
singleProjectPath: "/projects/my-project",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
||||
|
||||
const result = await fetchFirstRunStatus();
|
||||
|
||||
expect(result.hasProjects).toBe(true);
|
||||
expect(result.singleProjectPath).toBe("/projects/my-project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGlobalConcurrency", () => {
|
||||
it("returns global concurrency state", async () => {
|
||||
const mockState: GlobalConcurrencyState = {
|
||||
globalMaxConcurrent: 4,
|
||||
currentlyActive: 2,
|
||||
queuedCount: 0,
|
||||
projectsActive: { "proj_123": 2 },
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockState));
|
||||
|
||||
const result = await fetchGlobalConcurrency();
|
||||
|
||||
expect(result.globalMaxConcurrent).toBe(4);
|
||||
expect(result.currentlyActive).toBe(2);
|
||||
expect(result.projectsActive["proj_123"]).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pauseProject", () => {
|
||||
it("pauses a project", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "paused",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await pauseProject("proj_123");
|
||||
|
||||
expect(result.status).toBe("paused");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123/pause",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeProject", () => {
|
||||
it("resumes a paused project", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await resumeProject("proj_123");
|
||||
|
||||
expect(result.status).toBe("active");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123/resume",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectTasks", () => {
|
||||
it("fetches tasks for a specific project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchProjectTasks("proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("projectId=proj_123"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("supports pagination", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchProjectTasks("proj_123", 10, 20);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("limit=10"),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("offset=20"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProjectConfig", () => {
|
||||
it("fetches project config", async () => {
|
||||
const mockConfig = { maxConcurrent: 4, rootDir: "/projects/test" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockConfig));
|
||||
|
||||
const result = await fetchProjectConfig("proj_123");
|
||||
|
||||
expect(result.maxConcurrent).toBe(4);
|
||||
expect(result.rootDir).toBe("/projects/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProject (single)", () => {
|
||||
it("fetches a specific project by ID", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Specific Project",
|
||||
path: "/specific/path",
|
||||
status: "active",
|
||||
isolationMode: "child-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await fetchProject("proj_123");
|
||||
|
||||
expect(result.id).toBe("proj_123");
|
||||
expect(result.name).toBe("Specific Project");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateProject", () => {
|
||||
it("updates project with valid data", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Updated Name",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await updateProject("proj_123", { name: "Updated Name" });
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("updates project isolationMode", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "child-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const result = await updateProject("proj_123", { isolationMode: "child-process" });
|
||||
|
||||
expect(result.isolationMode).toBe("child-process");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProjects", () => {
|
||||
it("auto-detects projects in a base path", async () => {
|
||||
const mockDetected = {
|
||||
projects: [
|
||||
{ path: "/home/user/project1", suggestedName: "project1", existing: false },
|
||||
{ path: "/home/user/project2", suggestedName: "project2", existing: true },
|
||||
],
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockDetected));
|
||||
|
||||
const result = await detectProjects("/home/user");
|
||||
|
||||
expect(result.projects).toHaveLength(2);
|
||||
expect(result.projects[0].path).toBe("/home/user/project1");
|
||||
expect(result.projects[0].suggestedName).toBe("project1");
|
||||
expect(result.projects[1].existing).toBe(true);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/detect",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ basePath: "/home/user" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses home directory when basePath not provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { projects: [] }));
|
||||
|
||||
await detectProjects();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/detect",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ basePath: undefined }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useUsageData } from "./useUsageData";
|
||||
import * as api from "../api";
|
||||
|
||||
describe("useUsageData", () => {
|
||||
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchUsageData.mockClear();
|
||||
});
|
||||
|
||||
it("fetches data on initial mount", async () => {
|
||||
const mockData = {
|
||||
providers: [
|
||||
{
|
||||
name: "Claude",
|
||||
icon: "🟠",
|
||||
status: "ok" as const,
|
||||
windows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValue(mockData);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
// Should be loading initially
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.providers).toEqual([]);
|
||||
|
||||
// Wait for data to load
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.providers).toEqual(mockData.providers);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Network error");
|
||||
expect(result.current.providers).toEqual([]);
|
||||
});
|
||||
|
||||
it("manual refresh fetches new data", async () => {
|
||||
const mockData1 = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
const mockData2 = {
|
||||
providers: [{ name: "Codex", icon: "🟢", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
|
||||
mockFetchUsageData
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.providers).toEqual(mockData1.providers);
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
|
||||
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
|
||||
});
|
||||
|
||||
it("clears error on successful manual refresh after error", async () => {
|
||||
mockFetchUsageData
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("Network error");
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeNull());
|
||||
expect(result.current.providers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exports the correct interface", () => {
|
||||
expect(typeof useUsageData).toBe("function");
|
||||
});
|
||||
|
||||
it("returns expected default values before first fetch", () => {
|
||||
mockFetchUsageData.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
expect(result.current.providers).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeNull();
|
||||
expect(typeof result.current.refresh).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -1,512 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { getAgentHealthStatus, getAgentHealthColorVar } from "./agentHealth";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
// Mock Date.now to get deterministic elapsed time calculations
|
||||
const FIXED_NOW = new Date("2026-04-10T12:00:00.000Z").getTime();
|
||||
|
||||
type AgentHealthInput = Pick<
|
||||
Agent,
|
||||
"state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig" | "metadata" | "name" | "role" | "taskId"
|
||||
>;
|
||||
|
||||
function makeAgent(overrides: Partial<AgentHealthInput> = {}): AgentHealthInput {
|
||||
return {
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
taskId: undefined,
|
||||
metadata: {},
|
||||
lastHeartbeatAt: undefined,
|
||||
lastError: undefined,
|
||||
pauseReason: undefined,
|
||||
runtimeConfig: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAgentHealthStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Terminal states ──────────────────────────────────────────────────────
|
||||
|
||||
describe("terminated state", () => {
|
||||
it('returns "Terminated" for terminated agents', () => {
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for terminated agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "terminated",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
it('returns "Error" for error agents without lastError', () => {
|
||||
const agent = makeAgent({ state: "error" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Error");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
it("uses lastError as label when available", () => {
|
||||
const agent = makeAgent({ state: "error", lastError: "Agent crashed" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Agent crashed");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for error agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "error",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Error");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paused state", () => {
|
||||
it('returns "Paused" for paused agents without pauseReason', () => {
|
||||
const agent = makeAgent({ state: "paused" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-paused-text)");
|
||||
});
|
||||
|
||||
it("includes pauseReason in label when available", () => {
|
||||
const agent = makeAgent({ state: "paused", pauseReason: "User requested" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused: User requested");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for paused agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "paused",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("running state", () => {
|
||||
it('returns "Running" for running agents', () => {
|
||||
const agent = makeAgent({ state: "running" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for running agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "running",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // 100s ago - would be "unresponsive" without this
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Heartbeat scheduling is driven by agent.state on the server; there is no
|
||||
// separate "disabled" UI concept anymore. Non-task-worker agents with a
|
||||
// legacy `runtimeConfig.enabled === false` on disk are rendered by state
|
||||
// just like any other agent.
|
||||
|
||||
describe("task worker health classification", () => {
|
||||
it('returns "Running" for metadata-marked task workers with disabled heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
name: "executor-FN-1661",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-1661",
|
||||
metadata: {
|
||||
agentKind: "task-worker",
|
||||
taskWorker: true,
|
||||
managedBy: "task-executor",
|
||||
},
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(),
|
||||
runtimeConfig: { enabled: false, heartbeatTimeoutMs: 60_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('returns "Running" for legacy executor-* task workers with stale heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
name: "executor-FN-1661",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-1661",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('ignores legacy runtimeConfig.enabled=false on non-task-worker agents', () => {
|
||||
const agent = makeAgent({
|
||||
name: "Reviewer",
|
||||
role: "reviewer",
|
||||
state: "active",
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// No persisted heartbeat, no lastHeartbeatAt → Starting... not Disabled.
|
||||
expect(status.label).toBe("Starting...");
|
||||
});
|
||||
});
|
||||
|
||||
// ── No heartbeat data ──────────────────────────────────────────────────────
|
||||
|
||||
describe("no heartbeat data", () => {
|
||||
it('returns "Starting..." for active agents with no lastHeartbeatAt', () => {
|
||||
const agent = makeAgent({ state: "active" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Starting...");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
it('returns "Idle" for non-active agents with no lastHeartbeatAt', () => {
|
||||
const agent = makeAgent({ state: "idle" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Idle");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
it('returns "Idle" for terminated agents without heartbeat (edge case)', () => {
|
||||
// Although terminated state takes precedence, testing the fallback
|
||||
const agent = makeAgent({ state: "idle", lastHeartbeatAt: undefined });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Idle");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Healthy vs Unresponsive ───────────────────────────────────────────────
|
||||
|
||||
describe("heartbeat freshness", () => {
|
||||
it('returns "Healthy" when heartbeat is fresh (within timeout) with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(), // 30s ago, well within 60s timeout
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('returns "Healthy" when heartbeat is exactly at the timeout boundary with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // exactly 60s ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Unresponsive" when heartbeat exceeds the timeout with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 12 * 60 * 1000 - 1).toISOString(), // just over 12 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
it("ignores heartbeatTimeoutMs — that's the per-run work budget, not freshness", () => {
|
||||
// 30s interval → staleness threshold = max(60s floor, 60s) = 60s. A
|
||||
// 45s-old heartbeat is healthy regardless of what heartbeatTimeoutMs says.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 45_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agents without explicit heartbeatIntervalMs ───────────────────────────
|
||||
//
|
||||
// Agents that never had an interval persisted still get the server-side
|
||||
// default interval (1h), so they render Healthy within ~2h of the last
|
||||
// heartbeat and tip into Unresponsive beyond that.
|
||||
|
||||
describe("agents without explicit heartbeatIntervalMs", () => {
|
||||
it('returns "Healthy" within the default-interval grace window', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // 1m ago
|
||||
runtimeConfig: {}, // no interval — falls back to 1h default
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Unresponsive" once elapsed exceeds 2× the default 1h interval', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 3 * 3_600_000).toISOString(), // 3h ago
|
||||
runtimeConfig: {},
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it("clamps invalid intervals (0/negative) to the dashboard minimum (5m)", () => {
|
||||
// 0 clamp to 300000ms (5m minimum) → threshold = max(300000 × 2, 60000) = 600000ms (10 minutes).
|
||||
// A heartbeat 11 minutes old is stale.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 660_000).toISOString(), // 11 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 0 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Staleness floor ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Short intervals get a 60s floor so the UI doesn't flicker between
|
||||
// Healthy and Unresponsive every tick for second-level heartbeats.
|
||||
|
||||
describe("staleness floor", () => {
|
||||
it("holds Healthy below the 60s floor even for sub-minute intervals", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 10_000 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("tips to Unresponsive past the floor", () => {
|
||||
// 6 minute interval → threshold = max(6 × 60s × 2, 60s floor) = max(12 min, 1 min) = 12 minutes.
|
||||
// A heartbeat 13 minutes old exceeds the 12-minute threshold.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stateDerived semantics", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "paused without reason",
|
||||
agent: makeAgent({ state: "paused" }),
|
||||
expectedLabel: "Paused",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "paused with reason",
|
||||
agent: makeAgent({ state: "paused", pauseReason: "Backoff" }),
|
||||
expectedLabel: "Paused: Backoff",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "running",
|
||||
agent: makeAgent({ state: "running" }),
|
||||
expectedLabel: "Running",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "error without lastError",
|
||||
agent: makeAgent({ state: "error" }),
|
||||
expectedLabel: "Error",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "error with lastError",
|
||||
agent: makeAgent({ state: "error", lastError: "OOM" }),
|
||||
expectedLabel: "OOM",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "terminated",
|
||||
agent: makeAgent({ state: "terminated" }),
|
||||
expectedLabel: "Terminated",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "healthy",
|
||||
agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 10_000).toISOString() }),
|
||||
expectedLabel: "Healthy",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "unresponsive",
|
||||
agent: makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
}),
|
||||
expectedLabel: "Unresponsive",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "idle",
|
||||
agent: makeAgent({ state: "idle", lastHeartbeatAt: undefined }),
|
||||
expectedLabel: "Idle",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "starting",
|
||||
agent: makeAgent({ state: "active", lastHeartbeatAt: undefined }),
|
||||
expectedLabel: "Starting...",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
])("sets stateDerived correctly for $name", ({ agent, expectedLabel, expectedStateDerived }) => {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe(expectedLabel);
|
||||
expect(status.stateDerived).toBe(expectedStateDerived);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edge cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles null runtimeConfig gracefully", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
|
||||
runtimeConfig: null as unknown as undefined,
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty runtimeConfig object", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
|
||||
runtimeConfig: {},
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("100s stale heartbeat with no explicit interval → Healthy (default 1h applies)", () => {
|
||||
// 1h default interval → 2h threshold, so 100s is well within range.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no heartbeatIntervalMs
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("ignores runtimeConfig.enabled and uses interval-based staleness", () => {
|
||||
// 6 minute interval → 12 minute threshold. 13 minutes elapsed is stale regardless of any
|
||||
// legacy enabled flag or per-run timeout.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
runtimeConfig: { enabled: true, heartbeatIntervalMs: 6 * 60 * 1000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it("returns consistent icons for all states", () => {
|
||||
const testCases: Array<{ agent: ReturnType<typeof makeAgent>; expectedIconType: string }> = [
|
||||
{ agent: makeAgent({ state: "terminated" }), expectedIconType: "Square" },
|
||||
{ agent: makeAgent({ state: "error" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||
// state=active + no lastHeartbeatAt → "Starting..." → Bot icon
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" },
|
||||
{
|
||||
agent: makeAgent({
|
||||
name: "executor-FN-1661",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-1661",
|
||||
metadata: { agentKind: "task-worker" },
|
||||
runtimeConfig: { enabled: false },
|
||||
}),
|
||||
expectedIconType: "Activity",
|
||||
},
|
||||
// Active with recent heartbeat should show "Healthy" (Heart icon)
|
||||
{ agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString() }), expectedIconType: "Heart" },
|
||||
];
|
||||
|
||||
testCases.forEach(({ agent, expectedIconType }) => {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// lucide icons expose their component on the JSX element's `type`
|
||||
const iconElement = status.icon as JSX.Element & {
|
||||
type?: {
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
const iconType = iconElement.type?.displayName ?? iconElement.type?.name;
|
||||
expect(iconType).toBe(expectedIconType);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentHealthColorVar", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("extracts CSS variable name from health status color", () => {
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const colorVar = getAgentHealthColorVar(agent);
|
||||
expect(colorVar).toBe("--state-error-text");
|
||||
});
|
||||
|
||||
it("returns full color for non-variable colors (fallback)", () => {
|
||||
// This shouldn't happen in practice, but testing the fallback
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// The function should return the variable name in var() format
|
||||
expect(getAgentHealthColorVar(agent)).toBe(status.color.replace(/var\((--[^)]+)\)/, "$1"));
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
HEARTBEAT_INTERVAL_PRESETS,
|
||||
MIN_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
||||
formatHeartbeatInterval,
|
||||
resolveHeartbeatIntervalMs,
|
||||
getHeartbeatIntervalOptions,
|
||||
} from "./heartbeatIntervals";
|
||||
|
||||
describe("HEARTBEAT_INTERVAL_PRESETS", () => {
|
||||
it("starts at 5 minutes (300000ms)", () => {
|
||||
expect(HEARTBEAT_INTERVAL_PRESETS[0].value).toBe(300000);
|
||||
expect(HEARTBEAT_INTERVAL_PRESETS[0].label).toBe("5m");
|
||||
});
|
||||
|
||||
it("includes 48h preset", () => {
|
||||
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "48h");
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.value).toBe(172800000);
|
||||
});
|
||||
|
||||
it("includes 72h preset", () => {
|
||||
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "72h");
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.value).toBe(259200000);
|
||||
});
|
||||
|
||||
it("includes 1w preset", () => {
|
||||
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "1w");
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.value).toBe(604800000);
|
||||
});
|
||||
|
||||
it("does not include any presets below 5 minutes", () => {
|
||||
const allBelow5m = HEARTBEAT_INTERVAL_PRESETS.filter((p) => p.value < 300000);
|
||||
expect(allBelow5m).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is sorted in ascending order by value", () => {
|
||||
for (let i = 1; i < HEARTBEAT_INTERVAL_PRESETS.length; i++) {
|
||||
expect(HEARTBEAT_INTERVAL_PRESETS[i].value).toBeGreaterThan(
|
||||
HEARTBEAT_INTERVAL_PRESETS[i - 1].value,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_HEARTBEAT_INTERVAL_MS", () => {
|
||||
it("is 5 minutes (300000ms)", () => {
|
||||
expect(MIN_HEARTBEAT_INTERVAL_MS).toBe(300000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHeartbeatInterval", () => {
|
||||
it("formats milliseconds below 1000", () => {
|
||||
expect(formatHeartbeatInterval(500)).toBe("500ms");
|
||||
});
|
||||
|
||||
it("formats seconds", () => {
|
||||
expect(formatHeartbeatInterval(1000)).toBe("1s");
|
||||
expect(formatHeartbeatInterval(30000)).toBe("30s");
|
||||
expect(formatHeartbeatInterval(45000)).toBe("45s");
|
||||
});
|
||||
|
||||
it("formats minutes", () => {
|
||||
expect(formatHeartbeatInterval(60000)).toBe("1m");
|
||||
expect(formatHeartbeatInterval(300000)).toBe("5m");
|
||||
expect(formatHeartbeatInterval(2700000)).toBe("45m");
|
||||
});
|
||||
|
||||
it("formats hours", () => {
|
||||
expect(formatHeartbeatInterval(3600000)).toBe("1h");
|
||||
expect(formatHeartbeatInterval(7200000)).toBe("2h");
|
||||
expect(formatHeartbeatInterval(43200000)).toBe("12h");
|
||||
});
|
||||
|
||||
it("formats days", () => {
|
||||
expect(formatHeartbeatInterval(86400000)).toBe("1d");
|
||||
expect(formatHeartbeatInterval(172800000)).toBe("2d");
|
||||
expect(formatHeartbeatInterval(432000000)).toBe("5d");
|
||||
});
|
||||
|
||||
it("formats weeks", () => {
|
||||
expect(formatHeartbeatInterval(604800000)).toBe("1w");
|
||||
expect(formatHeartbeatInterval(1209600000)).toBe("2w");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatIntervalMs", () => {
|
||||
it("returns default for non-number input", () => {
|
||||
expect(resolveHeartbeatIntervalMs(undefined)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs(null)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs("300000")).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs({})).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs([])).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
});
|
||||
|
||||
it("returns default for NaN or Infinity", () => {
|
||||
expect(resolveHeartbeatIntervalMs(NaN)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs(Infinity)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs(-Infinity)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
});
|
||||
|
||||
it("clamps values below 5 minutes to 5 minutes", () => {
|
||||
expect(resolveHeartbeatIntervalMs(0)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(1000)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(60000)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(299999)).toBe(300000);
|
||||
});
|
||||
|
||||
it("returns exact value for valid intervals >= 5 minutes", () => {
|
||||
expect(resolveHeartbeatIntervalMs(300000)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(600000)).toBe(600000);
|
||||
expect(resolveHeartbeatIntervalMs(3600000)).toBe(3600000);
|
||||
expect(resolveHeartbeatIntervalMs(172800000)).toBe(172800000);
|
||||
});
|
||||
|
||||
it("rounds floating point values", () => {
|
||||
expect(resolveHeartbeatIntervalMs(300001.7)).toBe(300002);
|
||||
expect(resolveHeartbeatIntervalMs(300001.3)).toBe(300001);
|
||||
});
|
||||
|
||||
it("clamps negative values to minimum", () => {
|
||||
expect(resolveHeartbeatIntervalMs(-1)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(-60000)).toBe(300000);
|
||||
});
|
||||
|
||||
describe("legacy sub-5m values resolve to 5m", () => {
|
||||
it("1s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(1000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("5s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(5000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("10s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(10000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("30s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(30000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("1m legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(60000)).toBe(300000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHeartbeatIntervalOptions", () => {
|
||||
it("returns all presets when interval matches a preset", () => {
|
||||
const options = getHeartbeatIntervalOptions(300000);
|
||||
expect(options).toEqual([...HEARTBEAT_INTERVAL_PRESETS]);
|
||||
});
|
||||
|
||||
it("adds custom option when interval does not match any preset", () => {
|
||||
const options = getHeartbeatIntervalOptions(650000);
|
||||
// Should have all presets plus a custom option
|
||||
expect(options.length).toBe(HEARTBEAT_INTERVAL_PRESETS.length + 1);
|
||||
// The custom option should be added and sorted in by value
|
||||
const customOption = options.find((o) => o.label.includes("(custom)"));
|
||||
expect(customOption?.value).toBe(650000);
|
||||
expect(customOption?.label).toBe("11m (custom)");
|
||||
});
|
||||
|
||||
it("sorts custom option into correct position by value", () => {
|
||||
// 48h is a preset, so no custom option added
|
||||
const optionsWithPreset = getHeartbeatIntervalOptions(172800000);
|
||||
expect(optionsWithPreset.length).toBe(HEARTBEAT_INTERVAL_PRESETS.length);
|
||||
expect(optionsWithPreset).toEqual([...HEARTBEAT_INTERVAL_PRESETS]);
|
||||
});
|
||||
|
||||
it("sorts custom option after 1w when custom value exceeds 1w", () => {
|
||||
// 500h is not a preset, should be added and sorted after 1w
|
||||
const options = getHeartbeatIntervalOptions(500 * 3600000);
|
||||
const customOption = options.find((o) => o.label.includes("(custom)"));
|
||||
expect(customOption).toBeDefined();
|
||||
// Custom option should be inserted at the end since 500h > 1w
|
||||
const customIndex = options.findIndex((o) => o.label.includes("(custom)"));
|
||||
expect(options[customIndex - 1].label).toBe("1w");
|
||||
});
|
||||
|
||||
it("handles custom intervals below the minimum", () => {
|
||||
// Even if a legacy custom value is below 5m, getHeartbeatIntervalOptions
|
||||
// should include it in the options (the resolver clamps when consuming)
|
||||
const options = getHeartbeatIntervalOptions(30000); // 30s - no longer a preset
|
||||
const customOption = options.find((o) => o.value === 30000);
|
||||
expect(customOption).toBeDefined();
|
||||
expect(customOption?.label).toBe("30s (custom)");
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { highlightDiff } from "./highlightDiff";
|
||||
import React from "react";
|
||||
|
||||
type ElProps = { className?: string; children?: React.ReactNode };
|
||||
const propsOf = (el: React.ReactNode): ElProps =>
|
||||
(el as React.ReactElement<ElProps>).props as ElProps;
|
||||
const typeOf = (el: React.ReactNode) =>
|
||||
(el as React.ReactElement).type;
|
||||
|
||||
describe("highlightDiff", () => {
|
||||
it("applies diff-add class to added lines starting with +", () => {
|
||||
const result = highlightDiff("+hello world");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
expect(typeOf(result[0])).toBe("span");
|
||||
expect(propsOf(result[0]).className).toBe("diff-add");
|
||||
expect(propsOf(result[0]).children).toBe("+hello world\n");
|
||||
});
|
||||
|
||||
it("applies diff-del class to removed lines starting with -", () => {
|
||||
const result = highlightDiff("-world");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
expect(typeOf(result[0])).toBe("span");
|
||||
expect(propsOf(result[0]).className).toBe("diff-del");
|
||||
expect(propsOf(result[0]).children).toBe("-world\n");
|
||||
});
|
||||
|
||||
it("applies diff-hunk class to hunk headers starting with @@", () => {
|
||||
const result = highlightDiff("@@ -1,5 +1,6 @@ function");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
expect(typeOf(result[0])).toBe("span");
|
||||
expect(propsOf(result[0]).className).toBe("diff-hunk");
|
||||
expect(propsOf(result[0]).children).toBe("@@ -1,5 +1,6 @@ function\n");
|
||||
});
|
||||
|
||||
it("does not apply special class to context lines", () => {
|
||||
const result = highlightDiff(" context line");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
// Context lines should be returned as plain text fragments
|
||||
expect(typeOf(result[0])).toBe(React.Fragment);
|
||||
expect(propsOf(result[0]).children).toBe(" context line\n");
|
||||
});
|
||||
|
||||
it("does not apply diff-add class to +++ lines", () => {
|
||||
const result = highlightDiff("+++ b/file.ts");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
expect(typeOf(result[0])).toBe(React.Fragment);
|
||||
expect(propsOf(result[0]).children).toBe("+++ b/file.ts\n");
|
||||
});
|
||||
|
||||
it("does not apply diff-del class to --- lines", () => {
|
||||
const result = highlightDiff("--- a/file.ts");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
expect(typeOf(result[0])).toBe(React.Fragment);
|
||||
expect(propsOf(result[0]).children).toBe("--- a/file.ts\n");
|
||||
});
|
||||
|
||||
it("renders multiple lines correctly with different classes", () => {
|
||||
const diff = `diff --git a/file.ts b/file.ts
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
context line
|
||||
+added line
|
||||
-deleted line
|
||||
another context`;
|
||||
|
||||
const result = highlightDiff(diff);
|
||||
|
||||
expect(result).toHaveLength(8);
|
||||
|
||||
// Line 0: diff --git - plain fragment
|
||||
expect(typeOf(result[0])).toBe(React.Fragment);
|
||||
|
||||
// Line 1: --- a/file.ts - plain fragment (not diff-del)
|
||||
expect(typeOf(result[1])).toBe(React.Fragment);
|
||||
|
||||
// Line 2: +++ b/file.ts - plain fragment (not diff-add)
|
||||
expect(typeOf(result[2])).toBe(React.Fragment);
|
||||
|
||||
// Line 3: @@ hunk header - diff-hunk
|
||||
expect(typeOf(result[3])).toBe("span");
|
||||
expect(propsOf(result[3]).className).toBe("diff-hunk");
|
||||
|
||||
// Line 4: context - plain fragment
|
||||
expect(typeOf(result[4])).toBe(React.Fragment);
|
||||
|
||||
// Line 5: +added - diff-add
|
||||
expect(typeOf(result[5])).toBe("span");
|
||||
expect(propsOf(result[5]).className).toBe("diff-add");
|
||||
|
||||
// Line 6: -deleted - diff-del
|
||||
expect(typeOf(result[6])).toBe("span");
|
||||
expect(propsOf(result[6]).className).toBe("diff-del");
|
||||
|
||||
// Line 7: another context - plain fragment
|
||||
expect(typeOf(result[7])).toBe(React.Fragment);
|
||||
});
|
||||
|
||||
it("renders empty diff without errors", () => {
|
||||
const result = highlightDiff("");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
// Empty string becomes single element with empty line
|
||||
expect(typeOf(result[0])).toBe(React.Fragment);
|
||||
expect(propsOf(result[0]).children).toBe("\n");
|
||||
});
|
||||
|
||||
it("handles single line without newline", () => {
|
||||
const result = highlightDiff("+single line");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
expect(typeOf(result[0])).toBe("span");
|
||||
expect(propsOf(result[0]).className).toBe("diff-add");
|
||||
expect(propsOf(result[0]).children).toBe("+single line\n");
|
||||
});
|
||||
|
||||
it("handles diff header lines correctly", () => {
|
||||
const diff = `diff --git a/src/index.ts b/src/index.ts
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/src/index.ts
|
||||
+++ b/src/index.ts
|
||||
@@ -10,6 +10,7 @@ export`;
|
||||
|
||||
const result = highlightDiff(diff);
|
||||
|
||||
// 5 lines total (split by \n)
|
||||
expect(result).toHaveLength(5);
|
||||
|
||||
// All header lines should be plain fragments, not diff-add/diff-del
|
||||
expect(typeOf(result[0])).toBe(React.Fragment);
|
||||
expect(typeOf(result[1])).toBe(React.Fragment);
|
||||
expect(typeOf(result[2])).toBe(React.Fragment); // --- a/src/index.ts
|
||||
expect(typeOf(result[3])).toBe(React.Fragment); // +++ b/src/index.ts
|
||||
expect(typeOf(result[4])).toBe("span");
|
||||
expect(propsOf(result[4]).className).toBe("diff-hunk");
|
||||
});
|
||||
});
|
||||
@@ -1,364 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterModels } from "./modelFilter";
|
||||
import type { ModelInfo } from "../api";
|
||||
|
||||
/**
|
||||
* Model filter utility tests
|
||||
*
|
||||
* Tests for filtering AI models by provider, ID, or name.
|
||||
*/
|
||||
|
||||
function createModel(
|
||||
provider: string,
|
||||
id: string,
|
||||
name: string,
|
||||
reasoning = false,
|
||||
contextWindow = 128000,
|
||||
): ModelInfo {
|
||||
return { provider, id, name, reasoning, contextWindow };
|
||||
}
|
||||
|
||||
describe("filterModels", () => {
|
||||
const models: ModelInfo[] = [
|
||||
createModel("anthropic", "claude-sonnet-4-5", "Claude Sonnet 4.5"),
|
||||
createModel("anthropic", "claude-opus-4", "Claude Opus 4", true),
|
||||
createModel("openai", "gpt-4o", "GPT-4o"),
|
||||
createModel("openai", "gpt-4o-mini", "GPT-4o Mini"),
|
||||
createModel("google", "gemini-pro", "Gemini Pro"),
|
||||
createModel("ollama", "llama3.1", "Llama 3.1"),
|
||||
];
|
||||
|
||||
it("returns all models when filter is empty string", () => {
|
||||
expect(filterModels(models, "")).toEqual(models);
|
||||
});
|
||||
|
||||
it("returns all models when filter is whitespace-only", () => {
|
||||
expect(filterModels(models, " ")).toEqual(models);
|
||||
expect(filterModels(models, " \t \n ")).toEqual(models);
|
||||
});
|
||||
|
||||
it("filters by provider (case-insensitive)", () => {
|
||||
const result = filterModels(models, "anthropic");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
|
||||
expect(result.map((m) => m.id)).toContain("claude-opus-4");
|
||||
});
|
||||
|
||||
it("filters by provider (uppercase)", () => {
|
||||
const result = filterModels(models, "ANTHROPIC");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by provider (mixed case)", () => {
|
||||
const result = filterModels(models, "OpenAI");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.id)).toContain("gpt-4o");
|
||||
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("filters by model ID (case-insensitive, matches exact ID)", () => {
|
||||
// Using unique ID "opus" that doesn't appear in other models
|
||||
const result = filterModels(models, "opus");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-opus-4");
|
||||
});
|
||||
|
||||
it("filters by partial model ID (substring matching)", () => {
|
||||
const result = filterModels(models, "claude");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.provider)).toContain("anthropic");
|
||||
});
|
||||
|
||||
it("filters by model name (case-insensitive)", () => {
|
||||
const result = filterModels(models, "sonnet");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("filters by model name (partial match)", () => {
|
||||
// "opus" appears in "Claude Opus 4" name
|
||||
const result = filterModels(models, "opus");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-opus-4");
|
||||
});
|
||||
|
||||
it("handles multi-word filters with AND logic", () => {
|
||||
// "anthropic" AND "sonnet" should match only Claude Sonnet
|
||||
const result = filterModels(models, "anthropic sonnet");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("handles multi-word filters with multiple matches", () => {
|
||||
// "gpt" should match both gpt-4o and gpt-4o-mini
|
||||
const result = filterModels(models, "gpt 4o");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles partial matches across multiple fields", () => {
|
||||
// "pro" matches "Gemini Pro" in name
|
||||
const result = filterModels(models, "pro");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("gemini-pro");
|
||||
});
|
||||
|
||||
it("returns empty array when no matches", () => {
|
||||
const result = filterModels(models, "nonexistent");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for non-matching multi-word filter", () => {
|
||||
// "anthropic" AND "nonexistent" should match nothing
|
||||
const result = filterModels(models, "anthropic nonexistent");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty model array", () => {
|
||||
expect(filterModels([], "")).toEqual([]);
|
||||
expect(filterModels([], "test")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles single model array", () => {
|
||||
const singleModel = [models[0]];
|
||||
expect(filterModels(singleModel, "")).toEqual(singleModel);
|
||||
expect(filterModels(singleModel, "anthropic")).toEqual(singleModel);
|
||||
expect(filterModels(singleModel, "openai")).toEqual([]);
|
||||
});
|
||||
|
||||
it("is case-insensitive across all fields", () => {
|
||||
// Mix of cases should all work
|
||||
expect(filterModels(models, "CLAUDE")).toHaveLength(2);
|
||||
expect(filterModels(models, "GPT-4O")).toHaveLength(2);
|
||||
expect(filterModels(models, "GEMINI")).toHaveLength(1);
|
||||
expect(filterModels(models, "OPUS")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches model ID with special characters", () => {
|
||||
const modelsWithSpecial = [
|
||||
createModel("anthropic", "claude-3.5-sonnet", "Claude 3.5 Sonnet"),
|
||||
createModel("openai", "gpt-4-turbo-preview", "GPT-4 Turbo"),
|
||||
];
|
||||
|
||||
expect(filterModels(modelsWithSpecial, "3.5")).toHaveLength(1);
|
||||
expect(filterModels(modelsWithSpecial, "turbo-preview")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles leading and trailing whitespace in filter", () => {
|
||||
const result = filterModels(models, " anthropic ");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles multiple spaces between terms", () => {
|
||||
const result = filterModels(models, "anthropic sonnet");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("matches substring anywhere in provider, id, or name", () => {
|
||||
// "ai" appears in "openai" provider
|
||||
const result = filterModels(models, "ai");
|
||||
expect(result.map((m) => m.provider)).toContain("openai");
|
||||
|
||||
// "ll" appears in "ollama" provider and "llama" id
|
||||
const resultLl = filterModels(models, "ll");
|
||||
expect(resultLl.map((m) => m.id)).toContain("llama3.1");
|
||||
});
|
||||
|
||||
// --- Fuzzy matching: separator-insensitive ---
|
||||
|
||||
describe("separator-insensitive matching", () => {
|
||||
it("matches when search omits hyphens from model ID", () => {
|
||||
// "gpt4o" should match "gpt-4o" (hyphen omitted)
|
||||
const result = filterModels(models, "gpt4o");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.id)).toContain("gpt-4o");
|
||||
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("matches when search omits dots from model ID", () => {
|
||||
const modelsWithDots = [
|
||||
createModel("ollama", "llama3.1", "Llama 3.1"),
|
||||
];
|
||||
// "llama31" should match "llama3.1" (dot omitted)
|
||||
expect(filterModels(modelsWithDots, "llama31")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches when search omits underscores", () => {
|
||||
const modelsWithUnderscores = [
|
||||
createModel("test", "my_model_v2", "My Model V2"),
|
||||
];
|
||||
expect(filterModels(modelsWithUnderscores, "mymodelv2")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches when search uses different separators than the model ID", () => {
|
||||
// Searching with hyphen where the ID uses dot should still match
|
||||
const result = filterModels(models, "gpt-4o");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Fuzzy matching: typo tolerance ---
|
||||
|
||||
describe("typo-tolerant matching", () => {
|
||||
it("matches with single character deletion (sonet → sonnet)", () => {
|
||||
const result = filterModels(models, "sonet");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("matches with single character insertion", () => {
|
||||
// "sonnnet" (extra n) should still match "sonnet"
|
||||
const result = filterModels(models, "sonnnet");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("matches with single character substitution", () => {
|
||||
// "gemeno" → one substitution from "gemini" is too far, but "gemini" is close
|
||||
// "gemini" with 'n' instead of 'i' at end → "geminj" should match
|
||||
// Actually let's use a clear case: "gemino" (o instead of i) matches "gemini"
|
||||
const result = filterModels(models, "gemino");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("gemini-pro");
|
||||
});
|
||||
|
||||
it("matches with adjacent transposition", () => {
|
||||
// "opneai" (transposed n and e) should match "openai"
|
||||
const result = filterModels(models, "opneai");
|
||||
expect(result).toHaveLength(2); // Both openai models
|
||||
});
|
||||
|
||||
it("does not apply typo tolerance to very short terms (≤ 3 chars)", () => {
|
||||
// "xai" should NOT match "openai" via typo tolerance (edit distance 1)
|
||||
// because the term is only 3 chars — fuzzy matching requires ≥ 4 chars
|
||||
const result = filterModels(models, "xai");
|
||||
// "xai" is not a substring, not a subsequence of any single token
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves multi-term AND logic with typo-tolerant terms", () => {
|
||||
// "anthropic sonet" → "anthropic" matches exactly, "sonet" fuzzy-matches "sonnet"
|
||||
const result = filterModels(models, "anthropic sonet");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("does not match when both terms are required but only one fuzzy-matches", () => {
|
||||
// "google sonet" → "google" matches, "sonet" doesn't match any google model
|
||||
const result = filterModels(models, "google sonet");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Fuzzy matching: subsequence (non-contiguous) ---
|
||||
|
||||
describe("subsequence matching", () => {
|
||||
it("matches non-contiguous characters (cld → claude)", () => {
|
||||
const result = filterModels(models, "cld");
|
||||
// "cld" is a subsequence of "claude" (token), should match all claude models
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
|
||||
expect(result.map((m) => m.id)).toContain("claude-opus-4");
|
||||
});
|
||||
|
||||
it("matches non-contiguous characters in model name", () => {
|
||||
// "gmi" is a subsequence of "gemini" (g-e-m-i-n-i → g(0), m(2), i(3))
|
||||
// It's also a subsequence of "gpt4omini" (g(0), m(5), i(6))
|
||||
const result = filterModels(models, "gmi");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.id)).toContain("gemini-pro");
|
||||
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("does not apply subsequence matching for very short terms (< 3 chars)", () => {
|
||||
// "op" is 2 chars, so subsequence matching does NOT apply (min 3).
|
||||
// However, "op" IS a substring: it appears in "anthropic" ("anthr**op**ic")
|
||||
// and in "openai" ("**op**enai"), so it matches all 4 models from those providers.
|
||||
const result = filterModels(models, "op");
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
|
||||
expect(result.map((m) => m.id)).toContain("claude-opus-4");
|
||||
expect(result.map((m) => m.id)).toContain("gpt-4o");
|
||||
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("requires all characters in order for subsequence", () => {
|
||||
// "dcl" is NOT a subsequence of "claude" (d before c, but "dcl" reversed)
|
||||
const result = filterModels(models, "dcl");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("subsequence only matches within individual tokens, not across fields", () => {
|
||||
// "ops" should NOT match by picking 'o' from one field and 'ps' from another
|
||||
// It should only match if it's a subsequence of a single token
|
||||
// "ops" as subsequence of "claudeopus4" → o at index 6, p at index 7, s at index 9 → TRUE
|
||||
// So it DOES match the opus model because it's a subsequence of the token "claudeopus4"
|
||||
const result = filterModels(models, "ops");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("claude-opus-4");
|
||||
});
|
||||
|
||||
it("subsequence does not match across space-separated tokens", () => {
|
||||
// "cpo" picking c from "claude", p from provider "anthropic", o from "4"
|
||||
// should NOT match because subsequence is checked per-token
|
||||
// "cpo" is NOT a subsequence of any single token
|
||||
const result = filterModels(models, "cpo");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Fuzzy matching: negative tests (no over-matching) ---
|
||||
|
||||
describe("negative fuzzy matching (no over-matching)", () => {
|
||||
it("returns empty array for clearly irrelevant input", () => {
|
||||
expect(filterModels(models, "xyz")).toEqual([]);
|
||||
expect(filterModels(models, "banana")).toEqual([]);
|
||||
expect(filterModels(models, "zzzzz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fuzzy-match unrelated providers", () => {
|
||||
// "googel" is close to "google" (edit distance 1) but NOT to "openai" or "anthropic"
|
||||
const result = filterModels(models, "googel");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].provider).toBe("google");
|
||||
});
|
||||
|
||||
it("does not fuzzy-match when edit distance exceeds tolerance", () => {
|
||||
// "gpt5o" has edit distance 2 from "gpt4o" (4→5 substitution + different letter)
|
||||
// Actually edit distance is 1 (just 4→5). Let's use a clear 2-distance case.
|
||||
// "gpt99" has edit distance ≥ 2 from "gpt4o" (two substitutions: 4→9, o→9)
|
||||
expect(filterModels(models, "gpt99")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fuzzy-match very different words", () => {
|
||||
// "elephant" should not match anything despite fuzzy matching
|
||||
expect(filterModels(models, "elephant")).toEqual([]);
|
||||
});
|
||||
|
||||
it("multi-term AND with one non-matching term returns empty", () => {
|
||||
// Even if "sonet" fuzzy-matches, adding "elephant" should return empty
|
||||
expect(filterModels(models, "sonet elephant")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Fuzzy matching: result ordering stability ---
|
||||
|
||||
describe("result ordering", () => {
|
||||
it("preserves input-array order (no fuzzy-score re-sorting)", () => {
|
||||
// All claude models should appear in their original array order
|
||||
const result = filterModels(models, "claude");
|
||||
expect(result.map((m) => m.id)).toEqual([
|
||||
"claude-sonnet-4-5",
|
||||
"claude-opus-4",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves input-array order with fuzzy matches", () => {
|
||||
const result = filterModels(models, "gpt4o");
|
||||
expect(result.map((m) => m.id)).toEqual([
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelPreset } from "@fusion/core";
|
||||
import {
|
||||
applyPresetToSelection,
|
||||
generatePresetId,
|
||||
generateUniquePresetId,
|
||||
getPresetByName,
|
||||
getRecommendedPresetForSize,
|
||||
validatePresetId,
|
||||
} from "./modelPresets";
|
||||
|
||||
const presets: ModelPreset[] = [
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
executorProvider: "openai",
|
||||
executorModelId: "gpt-4o-mini",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o-mini",
|
||||
},
|
||||
{
|
||||
id: "complex",
|
||||
name: "Complex",
|
||||
executorProvider: "anthropic",
|
||||
executorModelId: "claude-sonnet-4-5",
|
||||
},
|
||||
];
|
||||
|
||||
describe("modelPresets utils", () => {
|
||||
it("finds presets by case-insensitive display name", () => {
|
||||
expect(getPresetByName(presets, "budget")).toEqual(presets[0]);
|
||||
expect(getPresetByName(presets, " COMPLEX ")).toEqual(presets[1]);
|
||||
expect(getPresetByName(presets, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies a preset to dropdown selection values", () => {
|
||||
expect(applyPresetToSelection(presets[0])).toEqual({
|
||||
executorValue: "openai/gpt-4o-mini",
|
||||
validatorValue: "openai/gpt-4o-mini",
|
||||
});
|
||||
expect(applyPresetToSelection(undefined)).toEqual({
|
||||
executorValue: "",
|
||||
validatorValue: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("recommends the mapped preset for a task size", () => {
|
||||
expect(
|
||||
getRecommendedPresetForSize("S", { S: "budget", M: "complex" }, presets),
|
||||
).toEqual(presets[0]);
|
||||
expect(
|
||||
getRecommendedPresetForSize("L", { S: "budget", M: "complex" }, presets),
|
||||
).toBeUndefined();
|
||||
expect(getRecommendedPresetForSize(undefined, { S: "budget" }, presets)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates preset ids", () => {
|
||||
expect(validatePresetId("budget")).toBe(true);
|
||||
expect(validatePresetId("budget_v2")).toBe(true);
|
||||
expect(validatePresetId("budget-v2")).toBe(true);
|
||||
expect(validatePresetId("")).toBe(false);
|
||||
expect(validatePresetId("has spaces")).toBe(false);
|
||||
expect(validatePresetId("invalid!char")).toBe(false);
|
||||
expect(validatePresetId("a".repeat(33))).toBe(false);
|
||||
});
|
||||
|
||||
it("generates slug-friendly preset ids", () => {
|
||||
expect(generatePresetId("Budget")).toBe("budget");
|
||||
expect(generatePresetId(" Normal Mode ")).toBe("normal-mode");
|
||||
expect(generatePresetId("Complex / Reviewer")).toBe("complex-reviewer");
|
||||
expect(generatePresetId("!!!")).toBe("preset");
|
||||
expect(generatePresetId("a".repeat(40))).toBe("a".repeat(32));
|
||||
});
|
||||
|
||||
describe("generateUniquePresetId", () => {
|
||||
it("returns the base slug when no collision", () => {
|
||||
// "standard" is not in the presets fixture
|
||||
expect(generateUniquePresetId("Standard", presets)).toBe("standard");
|
||||
});
|
||||
|
||||
it("returns base slug when existing list is empty", () => {
|
||||
expect(generateUniquePresetId("Budget", [])).toBe("budget");
|
||||
});
|
||||
|
||||
it("appends suffix when base slug is already taken", () => {
|
||||
// "budget" is already used in presets, so should get "budget-1"
|
||||
expect(generateUniquePresetId("Budget", presets)).toBe("budget-1");
|
||||
// "complex" is also taken, so should get "complex-1"
|
||||
expect(generateUniquePresetId("Complex", presets)).toBe("complex-1");
|
||||
});
|
||||
|
||||
it("increments suffix until finding a free id", () => {
|
||||
const crowded: ModelPreset[] = [
|
||||
{ id: "budget", name: "Budget" },
|
||||
{ id: "budget-1", name: "Budget Copy" },
|
||||
{ id: "budget-2", name: "Budget Copy 2" },
|
||||
];
|
||||
expect(generateUniquePresetId("Budget", crowded)).toBe("budget-3");
|
||||
});
|
||||
|
||||
it("truncates base slug to leave room for suffix", () => {
|
||||
const longName = "a".repeat(40);
|
||||
const existing: ModelPreset[] = [
|
||||
{ id: generatePresetId(longName), name: longName },
|
||||
];
|
||||
const result = generateUniquePresetId(longName, existing);
|
||||
// baseId is 32 a's, collision → truncate to 28 a's + "-1" = 30 chars
|
||||
expect(result).toBe(`${"a".repeat(28)}-1`);
|
||||
expect(result.length).toBeLessThanOrEqual(32);
|
||||
expect(validatePresetId(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles fallback 'preset' slug collisions", () => {
|
||||
const existing: ModelPreset[] = [
|
||||
{ id: "preset", name: "!!!" },
|
||||
];
|
||||
expect(generateUniquePresetId("!!!", existing)).toBe("preset-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isProjectRoutedToNode,
|
||||
getProjectsForNode,
|
||||
getProjectCountForNode,
|
||||
getUnassignedProjectCount,
|
||||
} from "./nodeProjectAssignment";
|
||||
import type { NodeInfo, ProjectInfo } from "../api";
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
id: "node-1",
|
||||
name: "Test Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
return {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
path: "/workspace/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("nodeProjectAssignment", () => {
|
||||
describe("isProjectRoutedToNode", () => {
|
||||
describe("local node", () => {
|
||||
const localNode = makeNode({ id: "local-1", type: "local" });
|
||||
|
||||
it("returns true for projects explicitly assigned to this local node", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: "local-1" });
|
||||
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for unassigned projects (nodeId undefined)", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: undefined });
|
||||
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for unassigned projects (nodeId null)", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
|
||||
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for projects assigned to other nodes", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: "other-node" });
|
||||
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for projects assigned to remote nodes", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
|
||||
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote node", () => {
|
||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
||||
|
||||
it("returns true for projects explicitly assigned to this remote node", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
|
||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unassigned projects (nodeId undefined)", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: undefined });
|
||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unassigned projects (nodeId null)", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
|
||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for projects assigned to local nodes", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: "local-1" });
|
||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for projects assigned to other remote nodes", () => {
|
||||
const project = makeProject({ id: "proj-1", nodeId: "other-remote" });
|
||||
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectsForNode", () => {
|
||||
it("returns all projects routed to a local node (including unassigned)", () => {
|
||||
const localNode = makeNode({ id: "local-1", type: "local" });
|
||||
const projects: ProjectInfo[] = [
|
||||
makeProject({ id: "proj-1", nodeId: "local-1" }), // assigned to this local node
|
||||
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned
|
||||
makeProject({ id: "proj-3", nodeId: "other-local" }), // assigned to different local node
|
||||
makeProject({ id: "proj-4", nodeId: "remote-1" }), // assigned to remote
|
||||
];
|
||||
|
||||
const result = getProjectsForNode(projects, localNode);
|
||||
expect(result.map((p) => p.id)).toEqual(["proj-1", "proj-2"]);
|
||||
});
|
||||
|
||||
it("returns only explicitly assigned projects for a remote node", () => {
|
||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
||||
const projects: ProjectInfo[] = [
|
||||
makeProject({ id: "proj-1", nodeId: "remote-1" }), // assigned to this remote node
|
||||
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned
|
||||
makeProject({ id: "proj-3", nodeId: "local-1" }), // assigned to local
|
||||
makeProject({ id: "proj-4", nodeId: "other-remote" }), // assigned to other remote
|
||||
];
|
||||
|
||||
const result = getProjectsForNode(projects, remoteNode);
|
||||
expect(result.map((p) => p.id)).toEqual(["proj-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectCountForNode", () => {
|
||||
it("returns correct count for local node (includes unassigned)", () => {
|
||||
const localNode = makeNode({ id: "local-1", type: "local" });
|
||||
const projects: ProjectInfo[] = [
|
||||
makeProject({ id: "proj-1", nodeId: "local-1" }),
|
||||
makeProject({ id: "proj-2", nodeId: undefined }),
|
||||
makeProject({ id: "proj-3", nodeId: undefined }),
|
||||
];
|
||||
|
||||
expect(getProjectCountForNode(projects, localNode)).toBe(3);
|
||||
});
|
||||
|
||||
it("returns correct count for remote node (explicit only)", () => {
|
||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
||||
const projects: ProjectInfo[] = [
|
||||
makeProject({ id: "proj-1", nodeId: "remote-1" }),
|
||||
makeProject({ id: "proj-2", nodeId: "remote-1" }),
|
||||
makeProject({ id: "proj-3", nodeId: undefined }),
|
||||
];
|
||||
|
||||
expect(getProjectCountForNode(projects, remoteNode)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 when no projects are routed to the node", () => {
|
||||
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
|
||||
const projects: ProjectInfo[] = [
|
||||
makeProject({ id: "proj-1", nodeId: "local-1" }),
|
||||
makeProject({ id: "proj-2", nodeId: undefined }),
|
||||
];
|
||||
|
||||
expect(getProjectCountForNode(projects, remoteNode)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUnassignedProjectCount", () => {
|
||||
it("counts projects without nodeId", () => {
|
||||
const projects: ProjectInfo[] = [
|
||||
makeProject({ id: "proj-1", nodeId: undefined }),
|
||||
makeProject({ id: "proj-2", nodeId: null as unknown as string }),
|
||||
makeProject({ id: "proj-3", nodeId: "local-1" }),
|
||||
];
|
||||
|
||||
expect(getUnassignedProjectCount(projects)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 when all projects are assigned", () => {
|
||||
const projects: ProjectInfo[] = [
|
||||
makeProject({ id: "proj-1", nodeId: "local-1" }),
|
||||
makeProject({ id: "proj-2", nodeId: "remote-1" }),
|
||||
];
|
||||
|
||||
expect(getUnassignedProjectCount(projects)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for empty array", () => {
|
||||
expect(getUnassignedProjectCount([])).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
GLOBAL_STORAGE_KEYS,
|
||||
PROJECT_STORAGE_KEYS,
|
||||
getScopedItem,
|
||||
removeScopedItem,
|
||||
scopedKey,
|
||||
setScopedItem,
|
||||
} from "./projectStorage";
|
||||
|
||||
describe("projectStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("scopedKey", () => {
|
||||
it("returns scoped key when projectId is provided", () => {
|
||||
expect(scopedKey("kb-dashboard-list-columns", "proj-abc")).toBe(
|
||||
"kb:proj-abc:kb-dashboard-list-columns",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns base key unchanged when projectId is undefined", () => {
|
||||
expect(scopedKey("kb-dashboard-list-columns", undefined)).toBe("kb-dashboard-list-columns");
|
||||
});
|
||||
|
||||
it("returns base key unchanged when projectId is omitted", () => {
|
||||
expect(scopedKey("kb-dashboard-list-columns")).toBe("kb-dashboard-list-columns");
|
||||
});
|
||||
|
||||
it("returns base key unchanged when projectId is empty", () => {
|
||||
expect(scopedKey("kb-dashboard-list-columns", "")).toBe("kb-dashboard-list-columns");
|
||||
});
|
||||
|
||||
it("returns base key unchanged when projectId is null", () => {
|
||||
expect(scopedKey("kb-dashboard-list-columns", null as any)).toBe("kb-dashboard-list-columns");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses scoped keys for get/set/remove with projectId", () => {
|
||||
setScopedItem("kb-dashboard-list-columns", "value", "proj-abc");
|
||||
|
||||
expect(localStorage.getItem("kb:proj-abc:kb-dashboard-list-columns")).toBe("value");
|
||||
expect(getScopedItem("kb-dashboard-list-columns", "proj-abc")).toBe("value");
|
||||
|
||||
removeScopedItem("kb-dashboard-list-columns", "proj-abc");
|
||||
expect(localStorage.getItem("kb:proj-abc:kb-dashboard-list-columns")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses unscoped keys for get/set/remove without projectId", () => {
|
||||
setScopedItem("kb-dashboard-list-columns", "value");
|
||||
|
||||
expect(localStorage.getItem("kb-dashboard-list-columns")).toBe("value");
|
||||
expect(getScopedItem("kb-dashboard-list-columns")).toBe("value");
|
||||
|
||||
removeScopedItem("kb-dashboard-list-columns");
|
||||
expect(localStorage.getItem("kb-dashboard-list-columns")).toBeNull();
|
||||
});
|
||||
|
||||
it("includes all global storage keys", () => {
|
||||
expect(GLOBAL_STORAGE_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
"kb-dashboard-theme-mode",
|
||||
"kb-dashboard-color-theme",
|
||||
"kb-dashboard-view-mode",
|
||||
"kb-dashboard-current-project",
|
||||
"kb-dashboard-recent-projects",
|
||||
]),
|
||||
);
|
||||
expect(GLOBAL_STORAGE_KEYS).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("includes all project-scoped storage keys", () => {
|
||||
expect(PROJECT_STORAGE_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
"kb-dashboard-task-view",
|
||||
"kb-dashboard-list-columns",
|
||||
"kb-dashboard-hide-done",
|
||||
"kb-dashboard-list-collapsed",
|
||||
"kb-dashboard-selected-tasks",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
"fn-agent-tree-expanded",
|
||||
"kb-terminal-tabs",
|
||||
"kb-planning-last-description",
|
||||
"kb-subtask-last-description",
|
||||
"kb-mission-last-goal",
|
||||
"kb-usage-view-mode",
|
||||
"kb-chat-active-session",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("has no overlap between global and project-scoped keys", () => {
|
||||
const globalSet = new Set(GLOBAL_STORAGE_KEYS);
|
||||
const overlap = PROJECT_STORAGE_KEYS.filter((key) => globalSet.has(key));
|
||||
|
||||
expect(overlap).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isTaskStuck, countStuckTasks } from "./taskStuck";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const createTask = (overrides: Partial<Task> = {}): Task =>
|
||||
({
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Task;
|
||||
|
||||
describe("isTaskStuck", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns false when timeout is undefined (disabled)", () => {
|
||||
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when timeout is 0", () => {
|
||||
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when timeout is negative", () => {
|
||||
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, -1)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-in-progress tasks", () => {
|
||||
const task = createTask({ column: "todo", updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for failed in-progress tasks", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ status: "failed", updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for stuck-killed in-progress tasks", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ status: "stuck-killed", updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for recent in-progress tasks within timeout", () => {
|
||||
const recent = new Date(Date.now() - 300000).toISOString(); // 5 minutes ago
|
||||
const task = createTask({ updatedAt: recent });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false); // 10 minute timeout
|
||||
});
|
||||
|
||||
it("returns true for stale in-progress tasks exceeding timeout", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString(); // just over 10 minutes
|
||||
const task = createTask({ updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for malformed updatedAt", () => {
|
||||
const task = createTask({ updatedAt: "not-a-date" });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty updatedAt", () => {
|
||||
const task = createTask({ updatedAt: "" });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles tasks in triage column", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ column: "triage", updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles tasks in done column", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ column: "done", updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true exactly at timeout boundary (greater than)", () => {
|
||||
const boundary = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ updatedAt: boundary });
|
||||
expect(isTaskStuck(task, 600000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false exactly at timeout boundary (equal)", () => {
|
||||
const boundary = new Date(Date.now() - 600000).toISOString();
|
||||
const task = createTask({ updatedAt: boundary });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
|
||||
it("uses dataAsOfMs instead of Date.now() when provided", () => {
|
||||
// Task updatedAt is 11 minutes ago
|
||||
const taskUpdatedAt = new Date(Date.now() - 11 * 60 * 1000).toISOString();
|
||||
const task = createTask({ updatedAt: taskUpdatedAt });
|
||||
|
||||
// dataAsOfMs is 5 minutes ago (task was fresh 5 minutes ago)
|
||||
const dataAsOfMs = Date.now() - 5 * 60 * 1000;
|
||||
|
||||
// 10 minute timeout
|
||||
// With dataAsOfMs: 5 min - 11 min = -6 min < 10 min → NOT stuck
|
||||
// Without dataAsOfMs: 0 min - 11 min = -11 min > 10 min → stuck
|
||||
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to Date.now() when dataAsOfMs is undefined", () => {
|
||||
// Task updatedAt is 5 minutes ago
|
||||
const taskUpdatedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const task = createTask({ updatedAt: taskUpdatedAt });
|
||||
|
||||
// Without dataAsOfMs, should use Date.now() → NOT stuck (within 10 min timeout)
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly identifies a task that would be stuck with Date.now() but not with dataAsOfMs", () => {
|
||||
// Scenario: Tab was in background for 20 minutes
|
||||
// Task was updated 10 minutes ago (relative to dataAsOfMs)
|
||||
// dataAsOfMs represents "10 minutes ago" (when we fetched fresh data)
|
||||
// Date.now() is "now" (20 minutes after the fetch)
|
||||
//
|
||||
// This simulates the background tab scenario:
|
||||
// - User opened tab at T=0, fetched tasks
|
||||
// - Tab went to background at T=0
|
||||
// - User came back at T=20
|
||||
// - dataAsOfMs = T=0 (when we last had fresh data)
|
||||
// - Task was updated at T=-10 (10 minutes before fetch)
|
||||
// - task.updatedAt represents T=-10
|
||||
//
|
||||
// Check: dataAsOfMs - updatedAt = 0 - (-10) = 10 min < 10 min timeout → NOT stuck
|
||||
// Without dataAsOfMs: Date.now() - updatedAt = 20 - (-10) = 30 min > 10 min → STUCK (false positive!)
|
||||
|
||||
// In fake timers, we set Date.now() to a fixed point
|
||||
// Let's say Date.now() = 1000 (representing "now")
|
||||
// dataAsOfMs = 0 (representing 20 minutes before "now" in fake time)
|
||||
// task.updatedAt = -600 (representing 10 minutes before dataAsOfMs)
|
||||
|
||||
vi.setSystemTime(new Date(1000)); // Date.now() = 1000
|
||||
const dataAsOfMs = 0; // 20 minutes before Date.now() in this scenario
|
||||
const taskUpdatedAt = new Date(-600000).toISOString(); // 10 minutes before dataAsOfMs
|
||||
const task = createTask({ updatedAt: taskUpdatedAt });
|
||||
|
||||
// With dataAsOfMs: 0 - (-600000) = 600000ms = 10 min = timeout → NOT stuck (boundary)
|
||||
// Without dataAsOfMs: 1000 - (-600000) = 601000ms > 10 min → STUCK
|
||||
// The key test: with dataAsOfMs it should NOT be stuck even though Date.now() would say it is
|
||||
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
|
||||
});
|
||||
|
||||
it("prevents false positive when tab was in background", () => {
|
||||
// Simulate: Tab in background, data fetched 15 min ago
|
||||
// Task.updatedAt is 12 min ago (stale from server perspective)
|
||||
// taskStuckTimeoutMs = 10 min
|
||||
// With fresh data (15 min ago): 15 - 12 = 3 min < 10 min → NOT stuck
|
||||
// With stale Date.now(): 0 - 12 = 12 min > 10 min → STUCK (FALSE POSITIVE)
|
||||
|
||||
vi.setSystemTime(new Date(0)); // Date.now() = 0
|
||||
const dataAsOfMs = -900000; // 15 minutes ago (in fake time)
|
||||
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago (in fake time)
|
||||
const task = createTask({ updatedAt: taskUpdatedAt });
|
||||
|
||||
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
|
||||
// Without dataAsOfMs: 0 - (-720000) = 720000ms = 12 min > 10 min → STUCK
|
||||
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly identifies genuinely stuck tasks even with dataAsOfMs", () => {
|
||||
// Task really is stuck: updatedAt is 15 min ago, timeout is 10 min
|
||||
// With dataAsOfMs of 2 min ago: 2 - 15 = -13 min < 10 min → NOT stuck (hmm, this is a problem)
|
||||
|
||||
// Actually, dataAsOfMs should represent when we last got FRESH data from the server
|
||||
// If dataAsOfMs = 2 min ago and task.updatedAt = 15 min ago, the task was stale
|
||||
// even when we fetched it, because 2 - 15 = -13 min > 10 min timeout
|
||||
|
||||
vi.setSystemTime(new Date(0));
|
||||
const dataAsOfMs = -120000; // 2 minutes ago
|
||||
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
|
||||
const task = createTask({ updatedAt: taskUpdatedAt });
|
||||
|
||||
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
|
||||
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("countStuckTasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns 0 when timeout is undefined", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const tasks = [createTask({ updatedAt: stale })];
|
||||
expect(countStuckTasks(tasks, undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when timeout is 0", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const tasks = [createTask({ updatedAt: stale })];
|
||||
expect(countStuckTasks(tasks, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts only stuck tasks", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const recent = new Date(Date.now() - 300000).toISOString();
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", updatedAt: stale }), // stuck
|
||||
createTask({ id: "FN-002", updatedAt: recent }), // not stuck
|
||||
createTask({ id: "FN-004", status: "failed", updatedAt: stale }), // terminal status
|
||||
createTask({ id: "FN-003", column: "todo", updatedAt: stale }), // not in-progress
|
||||
];
|
||||
expect(countStuckTasks(tasks, 600000)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for empty task list", () => {
|
||||
expect(countStuckTasks([], 600000)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts multiple stuck tasks", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", updatedAt: stale }),
|
||||
createTask({ id: "FN-002", updatedAt: stale }),
|
||||
];
|
||||
expect(countStuckTasks(tasks, 600000)).toBe(2);
|
||||
});
|
||||
|
||||
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
|
||||
it("passes dataAsOfMs through to isTaskStuck", () => {
|
||||
// Task would be stuck with Date.now() but not with dataAsOfMs
|
||||
vi.setSystemTime(new Date(0));
|
||||
const dataAsOfMs = -900000; // 15 minutes ago
|
||||
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago
|
||||
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
|
||||
|
||||
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
|
||||
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts tasks that are genuinely stuck even with dataAsOfMs", () => {
|
||||
vi.setSystemTime(new Date(0));
|
||||
const dataAsOfMs = -120000; // 2 minutes ago
|
||||
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
|
||||
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
|
||||
|
||||
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
|
||||
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { truncateMiddle } from "./truncatePath";
|
||||
|
||||
describe("truncateMiddle", () => {
|
||||
it("returns empty string unchanged", () => {
|
||||
expect(truncateMiddle("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns short paths unchanged", () => {
|
||||
expect(truncateMiddle("src/index.ts")).toBe("src/index.ts");
|
||||
});
|
||||
|
||||
it("returns paths at exactly maxLength unchanged", () => {
|
||||
const path = "a".repeat(60);
|
||||
expect(truncateMiddle(path, 60)).toBe(path);
|
||||
});
|
||||
|
||||
it("returns paths shorter than maxLength unchanged", () => {
|
||||
const path = "a".repeat(59);
|
||||
expect(truncateMiddle(path, 60)).toBe(path);
|
||||
});
|
||||
|
||||
it("truncates a long path from the middle", () => {
|
||||
const path = "packages/dashboard/app/components/TaskChangesTab.tsx";
|
||||
const result = truncateMiddle(path, 30);
|
||||
expect(result).toContain("...");
|
||||
expect(result.length).toBeLessThanOrEqual(30);
|
||||
// Filename should be preserved
|
||||
expect(result.endsWith("TaskChangesTab.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the full path when under maxLength", () => {
|
||||
const path = "src/components/Button.tsx";
|
||||
expect(truncateMiddle(path, 60)).toBe(path);
|
||||
});
|
||||
|
||||
it("truncates paths with no separator from the end", () => {
|
||||
const path = "verylongfilenamewithoutseparators.txt";
|
||||
const result = truncateMiddle(path, 20);
|
||||
expect(result).toContain("...");
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("handles maxLength of 4 (minimum for ellipsis + 1 char)", () => {
|
||||
const path = "src/components/deeply/nested/file.ts";
|
||||
const result = truncateMiddle(path, 4);
|
||||
expect(result.length).toBeLessThanOrEqual(4);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("handles maxLength smaller than 4 gracefully", () => {
|
||||
const path = "src/components/file.ts";
|
||||
const result = truncateMiddle(path, 3);
|
||||
expect(result.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("uses default maxLength of 60", () => {
|
||||
// 61 chars — should truncate
|
||||
const path = "packages/dashboard/app/components/VeryLongComponentNameGoesHere.tsx";
|
||||
// path is 73 chars
|
||||
const result = truncateMiddle(path);
|
||||
expect(result.length).toBeLessThanOrEqual(60);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("preserves filename when path is deeply nested", () => {
|
||||
const path = "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/file.ts";
|
||||
const result = truncateMiddle(path, 25);
|
||||
expect(result.endsWith("file.ts")).toBe(true);
|
||||
expect(result).toContain("...");
|
||||
expect(result.length).toBeLessThanOrEqual(25);
|
||||
});
|
||||
|
||||
it("handles single-segment paths", () => {
|
||||
const result = truncateMiddle("verylongfilename.tsx", 15);
|
||||
expect(result.length).toBeLessThanOrEqual(15);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("handles a path where the filename itself is longer than maxLength", () => {
|
||||
const path = "ExtremelyLongFileNameThatExceedsTheMaximumLength.tsx";
|
||||
const result = truncateMiddle(path, 20);
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("preserves start portion when truncating", () => {
|
||||
const path = "packages/dashboard/app/components/TaskChangesTab.tsx";
|
||||
const result = truncateMiddle(path, 35);
|
||||
expect(result.startsWith("packages")).toBe(true);
|
||||
expect(result).toContain("...");
|
||||
expect(result.endsWith("TaskChangesTab.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
it("works with paths that have dots but no slashes", () => {
|
||||
const result = truncateMiddle("config.local.development.json", 20);
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("handles exactly the boundary case where path is maxLength+1", () => {
|
||||
const path = "a".repeat(61);
|
||||
const result = truncateMiddle(path, 60);
|
||||
expect(result.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { groupByWorktree, getWorktreeLabel } from "./worktreeGrouping";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
function makeTask(overrides: Partial<Task> & { id: string }): Task {
|
||||
return {
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getWorktreeLabel", () => {
|
||||
it("extracts last path segment", () => {
|
||||
expect(getWorktreeLabel(".worktrees/FN-001")).toBe("FN-001");
|
||||
expect(getWorktreeLabel("/path/to/kb/kb-001")).toBe("kb-001");
|
||||
});
|
||||
|
||||
it("extracts humanized worktree names", () => {
|
||||
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
|
||||
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
|
||||
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByWorktree", () => {
|
||||
it("groups active in-progress tasks by worktree", () => {
|
||||
const t1 = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
||||
const t2 = makeTask({ id: "FN-002", worktree: ".worktrees/quiet-robin" });
|
||||
|
||||
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].label).toBe("swift-falcon");
|
||||
expect(groups[0].activeTasks).toEqual([t1]);
|
||||
expect(groups[1].label).toBe("quiet-robin");
|
||||
expect(groups[1].activeTasks).toEqual([t2]);
|
||||
});
|
||||
|
||||
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
|
||||
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
||||
const queued = makeTask({
|
||||
id: "FN-002",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
const groups = groupByWorktree([active], [active, queued], 2);
|
||||
|
||||
// Worktree group should have no queued tasks
|
||||
const worktreeGroup = groups.find((g) => g.label === "swift-falcon");
|
||||
expect(worktreeGroup).toBeDefined();
|
||||
expect(worktreeGroup!.queuedTasks).toEqual([]);
|
||||
|
||||
// Up Next should contain the queued task
|
||||
const upNext = groups.find((g) => g.label === "Up Next");
|
||||
expect(upNext).toBeDefined();
|
||||
expect(upNext!.queuedTasks).toEqual([queued]);
|
||||
expect(upNext!.activeTasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not create Up Next group when there are no eligible queued tasks", () => {
|
||||
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
||||
|
||||
const groups = groupByWorktree([active], [active], 2);
|
||||
|
||||
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
|
||||
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
||||
const blocked = makeTask({
|
||||
id: "FN-002",
|
||||
column: "todo",
|
||||
dependencies: ["FN-003"], // KB-003 doesn't exist or isn't done
|
||||
});
|
||||
|
||||
const groups = groupByWorktree([active], [active, blocked], 2);
|
||||
|
||||
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects maxConcurrent limit on queued tasks shown", () => {
|
||||
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
||||
const q1 = makeTask({ id: "FN-010", column: "todo" });
|
||||
const q2 = makeTask({ id: "FN-011", column: "todo" });
|
||||
const q3 = makeTask({ id: "FN-012", column: "todo" });
|
||||
|
||||
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
|
||||
|
||||
const upNext = groups.find((g) => g.label === "Up Next");
|
||||
expect(upNext).toBeDefined();
|
||||
expect(upNext!.queuedTasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("places unassigned in-progress tasks in Unassigned group", () => {
|
||||
const unassigned = makeTask({ id: "FN-001" }); // no worktree
|
||||
|
||||
const groups = groupByWorktree([unassigned], [unassigned], 2);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].label).toBe("Unassigned");
|
||||
expect(groups[0].activeTasks).toEqual([unassigned]);
|
||||
});
|
||||
|
||||
it("excludes paused todo tasks from Up Next", () => {
|
||||
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
||||
const paused = makeTask({
|
||||
id: "FN-002",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
paused: true,
|
||||
});
|
||||
const normal = makeTask({
|
||||
id: "FN-003",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
const groups = groupByWorktree([active], [active, paused, normal], 2);
|
||||
|
||||
const upNext = groups.find((g) => g.label === "Up Next");
|
||||
expect(upNext).toBeDefined();
|
||||
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["FN-003"]);
|
||||
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("FN-002");
|
||||
});
|
||||
|
||||
it("queued tasks with satisfied deps appear in Up Next", () => {
|
||||
const done = makeTask({ id: "FN-001", column: "done" });
|
||||
const queued = makeTask({
|
||||
id: "FN-002",
|
||||
column: "todo",
|
||||
dependencies: ["FN-001"],
|
||||
});
|
||||
|
||||
const groups = groupByWorktree([], [done, queued], 2);
|
||||
|
||||
const upNext = groups.find((g) => g.label === "Up Next");
|
||||
expect(upNext).toBeDefined();
|
||||
expect(upNext!.queuedTasks).toEqual([queued]);
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
__runAgentGenerationCleanupForTests,
|
||||
RateLimitError,
|
||||
SessionNotFoundError,
|
||||
} from "./agent-generation.js";
|
||||
} from "../agent-generation.js";
|
||||
import {
|
||||
setDiagnosticsSink,
|
||||
resetDiagnosticsSink,
|
||||
type DiagnosticsContext,
|
||||
type DiagnosticsLevel,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
} from "../ai-session-diagnostics.js";
|
||||
|
||||
// Counter for unique IPs per test
|
||||
let ipCounter = 0;
|
||||
@@ -122,7 +122,7 @@ describe("agent-generation module", () => {
|
||||
vi.resetModules();
|
||||
|
||||
const diagnostics: CapturedDiagnostic[] = [];
|
||||
const diagnosticsModule = await import("./ai-session-diagnostics.js");
|
||||
const diagnosticsModule = await import("../ai-session-diagnostics.js");
|
||||
diagnosticsModule.setDiagnosticsSink((level, scope, message, context) => {
|
||||
diagnostics.push({ level, scope, message, context });
|
||||
});
|
||||
@@ -155,7 +155,7 @@ describe("agent-generation module", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const agentGenerationModule = await import("./agent-generation.js");
|
||||
const agentGenerationModule = await import("../agent-generation.js");
|
||||
const session = await agentGenerationModule.startAgentGeneration(getUniqueIp(), "Role requiring generation");
|
||||
const spec = await agentGenerationModule.generateAgentSpec(session.id, "/tmp");
|
||||
|
||||
@@ -194,12 +194,12 @@ describe("agent-generation module", () => {
|
||||
|
||||
const diagnostics: CapturedDiagnostic[] = [];
|
||||
|
||||
const diagnosticsModule = await import("./ai-session-diagnostics.js");
|
||||
const diagnosticsModule = await import("../ai-session-diagnostics.js");
|
||||
diagnosticsModule.setDiagnosticsSink((level, scope, message, context) => {
|
||||
diagnostics.push({ level, scope, message, context });
|
||||
});
|
||||
|
||||
const agentGenerationModule = await import("./agent-generation.js");
|
||||
const agentGenerationModule = await import("../agent-generation.js");
|
||||
const session = await agentGenerationModule.startAgentGeneration(getUniqueIp(), "Role requiring AI spec");
|
||||
|
||||
await expect(agentGenerationModule.generateAgentSpec(session.id, "/tmp")).rejects.toThrow(generationFailure);
|
||||
@@ -522,7 +522,7 @@ describe("agent-generation module", () => {
|
||||
});
|
||||
|
||||
it("generates spec with default AGENT_GENERATION_SYSTEM_PROMPT when no overrides provided", async () => {
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("./agent-generation.js");
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("../agent-generation.js");
|
||||
|
||||
const session = await startGen(getUniqueIp(), "Test role");
|
||||
const spec = await genSpec(session.id, "/tmp");
|
||||
@@ -534,7 +534,7 @@ describe("agent-generation module", () => {
|
||||
});
|
||||
|
||||
it("generates spec with override system prompt when overrides provided", async () => {
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen } = await import("./agent-generation.js");
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen } = await import("../agent-generation.js");
|
||||
|
||||
const customPrompt = "CUSTOM AGENT GENERATION PROMPT";
|
||||
const overrides = { "agent-generation-system": customPrompt };
|
||||
@@ -549,7 +549,7 @@ describe("agent-generation module", () => {
|
||||
});
|
||||
|
||||
it("falls back to AGENT_GENERATION_SYSTEM_PROMPT constant when override key not recognized", async () => {
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("./agent-generation.js");
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("../agent-generation.js");
|
||||
|
||||
// Provide an override with a non-existent key
|
||||
const overrides = { "non-existent-key": "Some prompt" };
|
||||
@@ -563,7 +563,7 @@ describe("agent-generation module", () => {
|
||||
});
|
||||
|
||||
it("falls back to AGENT_GENERATION_SYSTEM_PROMPT constant when resolvePrompt returns empty", async () => {
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("./agent-generation.js");
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("../agent-generation.js");
|
||||
|
||||
// Empty overrides should still get the default constant
|
||||
const overrides = { "agent-generation-system": "" };
|
||||
@@ -576,7 +576,7 @@ describe("agent-generation module", () => {
|
||||
});
|
||||
|
||||
it("uses EXACT override when override is a non-empty string", async () => {
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen } = await import("./agent-generation.js");
|
||||
const { generateAgentSpec: genSpec, startAgentGeneration: startGen } = await import("../agent-generation.js");
|
||||
|
||||
// Exact override string
|
||||
const customPrompt = "EXACT CUSTOM PROMPT TEXT";
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_REQUESTS_PER_HOUR,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
} from "./ai-refine.js";
|
||||
} from "../ai-refine.js";
|
||||
|
||||
// Hoisted mock factory
|
||||
const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
getDiagnosticsSink,
|
||||
nonfatal,
|
||||
nonfatalAsync,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
import type { DiagnosticsSink, LogEntry, DiagnosticsLevel } from "./ai-session-diagnostics.js";
|
||||
} from "../ai-session-diagnostics.js";
|
||||
import type { DiagnosticsSink, LogEntry, DiagnosticsLevel } from "../ai-session-diagnostics.js";
|
||||
|
||||
describe("ai-session-diagnostics", () => {
|
||||
// Track captured log entries in memory
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
rateLimited,
|
||||
sendErrorResponse,
|
||||
unauthorized,
|
||||
} from "./api-error.js";
|
||||
import { resetRuntimeLogSink, setRuntimeLogSink } from "./runtime-logger.js";
|
||||
} from "../api-error.js";
|
||||
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
|
||||
|
||||
const runtimeLogEvents: Array<{
|
||||
level: string;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { probeClaudeCli } from "./claude-cli-probe.js";
|
||||
import { probeClaudeCli } from "../claude-cli-probe.js";
|
||||
|
||||
/**
|
||||
* These tests exercise the real probe — we deliberately do NOT mock
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { GitHubClient, CreatePrParams, PrComment, isPrMergeReady } from "./github.js";
|
||||
import { GitHubClient, CreatePrParams, PrComment, isPrMergeReady } from "../github.js";
|
||||
|
||||
// Mock the gh-cli module from @fusion/core
|
||||
vi.mock("@fusion/core", async () => {
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
SLICE_INTERVIEW_SYSTEM_PROMPT,
|
||||
type MilestoneInterviewSummary,
|
||||
type SliceInterviewSummary,
|
||||
} from "./milestone-slice-interview.js";
|
||||
} from "../milestone-slice-interview.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
import type { AiSessionRow } from "../ai-session-store.js";
|
||||
|
||||
function createQuestionJson(id = "q-1"): string {
|
||||
return JSON.stringify({
|
||||
@@ -218,7 +218,7 @@ describe("milestone-slice-interview module", () => {
|
||||
vi.clearAllMocks();
|
||||
__resetMilestoneSliceInterviewState();
|
||||
// Reset cached createFnAgent to force re-import with mock
|
||||
const mod = await import("./milestone-slice-interview.js") as any;
|
||||
const mod = await import("../milestone-slice-interview.js") as any;
|
||||
mod.__resetEngine?.();
|
||||
mockCreateFnAgent.mockImplementation(async () => createMockAgent([createQuestionJson()]));
|
||||
});
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { createMissionRouter } from "./mission-routes.js";
|
||||
import { request, get } from "./test-request.js";
|
||||
import { createMissionRouter } from "../mission-routes.js";
|
||||
import { request, get } from "../test-request.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type {
|
||||
Mission,
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
MissionValidatorRun,
|
||||
MissionAssertionFailureRecord,
|
||||
} from "@fusion/core";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
import type { AiSessionRow } from "../ai-session-store.js";
|
||||
import {
|
||||
__resetMissionInterviewState,
|
||||
createMissionInterviewSession,
|
||||
@@ -33,10 +33,10 @@ import {
|
||||
setAiSessionStore,
|
||||
getMissionInterviewSession,
|
||||
submitMissionInterviewResponse,
|
||||
} from "./mission-interview.js";
|
||||
import * as missionInterviewModule from "./mission-interview.js";
|
||||
import * as milestoneSliceInterviewModule from "./milestone-slice-interview.js";
|
||||
import * as projectStoreResolver from "./project-store-resolver.js";
|
||||
} from "../mission-interview.js";
|
||||
import * as missionInterviewModule from "../mission-interview.js";
|
||||
import * as milestoneSliceInterviewModule from "../milestone-slice-interview.js";
|
||||
import * as projectStoreResolver from "../project-store-resolver.js";
|
||||
|
||||
// Mock MissionStore factory
|
||||
function createMockMissionStore() {
|
||||
@@ -3966,7 +3966,7 @@ describe("Mission API", () => {
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
|
||||
const createSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"createTargetInterviewSession"
|
||||
).mockResolvedValueOnce("session-123");
|
||||
|
||||
@@ -4011,7 +4011,7 @@ describe("Mission API", () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const submitSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"submitTargetInterviewResponse"
|
||||
).mockResolvedValueOnce({
|
||||
type: "question",
|
||||
@@ -4051,7 +4051,7 @@ describe("Mission API", () => {
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
|
||||
const applySpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"applyTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...milestone,
|
||||
@@ -4081,7 +4081,7 @@ describe("Mission API", () => {
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
|
||||
const skipSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"skipTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...milestone,
|
||||
@@ -4114,7 +4114,7 @@ describe("Mission API", () => {
|
||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||
|
||||
const createSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"createTargetInterviewSession"
|
||||
).mockResolvedValueOnce("session-456");
|
||||
|
||||
@@ -4147,7 +4147,7 @@ describe("Mission API", () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const submitSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"submitTargetInterviewResponse"
|
||||
).mockResolvedValueOnce({
|
||||
type: "complete",
|
||||
@@ -4180,7 +4180,7 @@ describe("Mission API", () => {
|
||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||
|
||||
const applySpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"applyTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...slice,
|
||||
@@ -4210,7 +4210,7 @@ describe("Mission API", () => {
|
||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||
|
||||
const skipSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
await import("../milestone-slice-interview.js"),
|
||||
"skipTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...slice,
|
||||
@@ -4237,9 +4237,9 @@ describe("Mission API", () => {
|
||||
it("POST milestone interview/respond returns 404 for unknown session", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
const importMock = await import("../milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => {
|
||||
const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js");
|
||||
const { TargetSessionNotFoundError } = await import("../milestone-slice-interview.js");
|
||||
throw new TargetSessionNotFoundError("Session not found");
|
||||
});
|
||||
|
||||
@@ -4257,9 +4257,9 @@ describe("Mission API", () => {
|
||||
it("POST slice interview/respond returns 404 for unknown session", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
const importMock = await import("../milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => {
|
||||
const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js");
|
||||
const { TargetSessionNotFoundError } = await import("../milestone-slice-interview.js");
|
||||
throw new TargetSessionNotFoundError("Session not found");
|
||||
});
|
||||
|
||||
@@ -4281,9 +4281,9 @@ describe("Mission API", () => {
|
||||
const mission = ms.createMission({ title: "Rate Limit Test" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" });
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
const importMock = await import("../milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => {
|
||||
const { RateLimitError } = await import("./milestone-slice-interview.js");
|
||||
const { RateLimitError } = await import("../milestone-slice-interview.js");
|
||||
throw new RateLimitError("Rate limit exceeded", new Date(Date.now() + 3600000));
|
||||
});
|
||||
|
||||
@@ -4307,9 +4307,9 @@ describe("Mission API", () => {
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Rate Limit Slice" });
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
const importMock = await import("../milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => {
|
||||
const { RateLimitError } = await import("./milestone-slice-interview.js");
|
||||
const { RateLimitError } = await import("../milestone-slice-interview.js");
|
||||
throw new RateLimitError("Rate limit exceeded");
|
||||
});
|
||||
|
||||
@@ -28,14 +28,14 @@ import {
|
||||
RateLimitError,
|
||||
SessionNotFoundError,
|
||||
submitMissionInterviewResponse,
|
||||
} from "./mission-interview.js";
|
||||
} from "../mission-interview.js";
|
||||
import {
|
||||
setDiagnosticsSink,
|
||||
resetDiagnosticsSink,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
import type { LogEntry } from "./ai-session-diagnostics.js";
|
||||
} from "../ai-session-diagnostics.js";
|
||||
import type { LogEntry } from "../ai-session-diagnostics.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
import type { AiSessionRow } from "../ai-session-store.js";
|
||||
|
||||
function createQuestionJson(id = "q-1"): string {
|
||||
return JSON.stringify({
|
||||
@@ -53,7 +53,7 @@ type RawConsolePattern = (typeof RAW_CONSOLE_PATTERNS)[number];
|
||||
* Throws if the file cannot be read.
|
||||
*/
|
||||
function readModuleSource(moduleName: AiSessionFlowModule): string {
|
||||
const modulePath = resolve(import.meta.dirname, moduleName);
|
||||
const modulePath = resolve(import.meta.dirname, "..", moduleName);
|
||||
return readFileSync(modulePath, "utf-8");
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("AI-Session Diagnostics Guardrail", () => {
|
||||
`Found ${violations.length} violation(s):\n` +
|
||||
`${violationDetails}\n\n` +
|
||||
`Migration guide:\n` +
|
||||
` 1. Import: import { createSessionDiagnostics } from "./ai-session-diagnostics.js";\n` +
|
||||
` 1. Import: import { createSessionDiagnostics } from "../ai-session-diagnostics.js";\n` +
|
||||
` 2. Create: const diagnostics = createSessionDiagnostics("${moduleShortName}");\n` +
|
||||
` 3. Replace: console.error("msg:", err) -> diagnostics.errorFromException("msg", err, { sessionId, operation });\n` +
|
||||
` 4. Replace: console.error("msg") -> diagnostics.error("msg", { sessionId, operation });`
|
||||
@@ -132,7 +132,7 @@ describe("AI-Session Diagnostics Guardrail", () => {
|
||||
*/
|
||||
describe("shared diagnostics helper contract", () => {
|
||||
it("ai-session-diagnostics exports required APIs", async () => {
|
||||
const helperPath = resolve(import.meta.dirname, "ai-session-diagnostics.ts");
|
||||
const helperPath = resolve(import.meta.dirname, "..", "ai-session-diagnostics.ts");
|
||||
const helperSource = readFileSync(helperPath, "utf-8");
|
||||
|
||||
// Verify the helper exports the core APIs
|
||||
@@ -32,11 +32,11 @@ import {
|
||||
generateSubtasksFromPlanning,
|
||||
formatInterviewQA,
|
||||
SESSION_TTL_MS,
|
||||
} from "./planning.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { request, get } from "./test-request.js";
|
||||
} from "../planning.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request, get } from "../test-request.js";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import { AiSessionStore, type AiSessionRow } from "./ai-session-store.js";
|
||||
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
|
||||
|
||||
// ── Mock Agent Factory ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -520,7 +520,7 @@ describe("planning module", () => {
|
||||
|
||||
it("logs error diagnostic when agent initialization fails and preserves error state", async () => {
|
||||
// Import the shared helper for diagnostics capture
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("./ai-session-diagnostics.js");
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js");
|
||||
|
||||
let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record<string, unknown> }> = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
@@ -1133,7 +1133,7 @@ describe("planning module", () => {
|
||||
|
||||
it("skips corrupted rows and continues rehydrating valid sessions", async () => {
|
||||
// Import the shared helper for diagnostics capture
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("./ai-session-diagnostics.js");
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js");
|
||||
|
||||
const store = new MockAiSessionStore();
|
||||
const goodRow = buildPlanningRow({ id: "planning-good", status: "awaiting_input" });
|
||||
@@ -1436,7 +1436,7 @@ describe("planning module", () => {
|
||||
|
||||
it("logs error diagnostic when no JSON candidate found before throwing", async () => {
|
||||
// Import the shared helper for diagnostics capture
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("./ai-session-diagnostics.js");
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js");
|
||||
|
||||
let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record<string, unknown> }> = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
@@ -1465,7 +1465,7 @@ describe("planning module", () => {
|
||||
|
||||
it("logs error diagnostic when repair also fails before throwing", async () => {
|
||||
// Import the shared helper for diagnostics capture
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("./ai-session-diagnostics.js");
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js");
|
||||
|
||||
let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record<string, unknown> }> = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
@@ -1495,7 +1495,7 @@ describe("planning module", () => {
|
||||
|
||||
it("logs error diagnostic for invalid response structure before throwing", async () => {
|
||||
// Import the shared helper for diagnostics capture
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("./ai-session-diagnostics.js");
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js");
|
||||
|
||||
let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record<string, unknown> }> = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
@@ -1685,7 +1685,7 @@ describe("planning module", () => {
|
||||
|
||||
it("broadcast callback throw logs error but broadcast continues and buffer remains valid", async () => {
|
||||
// Import the shared helper for diagnostics capture
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("./ai-session-diagnostics.js");
|
||||
const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js");
|
||||
|
||||
const sessionId = "stream-session-throw";
|
||||
let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record<string, unknown> }> = [];
|
||||
@@ -6,9 +6,9 @@ import type { TaskStore } from "@fusion/core";
|
||||
import type { PluginInstallation } from "@fusion/core";
|
||||
import type { PluginStore } from "@fusion/core";
|
||||
import type { PluginLoader } from "@fusion/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
import * as projectStoreResolver from "./project-store-resolver.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import * as projectStoreResolver from "../project-store-resolver.js";
|
||||
|
||||
// Mock @fusion/core
|
||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -16,9 +16,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore, PluginStore, PluginLoader, PluginInstallation } from "@fusion/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
import * as projectStoreResolver from "./project-store-resolver.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import * as projectStoreResolver from "../project-store-resolver.js";
|
||||
|
||||
// ── Mock @fusion/core ─────────────────────────────────────────────
|
||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
|
||||
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
function mockReq(ip = "127.0.0.1"): Partial<Request> {
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
import { createRoadmapRouter } from "./roadmap-routes.js";
|
||||
import { ApiError } from "./api-error.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import { createRoadmapRouter } from "../roadmap-routes.js";
|
||||
import { ApiError } from "../api-error.js";
|
||||
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion/core";
|
||||
|
||||
|
||||
// vi.mock is hoisted
|
||||
vi.mock("./roadmap-suggestions.js", () => {
|
||||
vi.mock("../roadmap-suggestions.js", () => {
|
||||
// Define error classes inside the factory - these will be used by the mocked module
|
||||
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
|
||||
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
|
||||
@@ -28,7 +28,7 @@ vi.mock("./roadmap-suggestions.js", () => {
|
||||
});
|
||||
|
||||
const mockGetOrCreateProjectStore = vi.fn();
|
||||
vi.mock("./project-store-resolver.js", () => ({
|
||||
vi.mock("../project-store-resolver.js", () => ({
|
||||
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
|
||||
}));
|
||||
|
||||
@@ -578,7 +578,7 @@ describe("Roadmap Routes", () => {
|
||||
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
|
||||
it("returns 503 when generation times out", async () => {
|
||||
// Import the mocked module
|
||||
const mod = await import("./roadmap-suggestions.js");
|
||||
const mod = await import("../roadmap-suggestions.js");
|
||||
|
||||
// Create an instance of the mocked ServiceUnavailableError
|
||||
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
|
||||
@@ -604,7 +604,7 @@ describe("Roadmap Routes", () => {
|
||||
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
|
||||
it("returns 503 when generation times out", async () => {
|
||||
// Import the mocked module - vi.mocked helps with type inference
|
||||
const mod = vi.mocked(await import("./roadmap-suggestions.js"));
|
||||
const mod = vi.mocked(await import("../roadmap-suggestions.js"));
|
||||
|
||||
// Create an instance of the mocked ServiceUnavailableError
|
||||
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
__resetSuggestionState,
|
||||
__setCreateFnAgent,
|
||||
} from "./roadmap-suggestions";
|
||||
} from "../roadmap-suggestions";
|
||||
|
||||
describe("roadmap-suggestions", () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,24 +9,24 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { GitHubClient } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { GitHubClient } from "../github.js";
|
||||
import { githubRateLimiter } from "../github-poll.js";
|
||||
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, ChatSession, ChatMessage } from "@fusion/core";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "./routes.js";
|
||||
import * as agentGenerationModule from "./agent-generation.js";
|
||||
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "./planning.js";
|
||||
import * as planningModule from "./planning.js";
|
||||
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "./subtask-breakdown.js";
|
||||
import * as subtaskBreakdownModule from "./subtask-breakdown.js";
|
||||
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
|
||||
import * as projectStoreResolver from "./project-store-resolver.js";
|
||||
import * as terminalServiceModule from "./terminal-service.js";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
import { resetRuntimeLogSink, setRuntimeLogSink } from "./runtime-logger.js";
|
||||
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "./ai-session-diagnostics.js";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
|
||||
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
|
||||
import * as agentGenerationModule from "../agent-generation.js";
|
||||
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
|
||||
import * as planningModule from "../planning.js";
|
||||
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "../subtask-breakdown.js";
|
||||
import * as subtaskBreakdownModule from "../subtask-breakdown.js";
|
||||
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "../ai-session-store.js";
|
||||
import * as projectStoreResolver from "../project-store-resolver.js";
|
||||
import * as terminalServiceModule from "../terminal-service.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
|
||||
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
|
||||
|
||||
// Mock @fusion/core for gh CLI auth checks
|
||||
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||||
@@ -8780,7 +8780,7 @@ describe("Planning Mode Routes", () => {
|
||||
|
||||
// Manually simulate an awaiting_input session by updating the session state
|
||||
// In the real app, this happens via respondToPlanning which sets currentQuestion
|
||||
const { planningStreamManager, getSession } = await import("./planning.js");
|
||||
const { planningStreamManager, getSession } = await import("../planning.js");
|
||||
const session = getSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
|
||||
@@ -10798,7 +10798,7 @@ describe("Terminal WebSocket close handler", () => {
|
||||
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const { setupTerminalWebSocket } = await import("./server.js");
|
||||
const { setupTerminalWebSocket } = await import("../server.js");
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -10852,7 +10852,7 @@ describe("Terminal WebSocket close handler", () => {
|
||||
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const { setupTerminalWebSocket } = await import("./server.js");
|
||||
const { setupTerminalWebSocket } = await import("../server.js");
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -10910,7 +10910,7 @@ describe("Terminal WebSocket close handler", () => {
|
||||
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const { setupTerminalWebSocket } = await import("./server.js");
|
||||
const { setupTerminalWebSocket } = await import("../server.js");
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -12072,14 +12072,14 @@ describe("Routine routes", () => {
|
||||
// These tests verify the verifyWebhookSignature function directly
|
||||
// since testing through HTTP requires complex middleware setup
|
||||
it("verifyWebhookSignature rejects missing signature header", async () => {
|
||||
const { verifyWebhookSignature } = await import("./github-webhooks.js");
|
||||
const { verifyWebhookSignature } = await import("../github-webhooks.js");
|
||||
const result = verifyWebhookSignature(Buffer.from("{}"), undefined, "secret");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Missing signature header");
|
||||
});
|
||||
|
||||
it("verifyWebhookSignature rejects wrong signature", async () => {
|
||||
const { verifyWebhookSignature } = await import("./github-webhooks.js");
|
||||
const { verifyWebhookSignature } = await import("../github-webhooks.js");
|
||||
const body = Buffer.from('{"test":true}');
|
||||
const result = verifyWebhookSignature(body, "sha256=deadbeef", "secret");
|
||||
expect(result.valid).toBe(false);
|
||||
@@ -12087,7 +12087,7 @@ describe("Routine routes", () => {
|
||||
});
|
||||
|
||||
it("verifyWebhookSignature accepts valid HMAC", async () => {
|
||||
const { verifyWebhookSignature } = await import("./github-webhooks.js");
|
||||
const { verifyWebhookSignature } = await import("../github-webhooks.js");
|
||||
const secret = "test-secret";
|
||||
const body = Buffer.from('{"test":true}');
|
||||
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
||||
@@ -3,8 +3,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { request as performRequest, get as performGet } from "./test-request.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request as performRequest, get as performGet } from "../test-request.js";
|
||||
|
||||
function createMockGlobalSettingsStore() {
|
||||
return {
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLoopbackIntegrationTest } from "./__tests__/loopback-integration-test.js";
|
||||
import { createLoopbackIntegrationTest } from "./loopback-integration-test.js";
|
||||
|
||||
const staticAssetIntegrationTest = await createLoopbackIntegrationTest("server-static-assets integration");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createServer } from "./server.js";
|
||||
import { createServer } from "../server.js";
|
||||
import type { TaskStore, PluginStore } from "@fusion/core";
|
||||
import { get as performGet } from "./test-request.js";
|
||||
import { get as performGet } from "../test-request.js";
|
||||
|
||||
// Mock terminal-service before any imports that use it
|
||||
vi.mock("./terminal-service.js", () => {
|
||||
vi.mock("../terminal-service.js", () => {
|
||||
const mockTerminalService = {
|
||||
getSession: vi.fn(),
|
||||
getScrollbackAndClearPending: vi.fn().mockReturnValue(null),
|
||||
@@ -4,14 +4,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import { createHmac } from "node:crypto";
|
||||
import express from "express";
|
||||
import { createServer, setupTerminalWebSocket } from "./server.js";
|
||||
import { toSessionTag } from "./terminal-websocket-diagnostics.js";
|
||||
import { RATE_LIMITS } from "./rate-limit.js";
|
||||
import { createServer, setupTerminalWebSocket } from "../server.js";
|
||||
import { toSessionTag } from "../terminal-websocket-diagnostics.js";
|
||||
import { RATE_LIMITS } from "../rate-limit.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
|
||||
// Mock terminal-service before any imports that use it
|
||||
vi.mock("./terminal-service.js", () => {
|
||||
vi.mock("../terminal-service.js", () => {
|
||||
const mockTerminalService = {
|
||||
getSession: vi.fn(),
|
||||
getScrollbackAndClearPending: vi.fn().mockReturnValue(null),
|
||||
@@ -30,7 +30,7 @@ vi.mock("./terminal-service.js", () => {
|
||||
});
|
||||
|
||||
// Access the mock terminal service
|
||||
const { __mockTerminalService: mockTerminalService } = await import("./terminal-service.js") as any;
|
||||
const { __mockTerminalService: mockTerminalService } = await import("../terminal-service.js") as any;
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
@@ -12,14 +12,14 @@ vi.mock("@fusion/engine", () => ({
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
import type { AiSessionRow } from "../ai-session-store.js";
|
||||
// @ts-expect-error Vite raw loader import for source-level utility tests
|
||||
import subtaskBreakdownSource from "./subtask-breakdown.ts?raw";
|
||||
import subtaskBreakdownSource from "../subtask-breakdown.ts?raw";
|
||||
import {
|
||||
setDiagnosticsSink,
|
||||
resetDiagnosticsSink,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
import type { LogEntry } from "./ai-session-diagnostics.js";
|
||||
} from "../ai-session-diagnostics.js";
|
||||
import type { LogEntry } from "../ai-session-diagnostics.js";
|
||||
import {
|
||||
__resetSubtaskBreakdownState,
|
||||
cancelSubtaskSession,
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
InvalidSessionStateError,
|
||||
setAiSessionStore,
|
||||
SubtaskStreamManager,
|
||||
} from "./subtask-breakdown.js";
|
||||
} from "../subtask-breakdown.js";
|
||||
|
||||
const UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { TerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
||||
import { TerminalService, STALE_SESSION_THRESHOLD_MS } from "../terminal-service.js";
|
||||
|
||||
// Mock node-pty
|
||||
const mockPtyProcess = {
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
withTimeout,
|
||||
CLAUDE_FETCH_TIMEOUT_MS,
|
||||
_clearRefreshedToken,
|
||||
} from "./usage.js";
|
||||
} from "../usage.js";
|
||||
|
||||
// Mock the https module
|
||||
const mockRequest = vi.fn();
|
||||
@@ -1,434 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "@fusion/core";
|
||||
import {
|
||||
AiSessionStore,
|
||||
SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
|
||||
type AiSessionRow,
|
||||
type AiSessionStatus,
|
||||
} from "./ai-session-store.js";
|
||||
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "./ai-session-diagnostics.js";
|
||||
|
||||
describe("AiSessionStore", () => {
|
||||
let tmpRoot: string;
|
||||
let db: Database;
|
||||
let store: AiSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "kb-ai-session-store-"));
|
||||
db = new Database(join(tmpRoot, ".fusion"));
|
||||
db.init();
|
||||
store = new AiSessionStore(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.stopScheduledCleanup();
|
||||
resetDiagnosticsSink();
|
||||
vi.useRealTimers();
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeRow(id: string, status: AiSessionStatus, projectId: string | null = null): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
type: "planning",
|
||||
status,
|
||||
title: `Session ${id}`,
|
||||
inputPayload: JSON.stringify({ plan: `plan-${id}` }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: status === "complete" ? JSON.stringify({ title: "Done" }) : null,
|
||||
thinkingOutput: "",
|
||||
error: status === "error" ? "boom" : null,
|
||||
projectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function seedSession(params: {
|
||||
id: string;
|
||||
status: AiSessionStatus;
|
||||
ageMs?: number;
|
||||
projectId?: string | null;
|
||||
currentQuestion?: object | null;
|
||||
error?: string | null;
|
||||
}): void {
|
||||
const { id, status, ageMs = 0, projectId = null, currentQuestion = null, error } = params;
|
||||
const row = makeRow(id, status, projectId);
|
||||
row.currentQuestion = currentQuestion ? JSON.stringify(currentQuestion) : null;
|
||||
row.error = error ?? row.error;
|
||||
store.upsert(row);
|
||||
|
||||
if (ageMs > 0) {
|
||||
const staleTs = new Date(Date.now() - ageMs).toISOString();
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(staleTs, id);
|
||||
}
|
||||
}
|
||||
|
||||
function captureDiagnostics(): LogEntry[] {
|
||||
const entries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
entries.push({
|
||||
level,
|
||||
scope,
|
||||
message,
|
||||
context,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
it("cleanupOld removes only stale terminal sessions and emits deleted events", () => {
|
||||
const deletedIds: string[] = [];
|
||||
store.on("ai_session:deleted", (id) => deletedIds.push(id));
|
||||
|
||||
seedSession({ id: "S-complete", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-error", status: "error", ageMs: 2 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-generating", status: "generating", ageMs: 2 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
|
||||
|
||||
const removed = store.cleanupOld(60 * 60 * 1000);
|
||||
|
||||
expect(removed).toBe(2);
|
||||
expect(store.get("S-complete")).toBeNull();
|
||||
expect(store.get("S-error")).toBeNull();
|
||||
expect(store.get("S-generating")).not.toBeNull();
|
||||
expect(store.get("S-awaiting")).not.toBeNull();
|
||||
expect(deletedIds.sort()).toEqual(["S-complete", "S-error"]);
|
||||
});
|
||||
|
||||
it("cleanupStaleSessions removes stale terminal and orphaned sessions with summary", () => {
|
||||
seedSession({ id: "S-complete-old", status: "complete", ageMs: 8 * 24 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-error-old", status: "error", ageMs: 8 * 24 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-generating-old", status: "generating", ageMs: 8 * 24 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-awaiting-old", status: "awaiting_input", ageMs: 8 * 24 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-generating-fresh", status: "generating", ageMs: 2 * 24 * 60 * 60 * 1000 });
|
||||
|
||||
const summary = store.cleanupStaleSessions();
|
||||
|
||||
expect(summary).toEqual({
|
||||
terminalDeleted: 2,
|
||||
orphanedDeleted: 2,
|
||||
totalDeleted: 4,
|
||||
});
|
||||
expect(store.get("S-complete-old")).toBeNull();
|
||||
expect(store.get("S-error-old")).toBeNull();
|
||||
expect(store.get("S-generating-old")).toBeNull();
|
||||
expect(store.get("S-awaiting-old")).toBeNull();
|
||||
expect(store.get("S-generating-fresh")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cleanupStaleSessions emits structured diagnostics with cleanup summary counts", () => {
|
||||
const diagnostics = captureDiagnostics();
|
||||
|
||||
seedSession({ id: "S-complete-old", status: "complete", ageMs: 8 * 24 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-error-old", status: "error", ageMs: 8 * 24 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-generating-old", status: "generating", ageMs: 8 * 24 * 60 * 60 * 1000 });
|
||||
|
||||
const summary = store.cleanupStaleSessions();
|
||||
|
||||
expect(summary).toEqual({
|
||||
terminalDeleted: 2,
|
||||
orphanedDeleted: 1,
|
||||
totalDeleted: 3,
|
||||
});
|
||||
|
||||
expect(diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "info",
|
||||
scope: "ai-session-store",
|
||||
message: "Cleanup removed stale sessions",
|
||||
context: expect.objectContaining({
|
||||
terminalDeleted: 2,
|
||||
orphanedDeleted: 1,
|
||||
totalDeleted: 3,
|
||||
maxAgeMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
|
||||
operation: "cleanup-stale-sessions",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleanupStaleSessions respects explicit maxAgeMs values", () => {
|
||||
seedSession({ id: "S-complete-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-awaiting-older", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-error-recent", status: "error", ageMs: 30 * 60 * 1000 });
|
||||
|
||||
const summary = store.cleanupStaleSessions(60 * 60 * 1000);
|
||||
|
||||
expect(summary).toEqual({
|
||||
terminalDeleted: 1,
|
||||
orphanedDeleted: 1,
|
||||
totalDeleted: 2,
|
||||
});
|
||||
expect(store.get("S-complete-older")).toBeNull();
|
||||
expect(store.get("S-awaiting-older")).toBeNull();
|
||||
expect(store.get("S-error-recent")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cleanupStaleSessions defaults to 7-day max age", () => {
|
||||
seedSession({ id: "S-complete-6days", status: "complete", ageMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS - 60_000 });
|
||||
seedSession({ id: "S-complete-8days", status: "complete", ageMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS + 60_000 });
|
||||
|
||||
const summary = store.cleanupStaleSessions();
|
||||
|
||||
expect(summary).toEqual({
|
||||
terminalDeleted: 1,
|
||||
orphanedDeleted: 0,
|
||||
totalDeleted: 1,
|
||||
});
|
||||
expect(store.get("S-complete-6days")).not.toBeNull();
|
||||
expect(store.get("S-complete-8days")).toBeNull();
|
||||
});
|
||||
|
||||
it("startScheduledCleanup and stopScheduledCleanup control cleanup interval", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
seedSession({ id: "S-old", status: "complete", ageMs: 2 * 60 * 1000 });
|
||||
|
||||
store.startScheduledCleanup(1_000, 60_000);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(store.get("S-old")).toBeNull();
|
||||
|
||||
seedSession({ id: "S-old-2", status: "complete", ageMs: 2 * 60 * 1000 });
|
||||
store.stopScheduledCleanup();
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(store.get("S-old-2")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("startScheduledCleanup emits structured error diagnostics and remains non-fatal on cleanup failure", () => {
|
||||
vi.useFakeTimers();
|
||||
const diagnostics = captureDiagnostics();
|
||||
|
||||
const cleanupSpy = vi
|
||||
.spyOn(store, "cleanupStaleSessions")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
store.startScheduledCleanup(1_000, 60_000);
|
||||
|
||||
expect(() => vi.advanceTimersByTime(2_000)).not.toThrow();
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(2);
|
||||
expect(diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "ai-session-store",
|
||||
message: "Scheduled cleanup failed",
|
||||
context: expect.objectContaining({
|
||||
ttlMs: 60_000,
|
||||
operation: "scheduled-cleanup",
|
||||
error: expect.objectContaining({ message: "boom" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports configurable TTL values", () => {
|
||||
seedSession({ id: "S-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-recent", status: "complete", ageMs: 30 * 60 * 1000 });
|
||||
|
||||
const removedWithShortTtl = store.cleanupOld(60 * 60 * 1000);
|
||||
|
||||
expect(removedWithShortTtl).toBe(1);
|
||||
expect(store.get("S-older")).toBeNull();
|
||||
expect(store.get("S-recent")).not.toBeNull();
|
||||
|
||||
const removedWithLongTtl = store.cleanupOld(3 * 60 * 60 * 1000);
|
||||
expect(removedWithLongTtl).toBe(0);
|
||||
});
|
||||
|
||||
it("recoverStaleSessions keeps recoverable sessions and marks unrecoverable ones as error", () => {
|
||||
seedSession({
|
||||
id: "S-recoverable",
|
||||
status: "generating",
|
||||
currentQuestion: { id: "q-1", type: "text", question: "Continue?" },
|
||||
});
|
||||
seedSession({ id: "S-broken", status: "generating", currentQuestion: null });
|
||||
|
||||
const recovered = store.recoverStaleSessions();
|
||||
|
||||
expect(recovered).toBe(2);
|
||||
expect(store.get("S-recoverable")?.status).toBe("awaiting_input");
|
||||
expect(store.get("S-broken")?.status).toBe("error");
|
||||
expect(store.get("S-broken")?.error).toBe("Session interrupted — please restart");
|
||||
});
|
||||
|
||||
it("recoverStaleSessions emits structured diagnostics when stale sessions are recovered", () => {
|
||||
const diagnostics = captureDiagnostics();
|
||||
|
||||
seedSession({
|
||||
id: "S-recoverable",
|
||||
status: "generating",
|
||||
currentQuestion: { id: "q-1", type: "text", question: "Continue?" },
|
||||
});
|
||||
seedSession({ id: "S-broken", status: "generating", currentQuestion: null });
|
||||
|
||||
const recovered = store.recoverStaleSessions();
|
||||
|
||||
expect(recovered).toBe(2);
|
||||
expect(diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "info",
|
||||
scope: "ai-session-store",
|
||||
message: "Recovered stale sessions after restart",
|
||||
context: expect.objectContaining({
|
||||
recovered: 2,
|
||||
operation: "recover-stale-sessions",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("listActive returns generating/awaiting_input/error sessions", () => {
|
||||
seedSession({ id: "S-generating", status: "generating" });
|
||||
seedSession({ id: "S-awaiting", status: "awaiting_input" });
|
||||
seedSession({ id: "S-complete", status: "complete" });
|
||||
seedSession({ id: "S-error", status: "error" });
|
||||
|
||||
const active = store.listActive();
|
||||
|
||||
expect(active.map((session) => session.status).sort()).toEqual(["awaiting_input", "error", "generating"]);
|
||||
expect(active.map((session) => session.id).sort()).toEqual(["S-awaiting", "S-error", "S-generating"]);
|
||||
});
|
||||
|
||||
it("listActive filters by projectId", () => {
|
||||
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
|
||||
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
|
||||
seedSession({ id: "S-a3", status: "error", projectId: "project-a" });
|
||||
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
|
||||
seedSession({ id: "S-a-done", status: "complete", projectId: "project-a" });
|
||||
|
||||
const projectA = store.listActive("project-a");
|
||||
|
||||
expect(projectA).toHaveLength(3);
|
||||
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2", "S-a3"]);
|
||||
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("ping updates updatedAt for existing sessions without emitting updates", () => {
|
||||
seedSession({ id: "S-ping", status: "awaiting_input" });
|
||||
|
||||
const staleTs = new Date(Date.now() - 60_000).toISOString();
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(staleTs, "S-ping");
|
||||
|
||||
const onUpdated = vi.fn();
|
||||
store.on("ai_session:updated", onUpdated);
|
||||
|
||||
const updated = store.ping("S-ping");
|
||||
|
||||
expect(updated).toBe(true);
|
||||
expect(store.get("S-ping")?.updatedAt).not.toBe(staleTs);
|
||||
expect(onUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ping returns false for nonexistent sessions", () => {
|
||||
const onUpdated = vi.fn();
|
||||
store.on("ai_session:updated", onUpdated);
|
||||
|
||||
expect(store.ping("missing-session")).toBe(false);
|
||||
expect(onUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updateStatus atomically transitions status and clears error when omitted", () => {
|
||||
seedSession({ id: "S-retry", status: "error", error: "Transient failure" });
|
||||
|
||||
const onUpdated = vi.fn();
|
||||
store.on("ai_session:updated", onUpdated);
|
||||
|
||||
const updated = store.updateStatus("S-retry", "generating");
|
||||
|
||||
expect(updated).toBe(true);
|
||||
expect(store.get("S-retry")?.status).toBe("generating");
|
||||
expect(store.get("S-retry")?.error).toBeNull();
|
||||
expect(onUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(onUpdated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "S-retry",
|
||||
status: "generating",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updateStatus sets explicit error and returns false for missing session", () => {
|
||||
seedSession({ id: "S-failed", status: "generating" });
|
||||
|
||||
expect(store.updateStatus("S-failed", "error", "Agent crashed")).toBe(true);
|
||||
expect(store.get("S-failed")?.status).toBe("error");
|
||||
expect(store.get("S-failed")?.error).toBe("Agent crashed");
|
||||
|
||||
expect(store.updateStatus("S-missing", "error", "Nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("listRecoverable returns awaiting_input and generating sessions", () => {
|
||||
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
|
||||
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });
|
||||
seedSession({ id: "S-complete", status: "complete" });
|
||||
|
||||
const recoverable = store.listRecoverable();
|
||||
|
||||
expect(recoverable.map((session) => session.id)).toEqual(["S-awaiting", "S-generating"]);
|
||||
expect(recoverable.map((session) => session.status).sort()).toEqual(["awaiting_input", "generating"]);
|
||||
});
|
||||
|
||||
it("listRecoverable excludes complete and error sessions", () => {
|
||||
seedSession({ id: "S-complete", status: "complete" });
|
||||
seedSession({ id: "S-error", status: "error" });
|
||||
|
||||
const recoverable = store.listRecoverable();
|
||||
|
||||
expect(recoverable).toEqual([]);
|
||||
});
|
||||
|
||||
it("listRecoverable filters by projectId", () => {
|
||||
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
|
||||
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
|
||||
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
|
||||
|
||||
const projectA = store.listRecoverable("project-a");
|
||||
|
||||
expect(projectA).toHaveLength(2);
|
||||
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2"]);
|
||||
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("listRecoverable returns full AiSessionRow objects", () => {
|
||||
seedSession({
|
||||
id: "S-full",
|
||||
status: "awaiting_input",
|
||||
projectId: "project-a",
|
||||
currentQuestion: { id: "q-1", type: "text", question: "Next?" },
|
||||
});
|
||||
|
||||
const [row] = store.listRecoverable();
|
||||
|
||||
expect(row).toMatchObject({
|
||||
id: "S-full",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Session S-full",
|
||||
inputPayload: expect.any(String),
|
||||
conversationHistory: expect.any(String),
|
||||
currentQuestion: expect.any(String),
|
||||
result: null,
|
||||
thinkingOutput: expect.any(String),
|
||||
error: null,
|
||||
projectId: "project-a",
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createSSE, disconnectSSEClient, getActiveSSEConnections, markSSEClientAlive } from "./sse.js";
|
||||
|
||||
class MockSocket extends EventEmitter {
|
||||
destroyed = false;
|
||||
setKeepAlive = vi.fn();
|
||||
destroy = vi.fn(() => {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.emit("close");
|
||||
});
|
||||
}
|
||||
|
||||
class MockResponse extends EventEmitter {
|
||||
headers = new Map<string, string>();
|
||||
writableEnded = false;
|
||||
destroyed = false;
|
||||
write = vi.fn();
|
||||
flushHeaders = vi.fn();
|
||||
end = vi.fn(() => {
|
||||
if (this.writableEnded) return;
|
||||
this.writableEnded = true;
|
||||
this.emit("close");
|
||||
});
|
||||
|
||||
constructor(readonly socket: MockSocket) {
|
||||
super();
|
||||
}
|
||||
|
||||
setHeader(name: string, value: string): void {
|
||||
this.headers.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStore(): TaskStore {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function openSseConnection(clientId: string, projectId?: string) {
|
||||
const store = createMockStore();
|
||||
const socket = new MockSocket();
|
||||
const req = new EventEmitter() as Request & { query: Record<string, string>; socket: MockSocket };
|
||||
req.query = projectId ? { clientId, projectId } : { clientId };
|
||||
req.socket = socket;
|
||||
const res = new MockResponse(socket);
|
||||
|
||||
createSSE(
|
||||
store,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
projectId ? { projectId } : undefined,
|
||||
)(req, res as unknown as Response);
|
||||
|
||||
return { req, res, socket, store };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createSSE client cleanup", () => {
|
||||
it("disconnectSSEClient closes and unregisters the matching stream", () => {
|
||||
const baseline = getActiveSSEConnections();
|
||||
const connection = openSseConnection("client-one");
|
||||
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
expect(disconnectSSEClient("client-one")).toBe(1);
|
||||
|
||||
expect(connection.res.end).toHaveBeenCalledTimes(1);
|
||||
expect(connection.socket.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveSSEConnections()).toBe(baseline);
|
||||
});
|
||||
|
||||
it("a new stream supersedes an older stream from the same client and project", () => {
|
||||
const baseline = getActiveSSEConnections();
|
||||
const first = openSseConnection("client-two", "project-a");
|
||||
const second = openSseConnection("client-two", "project-a");
|
||||
|
||||
expect(first.res.end).toHaveBeenCalledTimes(1);
|
||||
expect(first.socket.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(second.res.end).not.toHaveBeenCalled();
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
expect(disconnectSSEClient("client-two", "project-a")).toBe(1);
|
||||
expect(getActiveSSEConnections()).toBe(baseline);
|
||||
});
|
||||
|
||||
it("keeps streams from the same client isolated by project scope", () => {
|
||||
const baseline = getActiveSSEConnections();
|
||||
const first = openSseConnection("client-three", "project-a");
|
||||
const second = openSseConnection("client-three", "project-b");
|
||||
|
||||
expect(first.res.end).not.toHaveBeenCalled();
|
||||
expect(second.res.end).not.toHaveBeenCalled();
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 2);
|
||||
|
||||
expect(disconnectSSEClient("client-three", "project-a")).toBe(1);
|
||||
expect(first.res.end).toHaveBeenCalledTimes(1);
|
||||
expect(second.res.end).not.toHaveBeenCalled();
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
expect(disconnectSSEClient("client-three", "project-b")).toBe(1);
|
||||
expect(getActiveSSEConnections()).toBe(baseline);
|
||||
});
|
||||
|
||||
it("closes a client stream when keepalives stop", () => {
|
||||
vi.useFakeTimers();
|
||||
const baseline = getActiveSSEConnections();
|
||||
const connection = openSseConnection("client-four");
|
||||
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
vi.advanceTimersByTime(4_999);
|
||||
expect(connection.res.end).not.toHaveBeenCalled();
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(connection.res.end).toHaveBeenCalledTimes(1);
|
||||
expect(connection.socket.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveSSEConnections()).toBe(baseline);
|
||||
});
|
||||
|
||||
it("extends a client stream while keepalives arrive", () => {
|
||||
vi.useFakeTimers();
|
||||
const baseline = getActiveSSEConnections();
|
||||
const connection = openSseConnection("client-five");
|
||||
|
||||
vi.advanceTimersByTime(4_000);
|
||||
expect(markSSEClientAlive("client-five")).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(4_000);
|
||||
expect(connection.res.end).not.toHaveBeenCalled();
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(connection.res.end).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveSSEConnections()).toBe(baseline);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user