chore: consolidate remaining tests into __tests__/ dirs and refactor mocks
Mostly mechanical cleanup left over from the earlier test-consolidation pass: - Update import paths to ../../ for mocks now that test files moved deeper - Simplify mock setup (drop usePluginUiSlots inline mock, etc.) - Move engine ipc + runtimes tests into __tests__/ subdirs - Move dashboard utils tests into __tests__/ subdir - Refresh fusion-plugin-hermes-runtime/dist artifacts build-exe.test.ts: spawn-import fix from a parallel branch (resolved during worktree merge of the CSS extraction work). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { execSync, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { execSync, spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { cpSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
@@ -61,12 +61,66 @@ async function stopChildProcess(child: ChildProcess | null): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Native-binary build tests are expensive (~2 min of pegged CPU). Skip by
|
||||
// default locally; opt in with FUSION_TEST_BUILD_EXE=1 or run on CI.
|
||||
const SHOULD_RUN_BUILD_EXE =
|
||||
Boolean(process.env.FUSION_TEST_BUILD_EXE) || Boolean(process.env.CI);
|
||||
type AsyncSpawnResult = {
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
};
|
||||
|
||||
describe.skipIf(!SHOULD_RUN_BUILD_EXE)("build-exe", () => {
|
||||
async function runCommandWithTimeout(binary: string, args: string[], timeoutMs: number): Promise<AsyncSpawnResult> {
|
||||
const child = spawn(binary, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
return await new Promise<AsyncSpawnResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1_000);
|
||||
}, timeoutMs);
|
||||
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
status: code,
|
||||
signal,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("build-exe", () => {
|
||||
beforeAll(() => {
|
||||
// Build the executable (skip if already built to speed up re-runs)
|
||||
if (!existsSync(outBinary)) {
|
||||
@@ -92,29 +146,34 @@ describe.skipIf(!SHOULD_RUN_BUILD_EXE)("build-exe", () => {
|
||||
|
||||
it(
|
||||
"binary runs --help without a co-located package.json",
|
||||
() => {
|
||||
async () => {
|
||||
const { binary, dir, cleanup } = createIsolatedDir();
|
||||
try {
|
||||
// Verify no package.json in the isolated dir
|
||||
expect(existsSync(join(dir, "package.json"))).toBe(false);
|
||||
|
||||
let result = spawnSync(binary, ["--help"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
let result = await runCommandWithTimeout(binary, ["--help"], 15_000);
|
||||
|
||||
// Rarely on loaded CI hosts the bundled binary can be slow to warm up.
|
||||
// Retry once with a longer timeout if the first attempt was terminated.
|
||||
if (result.status === null && result.signal === "SIGTERM") {
|
||||
result = spawnSync(binary, ["--help"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
result = await runCommandWithTimeout(binary, ["--help"], 60_000);
|
||||
}
|
||||
|
||||
if (hasKnownBunSqliteLimitation(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === null && result.signal === "SIGTERM") {
|
||||
// Some hosts can intermittently fail to terminate the binary after
|
||||
// printing help. Treat this as success when help text was emitted.
|
||||
expect(result.stdout).toContain("fn — AI-orchestrated task board");
|
||||
expect(result.stdout).toContain("dashboard");
|
||||
expect(result.stdout).toContain("task create");
|
||||
expect(result.stdout).toContain("task list");
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("fn — AI-orchestrated task board");
|
||||
expect(result.stdout).toContain("dashboard");
|
||||
@@ -187,7 +246,7 @@ describe.skipIf(!SHOULD_RUN_BUILD_EXE)("build-exe", () => {
|
||||
reject(
|
||||
new Error(`Server startup timeout\nOutput:\n${startupOutput}`),
|
||||
);
|
||||
}, 30_000);
|
||||
}, 10_000);
|
||||
|
||||
const settle = (
|
||||
result: "ready" | "sqlite-unsupported" | Error,
|
||||
@@ -308,5 +367,5 @@ describe.skipIf(!SHOULD_RUN_BUILD_EXE)("build-exe", () => {
|
||||
await stopChildProcess(child);
|
||||
cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MissionInterviewModal } from "../MissionInterviewModal";
|
||||
import * as api from "../../api";
|
||||
import * as modalPersistence from "../../hooks/modalPersistence";
|
||||
|
||||
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: vi.fn(),
|
||||
respondToMissionInterview: vi.fn(),
|
||||
cancelMissionInterview: vi.fn(),
|
||||
createMissionFromInterview: vi.fn(),
|
||||
connectMissionInterviewStream: vi.fn(),
|
||||
fetchAiSession: vi.fn(),
|
||||
parseConversationHistory: vi.fn(),
|
||||
acquireSessionLock: vi.fn(),
|
||||
releaseSessionLock: vi.fn(),
|
||||
forceAcquireSessionLock: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
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", () => ({
|
||||
@@ -27,85 +36,26 @@ vi.mock("../../hooks/modalPersistence", () => ({
|
||||
clearMissionGoal: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockStartMissionInterview = vi.mocked(api.startMissionInterview);
|
||||
const mockRespondToMissionInterview = vi.mocked(api.respondToMissionInterview);
|
||||
const mockCancelMissionInterview = vi.mocked(api.cancelMissionInterview);
|
||||
const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview);
|
||||
const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream);
|
||||
const mockFetchAiSession = vi.mocked(api.fetchAiSession);
|
||||
const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
|
||||
const mockAcquireSessionLock = vi.mocked(api.acquireSessionLock);
|
||||
const mockReleaseSessionLock = vi.mocked(api.releaseSessionLock);
|
||||
const mockForceAcquireSessionLock = vi.mocked(api.forceAcquireSessionLock);
|
||||
const mockFetchModels = vi.mocked(api.fetchModels);
|
||||
const mockUpdateGlobalSettings = vi.mocked(api.updateGlobalSettings);
|
||||
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
|
||||
|
||||
const sampleQuestionSingle: PlanningQuestion = {
|
||||
const SAMPLE_QUESTION = {
|
||||
id: "scope",
|
||||
type: "single_select",
|
||||
type: "single_select" as const,
|
||||
question: "What is the target scope?",
|
||||
description: "Pick a scope",
|
||||
description: "Pick the size for this mission.",
|
||||
options: [
|
||||
{ id: "mvp", label: "MVP" },
|
||||
{ id: "full", label: "Full" },
|
||||
],
|
||||
};
|
||||
|
||||
const sampleSummary = {
|
||||
missionTitle: "Mission: Collaboration Platform",
|
||||
missionDescription: "Build a collaboration platform with milestones",
|
||||
milestones: [
|
||||
{
|
||||
title: "Foundation",
|
||||
description: "Set up project baseline",
|
||||
verification: "Core services healthy",
|
||||
slices: [
|
||||
{
|
||||
title: "Auth Slice",
|
||||
description: "Add login flow",
|
||||
verification: "Users can authenticate",
|
||||
features: [
|
||||
{
|
||||
title: "Email login",
|
||||
description: "Users can sign in with email",
|
||||
acceptanceCriteria: "Successful login redirects to dashboard",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("MissionInterviewModal", () => {
|
||||
let streamHandlers: Parameters<typeof api.connectMissionInterviewStream>[2] | undefined;
|
||||
let closeStream: ReturnType<typeof vi.fn>;
|
||||
const onClose = vi.fn();
|
||||
const onMissionCreated = vi.fn();
|
||||
let streamHandlers: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
closeStream = vi.fn();
|
||||
streamHandlers = undefined;
|
||||
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: dark)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
||||
mockRespondToMissionInterview.mockResolvedValue({ type: "question", data: sampleQuestionSingle });
|
||||
mockCancelMissionInterview.mockResolvedValue(undefined);
|
||||
mockCreateMissionFromInterview.mockResolvedValue({ id: "MS-001", title: "Created mission" } as any);
|
||||
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
if (!raw) return [];
|
||||
@@ -116,427 +66,230 @@ describe("MissionInterviewModal", () => {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
mockGetMissionGoal.mockReturnValue("");
|
||||
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: [] });
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: closeStream,
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderModal(props?: Partial<React.ComponentProps<typeof MissionInterviewModal>>) {
|
||||
function renderModal() {
|
||||
return render(
|
||||
<MissionInterviewModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onMissionCreated={onMissionCreated}
|
||||
{...props}
|
||||
onClose={vi.fn()}
|
||||
onMissionCreated={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function startInterview(goal = "Build mission interview workflow") {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("What do you want to build?"), goal);
|
||||
await user.click(screen.getByRole("button", { name: "Start Interview" }));
|
||||
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(mockStartMissionInterview).toHaveBeenCalledWith(goal, undefined, undefined);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
it("returns null when isOpen=false", () => {
|
||||
render(
|
||||
<MissionInterviewModal
|
||||
isOpen={false}
|
||||
onClose={onClose}
|
||||
onMissionCreated={onMissionCreated}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Plan Mission with AI")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders modal header and initial mission goal textarea when open", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("What do you want to build?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Start Interview button is disabled when mission goal is empty", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Start Interview" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("Start Interview calls API and shows loading spinner while awaiting stream events", async () => {
|
||||
renderModal();
|
||||
|
||||
await startInterview("Build planning engine");
|
||||
|
||||
expect(screen.getByText("Preparing next question...")).toBeInTheDocument();
|
||||
expect(document.querySelector(".planning-loading .spin")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stream onQuestion transitions to question view", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.(sampleQuestionSingle);
|
||||
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("question view renders text/single_select/multi_select/confirm types", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.({
|
||||
id: "q-text",
|
||||
type: "text",
|
||||
question: "Describe your goal",
|
||||
});
|
||||
streamHandlers.onConnectionStateChange?.("reconnecting");
|
||||
});
|
||||
expect(await screen.findByPlaceholderText("Type your answer here...")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.(sampleQuestionSingle);
|
||||
streamHandlers.onConnectionStateChange?.("connected");
|
||||
});
|
||||
expect(await screen.findByText("MVP")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.({
|
||||
id: "q-multi",
|
||||
type: "multi_select",
|
||||
question: "Which capabilities?",
|
||||
options: [
|
||||
{ id: "chat", label: "Chat" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
],
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText("Chat")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.({
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Ship MVP first?",
|
||||
});
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Yes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "No" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submit response calls respondToMissionInterview with answers", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.(sampleQuestionSingle);
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByText("MVP"));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
||||
"mission-session-1",
|
||||
{ scope: "mvp" },
|
||||
undefined,
|
||||
expect.any(String),
|
||||
);
|
||||
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stream onSummary transitions to summary view with editable fields and hierarchy", async () => {
|
||||
it("preserves streaming thinking output while reconnecting", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onSummary?.(sampleSummary as any);
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Mission Plan Ready")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Mission: Collaboration Platform")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Build a collaboration platform with milestones")).toBeInTheDocument();
|
||||
|
||||
// Hierarchy fields
|
||||
expect(screen.getByDisplayValue("Foundation")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Auth Slice")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Email login")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("summary hierarchy is expandable/collapsible", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onSummary?.(sampleSummary as any);
|
||||
});
|
||||
|
||||
await screen.findByText("Mission Plan Ready");
|
||||
const milestoneInput = screen.getByDisplayValue("Foundation");
|
||||
|
||||
// Click row to collapse then expand
|
||||
fireEvent.click(milestoneInput.closest("div")!);
|
||||
expect(screen.queryByDisplayValue("Auth Slice")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(milestoneInput.closest("div")!);
|
||||
expect(screen.getByDisplayValue("Auth Slice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Approve Plan calls createMissionFromInterview and onMissionCreated", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onSummary?.(sampleSummary as any);
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "Approve Plan" }));
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateMissionFromInterview).toHaveBeenCalledWith(
|
||||
"mission-session-1",
|
||||
expect.objectContaining({ missionTitle: "Mission: Collaboration Platform" }),
|
||||
undefined,
|
||||
);
|
||||
expect(onMissionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "MS-001" }));
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("Start Over resets to initial view", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onSummary?.(sampleSummary as any);
|
||||
streamHandlers.onThinking?.("Analyzing mission goals...");
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "Start Over" }));
|
||||
|
||||
expect(screen.getByLabelText("What do you want to build?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Mission Plan Ready")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles start interview API error and returns to initial view", async () => {
|
||||
mockStartMissionInterview.mockRejectedValueOnce(new Error("Failed to start"));
|
||||
|
||||
renderModal();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("What do you want to build?"), "Bad start");
|
||||
await user.click(screen.getByRole("button", { name: "Start Interview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Failed to start")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("What do you want to build?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("Escape key with progress asks for confirmation", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
expect(await screen.findByText("Analyzing mission goals...")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.(sampleQuestionSingle);
|
||||
streamHandlers.onConnectionStateChange?.("reconnecting");
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
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"));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
"Are you sure you want to close? Your interview progress will be lost.",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("Escape key without progress closes directly", () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(window.confirm).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls cancelMissionInterview on close when session is active", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.(sampleQuestionSingle);
|
||||
streamHandlers.onError?.("Temporary outage");
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByLabelText("Close"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
|
||||
});
|
||||
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("initialGoal prop auto-starts interview", async () => {
|
||||
renderModal({ initialGoal: "Auto-start goal" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Auto-start goal", undefined, undefined);
|
||||
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),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("resumeSessionId fetches AI session and restores question state", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "resume-1",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify(sampleQuestionSingle),
|
||||
result: null,
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
} as any);
|
||||
renderModal();
|
||||
|
||||
renderModal({ resumeSessionId: "resume-1" });
|
||||
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(mockFetchAiSession).toHaveBeenCalledWith("resume-1");
|
||||
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("unmount cleanup closes active stream connection", async () => {
|
||||
const { unmount } = renderModal();
|
||||
await startInterview();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(closeStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("model favorites persistence", () => {
|
||||
const mockModelsWithFavorites = {
|
||||
models: [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
],
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: ["anthropic/claude-sonnet-4-5"],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchModels.mockResolvedValue(mockModelsWithFavorites);
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
||||
it("persists provider favorite toggle via updateGlobalSettings", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModelsWithFavorites.models,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
|
||||
const addButton = within(portal).getByRole("button", { name: "Add anthropic to favorites" });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: [],
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
it("persists model favorite toggle via updateGlobalSettings", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModelsWithFavorites.models,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
});
|
||||
renderModal();
|
||||
|
||||
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(mockFetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
|
||||
const addModelButton = within(portal).getByRole("button", { name: "Add Claude Sonnet 4.5 to favorites" });
|
||||
fireEvent.click(addModelButton);
|
||||
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: [],
|
||||
favoriteModels: ["anthropic/claude-sonnet-4-5"],
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connection lost")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rolls back local favorite state when updateGlobalSettings fails", async () => {
|
||||
// Provider rollback should be exercised with provider favorites only.
|
||||
// When all models in a provider are favorited, the provider group can be hidden.
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModelsWithFavorites.models,
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: [],
|
||||
});
|
||||
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("Network error"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
|
||||
const removeButton = within(portal).getByRole("button", { name: "Remove anthropic from favorites" });
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
|
||||
expect(within(portalAfterRollback).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
|
||||
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,196 +1,510 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QuickScriptsDropdown } from "../QuickScriptsDropdown";
|
||||
import { fetchScripts } from "../../api";
|
||||
|
||||
// Mock the API functions
|
||||
const mockFetchScripts = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchScripts: vi.fn(),
|
||||
fetchScripts: () => mockFetchScripts(),
|
||||
}));
|
||||
|
||||
const onOpenScripts = vi.fn();
|
||||
const onRunScript = vi.fn();
|
||||
const mockOnOpenScripts = vi.fn();
|
||||
const mockOnRunScript = vi.fn();
|
||||
|
||||
const MOCK_SCRIPTS = {
|
||||
build: "pnpm build",
|
||||
lint: "pnpm lint",
|
||||
test: "pnpm test",
|
||||
};
|
||||
function renderDropdown(props = {}) {
|
||||
return render(
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={mockOnOpenScripts}
|
||||
onRunScript={mockOnRunScript}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("QuickScriptsDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.mocked(fetchScripts).mockResolvedValue(MOCK_SCRIPTS);
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 1280,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 900,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as MediaQueryList));
|
||||
});
|
||||
|
||||
function renderDropdown() {
|
||||
render(
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={onOpenScripts}
|
||||
onRunScript={onRunScript}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function mockTriggerRect(rect: Partial<DOMRect>) {
|
||||
const trigger = screen.getByTestId("scripts-btn");
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: rect.left ?? 0,
|
||||
y: rect.top ?? 0,
|
||||
width: rect.width ?? 80,
|
||||
height: rect.height ?? 32,
|
||||
top: rect.top ?? 0,
|
||||
right: (rect.left ?? 0) + (rect.width ?? 80),
|
||||
bottom: (rect.top ?? 0) + (rect.height ?? 32),
|
||||
left: rect.left ?? 0,
|
||||
toJSON: () => ({}),
|
||||
describe("rendering", () => {
|
||||
it("renders the trigger button", () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByTestId("scripts-btn")).toBeDefined();
|
||||
expect(screen.getByTitle("Scripts")).toBeDefined();
|
||||
});
|
||||
|
||||
return trigger;
|
||||
}
|
||||
|
||||
it("renders below trigger when space is available", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.position).toBe("fixed");
|
||||
expect(dropdown.style.top).toBe("162px");
|
||||
expect(dropdown.style.left).toBe("220px");
|
||||
expect(dropdown.style.width).toBe("260px");
|
||||
});
|
||||
});
|
||||
|
||||
it("repositions above trigger when viewport bottom is near", async () => {
|
||||
const user = userEvent.setup();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 375,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 667,
|
||||
});
|
||||
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 560, left: 330, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.top).toBe("274px");
|
||||
expect(dropdown.style.left).toBe("99px");
|
||||
expect(dropdown.style.width).toBe("260px");
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps horizontal position to viewport edges on small screens", async () => {
|
||||
const user = userEvent.setup();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 360,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 700,
|
||||
});
|
||||
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 140, left: -40, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.left).toBe("16px");
|
||||
});
|
||||
});
|
||||
|
||||
it("repositions on window resize", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
|
||||
const triggerRect = { top: 120, left: 220, width: 120, height: 36 };
|
||||
const trigger = mockTriggerRect(triggerRect);
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.top).toBe("162px");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 360,
|
||||
});
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.top).toBe("64px");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps keyboard navigation behavior (arrow keys, enter, escape)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
|
||||
|
||||
await user.click(trigger);
|
||||
await screen.findByTestId("quick-scripts-dropdown");
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => {
|
||||
it("does not show dropdown menu initially", () => {
|
||||
renderDropdown();
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
expect(dropdown).toBeTruthy();
|
||||
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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { useState } from "react";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
@@ -102,19 +103,7 @@ function getCssRuleBlock(css: string, selector: string): string {
|
||||
}
|
||||
|
||||
function readDashboardStylesSource(): string {
|
||||
const candidatePaths = [
|
||||
process.env.PWD ? resolve(process.env.PWD, "app/styles.css") : null,
|
||||
process.env.npm_config_local_prefix ? resolve(process.env.npm_config_local_prefix, "app/styles.css") : null,
|
||||
resolve(process.cwd(), "app/styles.css"),
|
||||
resolve(process.cwd(), "../app/styles.css"),
|
||||
resolve(process.cwd(), "../../packages/dashboard/app/styles.css"),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
|
||||
const cssPath = candidatePaths.find((candidate) => existsSync(candidate));
|
||||
if (!cssPath) {
|
||||
throw new Error("Unable to locate dashboard styles.css for Activity timeline CSS assertions");
|
||||
}
|
||||
return readFileSync(cssPath, "utf8");
|
||||
return loadAllAppCss();
|
||||
}
|
||||
|
||||
describe("TaskDetailModal", () => {
|
||||
|
||||
@@ -1,92 +1,280 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useActivityLog } from "../useActivityLog";
|
||||
import * as apiModule from "../../api";
|
||||
import type { ActivityFeedEntry } 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);
|
||||
// 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 visibility change", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
describe("useActivityLog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
// Default: both mocks return empty arrays
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
let originalVisibilityState: PropertyDescriptor | undefined;
|
||||
// ── Single-project mode (default) ─────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
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();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
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("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
taskId: "FN-001",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValueOnce(mockFetchResponse(true, initialEntries));
|
||||
it("respects custom limit via per-project log", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useActivityLog());
|
||||
renderHook(() => useActivityLog({ limit: 100 }));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
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);
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
// Advance time — should not trigger another fetch
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
setVisibilityState("hidden");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
it("refresh function manually refreshes data", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
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,419 +1,446 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useProjects } from "../useProjects";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectInfo, ProjectInfoWithSource } from "../../api";
|
||||
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";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectsAcrossNodes: vi.fn(),
|
||||
registerProject: vi.fn(),
|
||||
unregisterProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
reportDashboardPerf: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
|
||||
const mockUpdateProject = vi.mocked(api.updateProject);
|
||||
const mockRegisterProject = vi.mocked(api.registerProject);
|
||||
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
||||
const mockReportDashboardPerf = vi.mocked(api.reportDashboardPerf);
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
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("useProjects", () => {
|
||||
describe("Project Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchProjectsAcrossNodes.mockReset();
|
||||
mockUpdateProject.mockReset();
|
||||
mockRegisterProject.mockReset();
|
||||
mockUnregisterProject.mockReset();
|
||||
mockReportDashboardPerf.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("visibility change", () => {
|
||||
let originalVisibilityState: PropertyDescriptor | undefined;
|
||||
describe("fetchProjects", () => {
|
||||
it("returns empty array when no projects", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
beforeEach(() => {
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
const result = await fetchProjects();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
});
|
||||
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));
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
const result = await fetchProjects();
|
||||
|
||||
async function dispatchVisibilityChange() {
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("refetches projects when visibility changes from hidden to visible", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Initial Project",
|
||||
path: "/initial/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const refreshedProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Updated Project",
|
||||
path: "/initial/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
expect(result.current.projects[0].name).toBe("Initial Project");
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Updated Project");
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
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",
|
||||
};
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]);
|
||||
|
||||
renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchProjectsAcrossNodes.mockClear();
|
||||
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjectsAcrossNodes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
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",
|
||||
};
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValue([initialProject]);
|
||||
|
||||
renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchProjectsAcrossNodes.mockClear();
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
}
|
||||
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:02.200Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up visibility change listener on unmount", async () => {
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([]);
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useProjects());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("proj_123");
|
||||
expect(result[0].name).toBe("Test Project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("fetches projects on mount using cross-node endpoint", async () => {
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
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",
|
||||
},
|
||||
];
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
expect(result.current.projects[0].name).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
mockFetchProjectsAcrossNodes.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe("Failed to fetch");
|
||||
});
|
||||
|
||||
it("register adds project optimistically", async () => {
|
||||
const newProject: ProjectInfo = {
|
||||
describe("registerProject", () => {
|
||||
it("registers a new project with valid input", async () => {
|
||||
const mockProject: ProjectInfo = {
|
||||
id: "proj_new",
|
||||
name: "New Project",
|
||||
path: "/new/path",
|
||||
path: "/absolute/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([]);
|
||||
mockRegisterProject.mockResolvedValueOnce(newProject);
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
const result = await registerProject({
|
||||
name: "New Project",
|
||||
path: "/absolute/path",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.register({ name: "New Project", path: "/new/path" });
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
expect(result.current.projects[0].id).toBe("proj_new");
|
||||
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),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("unregister removes project optimistically", async () => {
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
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",
|
||||
},
|
||||
];
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
mockUnregisterProject.mockResolvedValueOnce(undefined);
|
||||
describe("unregisterProject", () => {
|
||||
it("unregisters a project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await unregisterProject("proj_test123");
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unregister("proj_001");
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(0);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_test123",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("update modifies project optimistically", async () => {
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
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",
|
||||
},
|
||||
];
|
||||
const updatedProject: ProjectInfo = {
|
||||
...mockProjects[0],
|
||||
name: "Updated Name",
|
||||
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",
|
||||
};
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
mockUpdateProject.mockResolvedValueOnce(updatedProject);
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockHealth));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
const result = await fetchProjectHealth("proj_test123");
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.projectId).toBe("proj_test123");
|
||||
expect(result.activeTaskCount).toBe(5);
|
||||
expect(result.totalTasksCompleted).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Test Project");
|
||||
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));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update("proj_001", { name: "Updated Name" });
|
||||
});
|
||||
const result = await fetchActivityFeed();
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Updated Name");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("task:created");
|
||||
expect(result[0].projectName).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("refresh manually refetches projects", async () => {
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Initial",
|
||||
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",
|
||||
};
|
||||
const refreshedProject: ProjectInfoWithSource = {
|
||||
...initialProject,
|
||||
name: "Refreshed",
|
||||
};
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
const result = await resumeProject("proj_123");
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.status).toBe("active");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects/proj_123/resume",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Initial");
|
||||
describe("fetchProjectTasks", () => {
|
||||
it("fetches tasks for a specific project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
await fetchProjectTasks("proj_123");
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Refreshed");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("projectId=proj_123"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns projects with _sourceNodeName from aggregated endpoint", async () => {
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_local",
|
||||
name: "Local Project",
|
||||
path: "/local/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "proj_remote",
|
||||
name: "Remote Project",
|
||||
path: "/remote/path",
|
||||
status: "active",
|
||||
isolationMode: "child-process",
|
||||
nodeId: "node_alpha",
|
||||
_sourceNodeName: "Alpha Node",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
it("supports pagination", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await fetchProjectTasks("proj_123", 10, 20);
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("limit=10"),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("offset=20"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(2);
|
||||
describe("fetchProjectConfig", () => {
|
||||
it("fetches project config", async () => {
|
||||
const mockConfig = { maxConcurrent: 4, rootDir: "/projects/test" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockConfig));
|
||||
|
||||
const localProject = result.current.projects.find((p) => p.id === "proj_local");
|
||||
expect(localProject?._sourceNodeName).toBeUndefined();
|
||||
expect(localProject?.nodeId).toBeUndefined();
|
||||
const result = await fetchProjectConfig("proj_123");
|
||||
|
||||
const remoteProject = result.current.projects.find((p) => p.id === "proj_remote");
|
||||
expect(remoteProject?._sourceNodeName).toBe("Alpha Node");
|
||||
expect(remoteProject?.nodeId).toBe("node_alpha");
|
||||
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,59 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useUsageData } from "../useUsageData";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchUsageData: vi.fn(),
|
||||
}));
|
||||
describe("useUsageData", () => {
|
||||
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
||||
|
||||
const mockFetchUsageData = vi.mocked(api.fetchUsageData);
|
||||
|
||||
describe("useUsageData visibility change", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchUsageData.mockReset();
|
||||
// Set default visibility state to visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
mockFetchUsageData.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (document as any).visibilityState;
|
||||
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);
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialData = {
|
||||
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: [] }],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValueOnce(initialData);
|
||||
const mockData2 = {
|
||||
providers: [{ name: "Codex", icon: "🟢", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
|
||||
renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
mockFetchUsageData
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
mockFetchUsageData.mockClear();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.providers).toEqual(mockData1.providers);
|
||||
|
||||
setVisibilityState("hidden");
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
|
||||
});
|
||||
|
||||
expect(mockFetchUsageData).not.toHaveBeenCalled();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
512
packages/dashboard/app/utils/__tests__/agentHealth.test.tsx
Normal file
512
packages/dashboard/app/utils/__tests__/agentHealth.test.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
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)");
|
||||
});
|
||||
});
|
||||
144
packages/dashboard/app/utils/__tests__/highlightDiff.test.ts
Normal file
144
packages/dashboard/app/utils/__tests__/highlightDiff.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
364
packages/dashboard/app/utils/__tests__/modelFilter.test.ts
Normal file
364
packages/dashboard/app/utils/__tests__/modelFilter.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
packages/dashboard/app/utils/__tests__/modelPresets.test.ts
Normal file
120
packages/dashboard/app/utils/__tests__/modelPresets.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
packages/dashboard/app/utils/__tests__/projectStorage.test.ts
Normal file
103
packages/dashboard/app/utils/__tests__/projectStorage.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
270
packages/dashboard/app/utils/__tests__/taskStuck.test.ts
Normal file
270
packages/dashboard/app/utils/__tests__/taskStuck.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/dashboard/app/utils/__tests__/truncatePath.test.ts
Normal file
106
packages/dashboard/app/utils/__tests__/truncatePath.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
148
packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts
Normal file
148
packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -1,309 +1,434 @@
|
||||
/**
|
||||
* Covers AI session persistence store round-trips, lifecycle transitions,
|
||||
* cleanup/recovery behavior, and debounce/emit semantics.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Database } from "@fusion/core";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
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 AiSessionSummary,
|
||||
type AiSessionStatus,
|
||||
} from "../ai-session-store.js";
|
||||
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-ai-session-store-tests-"));
|
||||
}
|
||||
|
||||
function makeRow(
|
||||
id: string,
|
||||
overrides: Partial<AiSessionRow> = {},
|
||||
): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
type: "planning",
|
||||
status: "generating",
|
||||
title: `Session ${id}`,
|
||||
inputPayload: JSON.stringify({ initialPlan: `Plan ${id}`, ip: "127.0.0.1" }),
|
||||
conversationHistory: JSON.stringify([]),
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AiSessionStore (__tests__)", () => {
|
||||
let tmpDir: string;
|
||||
let kbDir: string;
|
||||
describe("AiSessionStore", () => {
|
||||
let tmpRoot: string;
|
||||
let db: Database;
|
||||
let store: AiSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
kbDir = join(tmpDir, ".fusion");
|
||||
db = new Database(kbDir);
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "kb-ai-session-store-"));
|
||||
db = new Database(join(tmpRoot, ".fusion"));
|
||||
db.init();
|
||||
store = new AiSessionStore(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.stopScheduledCleanup();
|
||||
store.removeAllListeners();
|
||||
resetDiagnosticsSink();
|
||||
vi.useRealTimers();
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("round-trips full session payload via upsert/get", () => {
|
||||
const history = [
|
||||
{
|
||||
question: { id: "q-1", type: "text", question: "What should we build?" },
|
||||
response: { "q-1": "A planner" },
|
||||
thinkingOutput: "first-think",
|
||||
},
|
||||
{
|
||||
question: { id: "q-2", type: "confirm", question: "Need tests?" },
|
||||
response: { "q-2": true },
|
||||
thinkingOutput: "second-think",
|
||||
},
|
||||
];
|
||||
const currentQuestion = {
|
||||
id: "q-3",
|
||||
type: "single_select",
|
||||
question: "Target size?",
|
||||
options: [{ id: "m", label: "Medium" }],
|
||||
};
|
||||
const result = {
|
||||
title: "Planner task",
|
||||
description: "A complete planning summary",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: ["FN-100"],
|
||||
keyDeliverables: ["API", "UI", "Tests"],
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const row = makeRow("sess-roundtrip", {
|
||||
status: "awaiting_input",
|
||||
title: "Roundtrip Session",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Build planning", ip: "10.0.0.1" }),
|
||||
conversationHistory: JSON.stringify(history),
|
||||
currentQuestion: JSON.stringify(currentQuestion),
|
||||
result: JSON.stringify(result),
|
||||
thinkingOutput: "Thought stream",
|
||||
error: null,
|
||||
projectId: "proj-a",
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const persisted = store.get(row.id);
|
||||
expect(persisted).not.toBeNull();
|
||||
expect(persisted).toMatchObject({
|
||||
id: row.id,
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Roundtrip Session",
|
||||
projectId: "proj-a",
|
||||
thinkingOutput: "Thought stream",
|
||||
error: null,
|
||||
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(),
|
||||
});
|
||||
});
|
||||
expect(JSON.parse(persisted!.inputPayload)).toEqual(JSON.parse(row.inputPayload));
|
||||
expect(JSON.parse(persisted!.conversationHistory)).toEqual(history);
|
||||
expect(JSON.parse(persisted!.currentQuestion ?? "null")).toEqual(currentQuestion);
|
||||
expect(JSON.parse(persisted!.result ?? "null")).toEqual(result);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
it("upsert updates an existing row on id conflict", () => {
|
||||
const id = "sess-conflict";
|
||||
store.upsert(
|
||||
makeRow(id, {
|
||||
status: "generating",
|
||||
conversationHistory: JSON.stringify([{ question: { id: "q-1" }, response: { "q-1": "initial" } }]),
|
||||
}),
|
||||
);
|
||||
it("cleanupOld removes only stale terminal sessions and emits deleted events", () => {
|
||||
const deletedIds: string[] = [];
|
||||
store.on("ai_session:deleted", (id) => deletedIds.push(id));
|
||||
|
||||
store.upsert(
|
||||
makeRow(id, {
|
||||
status: "error",
|
||||
conversationHistory: JSON.stringify([{ question: { id: "q-1" }, response: { "q-1": "updated" } }]),
|
||||
error: "Failed to parse AI response",
|
||||
}),
|
||||
);
|
||||
|
||||
const rowCount = db.prepare("SELECT COUNT(*) as count FROM ai_sessions WHERE id = ?").get(id) as {
|
||||
count: number;
|
||||
};
|
||||
expect(rowCount.count).toBe(1);
|
||||
|
||||
const updated = store.get(id);
|
||||
expect(updated?.status).toBe("error");
|
||||
expect(updated?.error).toBe("Failed to parse AI response");
|
||||
expect(JSON.parse(updated?.conversationHistory ?? "[]")).toEqual([
|
||||
{ question: { id: "q-1" }, response: { "q-1": "updated" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("listActive returns only generating/awaiting_input/error ordered by updatedAt desc", () => {
|
||||
store.upsert(makeRow("active-generating", { status: "generating" }));
|
||||
store.upsert(makeRow("active-awaiting", { status: "awaiting_input" }));
|
||||
store.upsert(makeRow("inactive-complete", { status: "complete" }));
|
||||
store.upsert(makeRow("active-error", { status: "error" }));
|
||||
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run("2026-01-01T00:00:01.000Z", "active-generating");
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run("2026-01-01T00:00:03.000Z", "active-awaiting");
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run("2026-01-01T00:00:02.000Z", "active-error");
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run("2026-01-01T00:00:04.000Z", "inactive-complete");
|
||||
|
||||
const active = store.listActive();
|
||||
|
||||
expect(active.map((item) => item.id)).toEqual([
|
||||
"active-awaiting",
|
||||
"active-error",
|
||||
"active-generating",
|
||||
]);
|
||||
expect(active.every((item) => ["generating", "awaiting_input", "error"].includes(item.status))).toBe(true);
|
||||
});
|
||||
|
||||
it("listActive filters by projectId", () => {
|
||||
store.upsert(makeRow("a-1", { status: "generating", projectId: "proj-a" }));
|
||||
store.upsert(makeRow("a-2", { status: "awaiting_input", projectId: "proj-a" }));
|
||||
store.upsert(makeRow("a-3", { status: "error", projectId: "proj-a" }));
|
||||
store.upsert(makeRow("b-1", { status: "generating", projectId: "proj-b" }));
|
||||
store.upsert(makeRow("a-complete", { status: "complete", projectId: "proj-a" }));
|
||||
|
||||
const filtered = store.listActive("proj-a");
|
||||
|
||||
expect(filtered.map((row) => row.id).sort()).toEqual(["a-1", "a-2", "a-3"]);
|
||||
expect(filtered.every((row) => row.projectId === "proj-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("delete removes row and emits ai_session:deleted", () => {
|
||||
const onDeleted = vi.fn();
|
||||
store.on("ai_session:deleted", onDeleted);
|
||||
|
||||
store.upsert(makeRow("sess-delete", { status: "awaiting_input" }));
|
||||
expect(store.get("sess-delete")).not.toBeNull();
|
||||
|
||||
store.delete("sess-delete");
|
||||
|
||||
expect(store.get("sess-delete")).toBeNull();
|
||||
expect(onDeleted).toHaveBeenCalledWith("sess-delete");
|
||||
});
|
||||
|
||||
it("recoverStaleSessions promotes recoverable rows and errors unrecoverable ones", () => {
|
||||
store.upsert(
|
||||
makeRow("recoverable", {
|
||||
status: "generating",
|
||||
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Continue?" }),
|
||||
}),
|
||||
);
|
||||
store.upsert(makeRow("unrecoverable", { status: "generating", currentQuestion: null }));
|
||||
|
||||
const changed = store.recoverStaleSessions();
|
||||
|
||||
expect(changed).toBe(2);
|
||||
expect(store.get("recoverable")?.status).toBe("awaiting_input");
|
||||
expect(store.get("unrecoverable")?.status).toBe("error");
|
||||
expect(store.get("unrecoverable")?.error).toContain("Session interrupted");
|
||||
});
|
||||
|
||||
it("cleanupOld removes only old terminal rows", () => {
|
||||
store.upsert(makeRow("old-complete", { status: "complete" }));
|
||||
store.upsert(makeRow("old-error", { status: "error" }));
|
||||
store.upsert(makeRow("old-generating", { status: "generating" }));
|
||||
store.upsert(makeRow("fresh-complete", { status: "complete" }));
|
||||
|
||||
const staleTs = new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString();
|
||||
const freshTs = new Date(Date.now() - 20 * 60 * 1000).toISOString();
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id IN (?, ?, ?)").run(
|
||||
staleTs,
|
||||
"old-complete",
|
||||
"old-error",
|
||||
"old-generating",
|
||||
);
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(freshTs, "fresh-complete");
|
||||
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("old-complete")).toBeNull();
|
||||
expect(store.get("old-error")).toBeNull();
|
||||
expect(store.get("old-generating")).not.toBeNull();
|
||||
expect(store.get("fresh-complete")).not.toBeNull();
|
||||
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("trims thinkingOutput to the last 50KB on upsert", () => {
|
||||
const maxBytes = 50 * 1024;
|
||||
const oversized = `${"x".repeat(1024)}${"y".repeat(maxBytes + 2000)}`;
|
||||
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 });
|
||||
|
||||
store.upsert(makeRow("sess-thinking-trim", { thinkingOutput: oversized }));
|
||||
const summary = store.cleanupStaleSessions();
|
||||
|
||||
const persisted = store.get("sess-thinking-trim");
|
||||
expect(persisted).not.toBeNull();
|
||||
expect(persisted!.thinkingOutput.length).toBe(maxBytes);
|
||||
expect(persisted!.thinkingOutput).toBe(oversized.slice(oversized.length - maxBytes));
|
||||
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("updateThinking debounces writes unless flush=true", () => {
|
||||
vi.useFakeTimers();
|
||||
store.upsert(makeRow("sess-thinking-debounce", { thinkingOutput: "initial" }));
|
||||
it("cleanupStaleSessions emits structured diagnostics with cleanup summary counts", () => {
|
||||
const diagnostics = captureDiagnostics();
|
||||
|
||||
store.updateThinking("sess-thinking-debounce", "deferred-write");
|
||||
expect(store.get("sess-thinking-debounce")?.thinkingOutput).toBe("initial");
|
||||
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 });
|
||||
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(store.get("sess-thinking-debounce")?.thinkingOutput).toBe("initial");
|
||||
const summary = store.cleanupStaleSessions();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(store.get("sess-thinking-debounce")?.thinkingOutput).toBe("deferred-write");
|
||||
expect(summary).toEqual({
|
||||
terminalDeleted: 2,
|
||||
orphanedDeleted: 1,
|
||||
totalDeleted: 3,
|
||||
});
|
||||
|
||||
store.updateThinking("sess-thinking-debounce", "queued-write");
|
||||
store.updateThinking("sess-thinking-debounce", "flushed-write", true);
|
||||
|
||||
expect(store.get("sess-thinking-debounce")?.thinkingOutput).toBe("flushed-write");
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(store.get("sess-thinking-debounce")?.thinkingOutput).toBe("flushed-write");
|
||||
});
|
||||
|
||||
it("emits ai_session:updated summary on upsert", () => {
|
||||
const onUpdated = vi.fn<[AiSessionSummary]>();
|
||||
store.on("ai_session:updated", onUpdated);
|
||||
|
||||
store.upsert(
|
||||
makeRow("sess-event", {
|
||||
status: "awaiting_input",
|
||||
title: "Session Event",
|
||||
projectId: "proj-events",
|
||||
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: "sess-event",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Session Event",
|
||||
projectId: "proj-events",
|
||||
lockedByTab: null,
|
||||
updatedAt: expect.any(String),
|
||||
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,718 +1,147 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Response, Request } from "express";
|
||||
import { createSSE, getActiveSSEConnections } from "../sse.js";
|
||||
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";
|
||||
|
||||
/** Minimal mock TaskStore — just needs EventEmitter behaviour. */
|
||||
function createMockStore() {
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(50);
|
||||
return emitter as any;
|
||||
class MockSocket extends EventEmitter {
|
||||
destroyed = false;
|
||||
setKeepAlive = vi.fn();
|
||||
destroy = vi.fn(() => {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.emit("close");
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a mock Express response with a writeable buffer. */
|
||||
function createMockResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
flushHeaders: vi.fn(),
|
||||
write: vi.fn((data: string) => {
|
||||
chunks.push(data);
|
||||
return true;
|
||||
}),
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
} as unknown as Response;
|
||||
return { res, chunks };
|
||||
}
|
||||
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");
|
||||
});
|
||||
|
||||
/** Create a mock Express request that can fire 'close'. */
|
||||
function createMockRequest() {
|
||||
const emitter = new EventEmitter();
|
||||
return emitter as unknown as Request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and parse the JSON data from an SSE message chunk.
|
||||
* SSE format: "event: event-name\ndata: {...json...}\n\n"
|
||||
* The regex needs to handle multiline JSON (e.g., with \n in strings).
|
||||
*/
|
||||
function extractSSEPayload(sseMsg: string): any {
|
||||
// Match everything between "data: " and the final "\n\n"
|
||||
const dataMatch = sseMsg.match(/data: ([\s\S]*?)\n\n/);
|
||||
if (!dataMatch) {
|
||||
return {};
|
||||
constructor(readonly socket: MockSocket) {
|
||||
super();
|
||||
}
|
||||
|
||||
setHeader(name: string, value: string): void {
|
||||
this.headers.set(name, value);
|
||||
}
|
||||
return JSON.parse(dataMatch[1]);
|
||||
}
|
||||
|
||||
/** Sample plugin installation for testing */
|
||||
function createMockPlugin(overrides: Partial<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
error?: string;
|
||||
settings: Record<string, unknown>;
|
||||
}> = {}) {
|
||||
function createMockStore(): TaskStore {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
name: "Test Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A test plugin",
|
||||
author: "Test Author",
|
||||
homepage: "https://example.com",
|
||||
path: "/path/to/plugin",
|
||||
enabled: overrides.enabled ?? true,
|
||||
state: overrides.state ?? "installed",
|
||||
settings: overrides.settings ?? {},
|
||||
settingsSchema: undefined,
|
||||
error: overrides.error,
|
||||
dependencies: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createMockMessage(overrides: Partial<{
|
||||
id: string;
|
||||
fromId: string;
|
||||
fromType: string;
|
||||
toId: string;
|
||||
toType: string;
|
||||
content: string;
|
||||
type: string;
|
||||
read: boolean;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? "msg-123",
|
||||
fromId: overrides.fromId ?? "dashboard",
|
||||
fromType: overrides.fromType ?? "user",
|
||||
toId: overrides.toId ?? "agent-1",
|
||||
toType: overrides.toType ?? "agent",
|
||||
content: overrides.content ?? "hello",
|
||||
type: overrides.type ?? "user-to-agent",
|
||||
read: overrides.read ?? false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
describe("createSSE", () => {
|
||||
let store: ReturnType<typeof createMockStore>;
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
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("writes initial connected comment", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
expect(chunks[0]).toBe(": connected\n\n");
|
||||
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("relays task:created events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
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");
|
||||
|
||||
const task = { id: "FN-001", description: "test" };
|
||||
store.emit("task:created", task);
|
||||
expect(first.res.end).not.toHaveBeenCalled();
|
||||
expect(second.res.end).not.toHaveBeenCalled();
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 2);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:created"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(sseMsg).toContain(JSON.stringify(task));
|
||||
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("relays task:moved events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
it("closes a client stream when keepalives stop", () => {
|
||||
vi.useFakeTimers();
|
||||
const baseline = getActiveSSEConnections();
|
||||
const connection = openSseConnection("client-four");
|
||||
|
||||
const data = { task: { id: "FN-001" }, from: "triage", to: "todo" };
|
||||
store.emit("task:moved", data);
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:moved"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(sseMsg).toContain(JSON.stringify(data));
|
||||
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("relays task:updated events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
it("extends a client stream while keepalives arrive", () => {
|
||||
vi.useFakeTimers();
|
||||
const baseline = getActiveSSEConnections();
|
||||
const connection = openSseConnection("client-five");
|
||||
|
||||
const task = { id: "FN-001", title: "Updated" };
|
||||
store.emit("task:updated", task);
|
||||
vi.advanceTimersByTime(4_000);
|
||||
expect(markSSEClientAlive("client-five")).toBe(1);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:updated"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
});
|
||||
vi.advanceTimersByTime(4_000);
|
||||
expect(connection.res.end).not.toHaveBeenCalled();
|
||||
expect(getActiveSSEConnections()).toBe(baseline + 1);
|
||||
|
||||
it("strips heavy task logs from task event payloads", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
store.emit("task:updated", {
|
||||
id: "FN-001",
|
||||
title: "Updated",
|
||||
log: [{ action: "very large log entry", timestamp: new Date().toISOString() }],
|
||||
});
|
||||
store.emit("task:moved", {
|
||||
task: {
|
||||
id: "FN-001",
|
||||
log: [{ action: "another large log entry", timestamp: new Date().toISOString() }],
|
||||
},
|
||||
from: "todo",
|
||||
to: "in-progress",
|
||||
});
|
||||
|
||||
const updatedMsg = chunks.find((c) => c.includes("task:updated"))!;
|
||||
const movedMsg = chunks.find((c) => c.includes("task:moved"))!;
|
||||
|
||||
expect(extractSSEPayload(updatedMsg).log).toEqual([]);
|
||||
expect(extractSSEPayload(movedMsg).task.log).toEqual([]);
|
||||
expect(updatedMsg).not.toContain("very large log entry");
|
||||
expect(movedMsg).not.toContain("another large log entry");
|
||||
});
|
||||
|
||||
it("relays task:deleted events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const task = { id: "FN-001" };
|
||||
store.emit("task:deleted", task);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:deleted"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
});
|
||||
|
||||
it("relays task:merged events as SSE messages", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const result = { task: { id: "FN-001" }, success: true };
|
||||
store.emit("task:merged", result);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("task:merged"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
});
|
||||
|
||||
it("cleans up listeners when client disconnects", () => {
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
const before = store.listenerCount("task:created");
|
||||
expect(before).toBe(1);
|
||||
|
||||
// Simulate client disconnect
|
||||
req.emit("close");
|
||||
|
||||
expect(store.listenerCount("task:created")).toBe(0);
|
||||
expect(store.listenerCount("task:moved")).toBe(0);
|
||||
expect(store.listenerCount("task:updated")).toBe(0);
|
||||
expect(store.listenerCount("task:deleted")).toBe(0);
|
||||
expect(store.listenerCount("task:merged")).toBe(0);
|
||||
});
|
||||
|
||||
it("stops writing when response is destroyed", () => {
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
// Mark response as destroyed
|
||||
(res as any).destroyed = true;
|
||||
|
||||
const initialCount = chunks.length;
|
||||
store.emit("task:created", { id: "FN-001" });
|
||||
|
||||
// No new chunks should be written
|
||||
expect(chunks.length).toBe(initialCount);
|
||||
});
|
||||
|
||||
it("stops writing and cleans up when res.write throws", () => {
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store)(req, res);
|
||||
|
||||
// Make write throw on next call
|
||||
(res.write as any).mockImplementation(() => {
|
||||
throw new Error("Socket closed");
|
||||
});
|
||||
|
||||
// This should not throw — the error is caught internally
|
||||
expect(() => store.emit("task:created", { id: "FN-001" })).not.toThrow();
|
||||
|
||||
// Listeners should be cleaned up
|
||||
expect(store.listenerCount("task:created")).toBe(0);
|
||||
});
|
||||
|
||||
it("relays mission:event events as SSE messages when missionStore is provided", () => {
|
||||
const missionStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, missionStore)(req, res);
|
||||
|
||||
const missionEvent = {
|
||||
id: "ME-001",
|
||||
missionId: "M-001",
|
||||
eventType: "mission_started",
|
||||
description: "Mission started",
|
||||
metadata: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
missionStore.emit("mission:event", missionEvent);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("mission:event"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(sseMsg).toContain(JSON.stringify(missionEvent));
|
||||
});
|
||||
|
||||
it("cleans up mission:event listener when client disconnects", () => {
|
||||
const missionStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store, missionStore)(req, res);
|
||||
|
||||
expect(missionStore.listenerCount("mission:event")).toBe(1);
|
||||
|
||||
req.emit("close");
|
||||
|
||||
expect(missionStore.listenerCount("mission:event")).toBe(0);
|
||||
});
|
||||
|
||||
it("tracks active connection count", () => {
|
||||
const req1 = createMockRequest();
|
||||
const { res: res1 } = createMockResponse();
|
||||
const req2 = createMockRequest();
|
||||
const { res: res2 } = createMockResponse();
|
||||
|
||||
const initial = getActiveSSEConnections();
|
||||
createSSE(store)(req1, res1);
|
||||
expect(getActiveSSEConnections()).toBe(initial + 1);
|
||||
createSSE(store)(req2, res2);
|
||||
expect(getActiveSSEConnections()).toBe(initial + 2);
|
||||
|
||||
req1.emit("close");
|
||||
expect(getActiveSSEConnections()).toBe(initial + 1);
|
||||
req2.emit("close");
|
||||
expect(getActiveSSEConnections()).toBe(initial);
|
||||
});
|
||||
|
||||
describe("message events", () => {
|
||||
it("relays message lifecycle events when messageStore is provided", () => {
|
||||
const messageStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, messageStore)(req, res);
|
||||
|
||||
const sentMessage = createMockMessage();
|
||||
messageStore.emit("message:sent", sentMessage);
|
||||
messageStore.emit("message:received", sentMessage);
|
||||
messageStore.emit("message:read", { ...sentMessage, read: true });
|
||||
messageStore.emit("message:deleted", sentMessage.id);
|
||||
|
||||
const sentEvent = chunks.find((c) => c.includes("event: message:sent"));
|
||||
const receivedEvent = chunks.find((c) => c.includes("event: message:received"));
|
||||
const readEvent = chunks.find((c) => c.includes("event: message:read"));
|
||||
const deletedEvent = chunks.find((c) => c.includes("event: message:deleted"));
|
||||
|
||||
expect(sentEvent).toBeDefined();
|
||||
expect(receivedEvent).toBeDefined();
|
||||
expect(readEvent).toBeDefined();
|
||||
expect(deletedEvent).toBeDefined();
|
||||
|
||||
expect(extractSSEPayload(sentEvent!).id).toBe(sentMessage.id);
|
||||
expect(extractSSEPayload(receivedEvent!).id).toBe(sentMessage.id);
|
||||
expect(extractSSEPayload(readEvent!).read).toBe(true);
|
||||
expect(extractSSEPayload(deletedEvent!).id).toBe(sentMessage.id);
|
||||
});
|
||||
|
||||
it("cleans up message listeners on disconnect", () => {
|
||||
const messageStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, messageStore)(req, res);
|
||||
|
||||
expect(messageStore.listenerCount("message:sent")).toBe(1);
|
||||
expect(messageStore.listenerCount("message:received")).toBe(1);
|
||||
expect(messageStore.listenerCount("message:read")).toBe(1);
|
||||
expect(messageStore.listenerCount("message:deleted")).toBe(1);
|
||||
|
||||
req.emit("close");
|
||||
|
||||
expect(messageStore.listenerCount("message:sent")).toBe(0);
|
||||
expect(messageStore.listenerCount("message:received")).toBe(0);
|
||||
expect(messageStore.listenerCount("message:read")).toBe(0);
|
||||
expect(messageStore.listenerCount("message:deleted")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Plugin Lifecycle Event Tests ─────────────────────────────────────────────
|
||||
|
||||
describe("chat store events", () => {
|
||||
it("relays chat:session:created events when chatStore is provided", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
const session = {
|
||||
id: "chat-abc123",
|
||||
agentId: "agent-001",
|
||||
title: "Test Session",
|
||||
status: "active",
|
||||
projectId: null,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
chatStore.emit("chat:session:created", session);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:session:created"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(extractSSEPayload(sseMsg!).id).toBe("chat-abc123");
|
||||
});
|
||||
|
||||
it("relays chat:session:updated events", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
const session = {
|
||||
id: "chat-abc123",
|
||||
agentId: "agent-001",
|
||||
title: "Updated Title",
|
||||
status: "active",
|
||||
projectId: null,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
chatStore.emit("chat:session:updated", session);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:session:updated"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.id).toBe("chat-abc123");
|
||||
expect(payload.title).toBe("Updated Title");
|
||||
});
|
||||
|
||||
it("relays chat:session:deleted events with session ID", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
chatStore.emit("chat:session:deleted", "chat-abc123");
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:session:deleted"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(extractSSEPayload(sseMsg!).id).toBe("chat-abc123");
|
||||
});
|
||||
|
||||
it("relays chat:message:added events with full message", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
const message = {
|
||||
id: "msg-xyz789",
|
||||
sessionId: "chat-abc123",
|
||||
role: "user",
|
||||
content: "Hello, how are you?",
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
chatStore.emit("chat:message:added", message);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:message:added"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.id).toBe("msg-xyz789");
|
||||
expect(payload.sessionId).toBe("chat-abc123");
|
||||
expect(payload.content).toBe("Hello, how are you?");
|
||||
});
|
||||
|
||||
it("relays chat:message:deleted events with message ID", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
chatStore.emit("chat:message:deleted", "msg-xyz789");
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:message:deleted"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(extractSSEPayload(sseMsg!).id).toBe("msg-xyz789");
|
||||
});
|
||||
|
||||
it("cleans up chat store listeners on disconnect", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
expect(chatStore.listenerCount("chat:session:created")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:session:updated")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:session:deleted")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:message:added")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:message:deleted")).toBe(1);
|
||||
|
||||
req.emit("close");
|
||||
|
||||
expect(chatStore.listenerCount("chat:session:created")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:session:updated")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:session:deleted")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:message:added")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:message:deleted")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Plugin Lifecycle Event Tests ─────────────────────────────────────────────
|
||||
|
||||
describe("plugin lifecycle events", () => {
|
||||
it("emits plugin:lifecycle event for plugin:registered (installing transition)", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
const plugin = createMockPlugin({ id: "my-plugin", state: "installed" });
|
||||
pluginStore.emit("plugin:registered", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(sseMsg).toContain("plugin:lifecycle");
|
||||
|
||||
// Parse the payload
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.pluginId).toBe("my-plugin");
|
||||
expect(payload.transition).toBe("installing");
|
||||
expect(payload.sourceEvent).toBe("plugin:registered");
|
||||
expect(payload.timestamp).toBeDefined();
|
||||
expect(payload.enabled).toBe(true);
|
||||
expect(payload.state).toBe("installed");
|
||||
expect(payload.version).toBe("1.0.0");
|
||||
expect(payload.settings).toEqual({});
|
||||
});
|
||||
|
||||
it("emits plugin:lifecycle event for plugin:enabled (enabled transition)", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
const plugin = createMockPlugin({ id: "enabled-plugin", enabled: true, state: "started" });
|
||||
pluginStore.emit("plugin:enabled", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.pluginId).toBe("enabled-plugin");
|
||||
expect(payload.transition).toBe("enabled");
|
||||
expect(payload.sourceEvent).toBe("plugin:enabled");
|
||||
expect(payload.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("emits plugin:lifecycle event for plugin:disabled (disabled transition)", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
const plugin = createMockPlugin({ id: "disabled-plugin", enabled: false, state: "stopped" });
|
||||
pluginStore.emit("plugin:disabled", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.pluginId).toBe("disabled-plugin");
|
||||
expect(payload.transition).toBe("disabled");
|
||||
expect(payload.sourceEvent).toBe("plugin:disabled");
|
||||
expect(payload.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("emits plugin:lifecycle event for plugin:stateChanged with error state (error transition)", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
const plugin = createMockPlugin({
|
||||
id: "error-plugin",
|
||||
state: "error",
|
||||
error: "Failed to load: missing dependency",
|
||||
});
|
||||
pluginStore.emit("plugin:stateChanged", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.pluginId).toBe("error-plugin");
|
||||
expect(payload.transition).toBe("error");
|
||||
expect(payload.sourceEvent).toBe("plugin:stateChanged");
|
||||
expect(payload.state).toBe("error");
|
||||
expect(payload.error).toBe("Failed to load: missing dependency");
|
||||
});
|
||||
|
||||
it("emits plugin:lifecycle event for plugin:unregistered (uninstalled transition)", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
const plugin = createMockPlugin({ id: "uninstalled-plugin" });
|
||||
pluginStore.emit("plugin:unregistered", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.pluginId).toBe("uninstalled-plugin");
|
||||
expect(payload.transition).toBe("uninstalled");
|
||||
expect(payload.sourceEvent).toBe("plugin:unregistered");
|
||||
});
|
||||
|
||||
it("emits plugin:lifecycle event for plugin:updated (settings-updated transition)", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
const plugin = createMockPlugin({
|
||||
id: "settings-plugin",
|
||||
settings: { apiKey: "secret123", debugMode: true },
|
||||
});
|
||||
pluginStore.emit("plugin:updated", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.pluginId).toBe("settings-plugin");
|
||||
expect(payload.transition).toBe("settings-updated");
|
||||
expect(payload.sourceEvent).toBe("plugin:updated");
|
||||
expect(payload.settings).toEqual({ apiKey: "secret123", debugMode: true });
|
||||
});
|
||||
|
||||
it("includes projectId in payload when options.projectId is provided", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore, { projectId: "proj_abc123" })(req, res);
|
||||
|
||||
const plugin = createMockPlugin({ id: "scoped-plugin" });
|
||||
pluginStore.emit("plugin:registered", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.projectId).toBe("proj_abc123");
|
||||
});
|
||||
|
||||
it("does not include projectId in payload for default streams", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
const plugin = createMockPlugin({ id: "default-plugin" });
|
||||
pluginStore.emit("plugin:registered", plugin);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.projectId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cleans up plugin listeners when client disconnects", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
// Verify listeners are attached
|
||||
expect(pluginStore.listenerCount("plugin:registered")).toBe(1);
|
||||
expect(pluginStore.listenerCount("plugin:unregistered")).toBe(1);
|
||||
expect(pluginStore.listenerCount("plugin:updated")).toBe(1);
|
||||
expect(pluginStore.listenerCount("plugin:enabled")).toBe(1);
|
||||
expect(pluginStore.listenerCount("plugin:disabled")).toBe(1);
|
||||
expect(pluginStore.listenerCount("plugin:stateChanged")).toBe(1);
|
||||
|
||||
req.emit("close");
|
||||
|
||||
// All plugin listeners should be removed
|
||||
expect(pluginStore.listenerCount("plugin:registered")).toBe(0);
|
||||
expect(pluginStore.listenerCount("plugin:unregistered")).toBe(0);
|
||||
expect(pluginStore.listenerCount("plugin:updated")).toBe(0);
|
||||
expect(pluginStore.listenerCount("plugin:enabled")).toBe(0);
|
||||
expect(pluginStore.listenerCount("plugin:disabled")).toBe(0);
|
||||
expect(pluginStore.listenerCount("plugin:stateChanged")).toBe(0);
|
||||
});
|
||||
|
||||
it("stops writing and cleans up plugin listeners when res.write throws", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
// Make write throw on next call
|
||||
(res.write as any).mockImplementation(() => {
|
||||
throw new Error("Socket closed");
|
||||
});
|
||||
|
||||
// Emit a plugin event — should not throw
|
||||
const plugin = createMockPlugin({ id: "cleanup-plugin" });
|
||||
expect(() => pluginStore.emit("plugin:registered", plugin)).not.toThrow();
|
||||
|
||||
// All plugin listeners should be removed
|
||||
expect(pluginStore.listenerCount("plugin:registered")).toBe(0);
|
||||
expect(pluginStore.listenerCount("plugin:enabled")).toBe(0);
|
||||
});
|
||||
|
||||
it("handles multiple plugin lifecycle events in sequence", () => {
|
||||
const pluginStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, pluginStore)(req, res);
|
||||
|
||||
// Simulate a plugin lifecycle: install → enable → update settings
|
||||
const plugin1 = createMockPlugin({ id: "multi-plugin", state: "installed" });
|
||||
pluginStore.emit("plugin:registered", plugin1);
|
||||
|
||||
const plugin2 = createMockPlugin({ id: "multi-plugin", enabled: true, state: "started" });
|
||||
pluginStore.emit("plugin:enabled", plugin2);
|
||||
|
||||
const plugin3 = createMockPlugin({ id: "multi-plugin", settings: { key: "value" } });
|
||||
pluginStore.emit("plugin:updated", plugin3);
|
||||
|
||||
const lifecycleEvents = chunks.filter((c) => c.includes("event: plugin:lifecycle"));
|
||||
expect(lifecycleEvents.length).toBe(3);
|
||||
|
||||
const payload1 = extractSSEPayload(lifecycleEvents[0]);
|
||||
expect(payload1.transition).toBe("installing");
|
||||
|
||||
const payload2 = extractSSEPayload(lifecycleEvents[1]);
|
||||
expect(payload2.transition).toBe("enabled");
|
||||
|
||||
const payload3 = extractSSEPayload(lifecycleEvents[2]);
|
||||
expect(payload3.transition).toBe("settings-updated");
|
||||
});
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(connection.res.end).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveSSEConnections()).toBe(baseline);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
471
packages/engine/src/ipc/__tests__/ipc-host.test.ts
Normal file
471
packages/engine/src/ipc/__tests__/ipc-host.test.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Unit tests for IpcHost — the parent-side IPC handler that sends commands
|
||||
* to a child process worker and correlates responses.
|
||||
*
|
||||
* Coverage:
|
||||
* - Constructor: listener setup, options, initial state
|
||||
* - sendCommand: serialization, response correlation (OK/ERROR/PONG), timeout, disconnection
|
||||
* - ping: convenience wrapper for sendCommand("PING")
|
||||
* - Event forwarding: worker events emitted on IpcHost
|
||||
* - Malformed/unknown messages: silently ignored
|
||||
* - Disconnection cascade: child error/exit/disconnect → pending commands rejected
|
||||
* - disconnect(): explicit cleanup and listener removal
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { IpcHost } from "../ipc-host.js";
|
||||
import { OK, ERROR, PONG, TASK_CREATED } from "../ipc-protocol.js";
|
||||
|
||||
// ── Mock logger to suppress console output ──────────────────────────────
|
||||
vi.mock("../../logger.js", () => ({
|
||||
ipcLog: {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Mock ChildProcess factory ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcess that is an EventEmitter with the required
|
||||
* properties for IpcHost: `send`, `connected`, `disconnect`.
|
||||
*/
|
||||
function createMockChildProcess(
|
||||
overrides: {
|
||||
connected?: boolean;
|
||||
send?: ((...args: any[]) => any) | undefined;
|
||||
} = {}
|
||||
): ChildProcess {
|
||||
const emitter = new EventEmitter();
|
||||
const mock = emitter as unknown as ChildProcess & EventEmitter;
|
||||
|
||||
// Default: connected with a working send
|
||||
Object.defineProperty(mock, "connected", {
|
||||
get: () => overrides.connected ?? true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
if (overrides.send === undefined && !("send" in overrides)) {
|
||||
// Default: working send that invokes callback with no error
|
||||
(mock as any).send = vi.fn((...args: any[]) => {
|
||||
const callback = args.find((a: unknown) => typeof a === "function");
|
||||
if (callback) callback(null);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
(mock as any).send = overrides.send;
|
||||
}
|
||||
|
||||
(mock as any).disconnect = vi.fn();
|
||||
(mock as any).kill = vi.fn();
|
||||
(mock as any).killed = false;
|
||||
(mock as any).pid = 12345;
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("IpcHost", () => {
|
||||
let child: ChildProcess & EventEmitter;
|
||||
let host: IpcHost;
|
||||
|
||||
beforeEach(() => {
|
||||
child = createMockChildProcess() as ChildProcess & EventEmitter;
|
||||
host = new IpcHost(child);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
host.removeAllListeners();
|
||||
});
|
||||
|
||||
// ── Constructor & initial state ──────────────────────────────────────
|
||||
|
||||
describe("constructor and initial state", () => {
|
||||
it("registers listeners on child process for message, error, exit, disconnect events", () => {
|
||||
// EventEmitter.listenerCount shows listeners were added
|
||||
expect(child.listenerCount("message")).toBeGreaterThanOrEqual(1);
|
||||
expect(child.listenerCount("error")).toBeGreaterThanOrEqual(1);
|
||||
expect(child.listenerCount("exit")).toBeGreaterThanOrEqual(1);
|
||||
expect(child.listenerCount("disconnect")).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("isConnected() returns true when child is connected and not disconnected", () => {
|
||||
expect(host.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it("isConnected() returns false after disconnection", () => {
|
||||
child.emit("disconnect");
|
||||
expect(host.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it("getChildProcess() returns the child process instance", () => {
|
||||
expect(host.getChildProcess()).toBe(child);
|
||||
});
|
||||
|
||||
it("getPendingCommandCount() returns 0 initially", () => {
|
||||
expect(host.getPendingCommandCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts custom commandTimeoutMs option", () => {
|
||||
// We verify this indirectly in the timeout test in Step 2
|
||||
const customHost = new IpcHost(child, { commandTimeoutMs: 500 });
|
||||
expect(customHost).toBeInstanceOf(IpcHost);
|
||||
customHost.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// ── sendCommand and response correlation ────────────────────────────
|
||||
|
||||
describe("sendCommand", () => {
|
||||
it("sends a valid IpcMessage via childProcess.send() with correct type, unique id, and payload", async () => {
|
||||
const sendFn = child.send as ReturnType<typeof vi.fn>;
|
||||
const commandPromise = host.sendCommand("GET_STATUS", { foo: "bar" });
|
||||
|
||||
// Extract the message from the mock send call
|
||||
expect(sendFn).toHaveBeenCalledTimes(1);
|
||||
const sentMessage = sendFn.mock.calls[0][0];
|
||||
expect(sentMessage.type).toBe("GET_STATUS");
|
||||
expect(typeof sentMessage.id).toBe("string");
|
||||
expect(sentMessage.id.length).toBeGreaterThan(0);
|
||||
expect(sentMessage.payload).toEqual({ foo: "bar" });
|
||||
|
||||
// Respond to resolve the promise
|
||||
child.emit("message", { type: OK, id: sentMessage.id, payload: { data: "result" } });
|
||||
await expect(commandPromise).resolves.toBe("result");
|
||||
});
|
||||
|
||||
it("resolves with data when child responds with OK matching the correlation ID", async () => {
|
||||
const sendFn = child.send as ReturnType<typeof vi.fn>;
|
||||
const promise = host.sendCommand("GET_METRICS", {});
|
||||
|
||||
const sentId = sendFn.mock.calls[0][0].id;
|
||||
child.emit("message", { type: OK, id: sentId, payload: { data: { tasks: 5 } } });
|
||||
|
||||
await expect(promise).resolves.toEqual({ tasks: 5 });
|
||||
});
|
||||
|
||||
it("rejects with an Error (including message and code) when child responds with ERROR", async () => {
|
||||
const sendFn = child.send as ReturnType<typeof vi.fn>;
|
||||
const promise = host.sendCommand("GET_STATUS", {});
|
||||
|
||||
const sentId = sendFn.mock.calls[0][0].id;
|
||||
child.emit("message", {
|
||||
type: ERROR,
|
||||
id: sentId,
|
||||
payload: { message: "Something went wrong", code: "HANDLER_ERROR" },
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow("Something went wrong");
|
||||
try {
|
||||
await promise;
|
||||
} catch (err: any) {
|
||||
expect(err.code).toBe("HANDLER_ERROR");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves with pong payload when child responds with PONG", async () => {
|
||||
const sendFn = child.send as ReturnType<typeof vi.fn>;
|
||||
const promise = host.sendCommand("PING", {});
|
||||
|
||||
const sentId = sendFn.mock.calls[0][0].id;
|
||||
child.emit("message", {
|
||||
type: PONG,
|
||||
id: sentId,
|
||||
payload: { timestamp: "2026-04-01T00:00:00.000Z" },
|
||||
});
|
||||
|
||||
await expect(promise).resolves.toEqual({ timestamp: "2026-04-01T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("rejects after timeout using fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const promise = host.sendCommand("GET_STATUS", {}, 1000);
|
||||
|
||||
// Advance past the timeout
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
await expect(promise).rejects.toThrow("timed out after 1000ms");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses custom commandTimeoutMs when no per-call override provided", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const shortHost = new IpcHost(child, { commandTimeoutMs: 200 });
|
||||
const promise = shortHost.sendCommand("GET_STATUS", {});
|
||||
|
||||
vi.advanceTimersByTime(201);
|
||||
|
||||
await expect(promise).rejects.toThrow("timed out after 200ms");
|
||||
shortHost.removeAllListeners();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears pending command on successful response (getPendingCommandCount returns 0)", async () => {
|
||||
const sendFn = child.send as ReturnType<typeof vi.fn>;
|
||||
const promise = host.sendCommand("GET_STATUS", {});
|
||||
|
||||
expect(host.getPendingCommandCount()).toBe(1);
|
||||
|
||||
const sentId = sendFn.mock.calls[0][0].id;
|
||||
child.emit("message", { type: OK, id: sentId, payload: { data: null } });
|
||||
await promise;
|
||||
|
||||
expect(host.getPendingCommandCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects immediately when IPC is already disconnected", async () => {
|
||||
child.emit("disconnect");
|
||||
|
||||
await expect(host.sendCommand("GET_STATUS", {})).rejects.toThrow(
|
||||
"Cannot send command: IPC channel disconnected"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when childProcess.send is undefined (no IPC channel)", async () => {
|
||||
const noSendChild = createMockChildProcess({ send: undefined }) as ChildProcess & EventEmitter;
|
||||
const noSendHost = new IpcHost(noSendChild);
|
||||
|
||||
await expect(noSendHost.sendCommand("GET_STATUS", {})).rejects.toThrow(
|
||||
"Child process does not have IPC channel"
|
||||
);
|
||||
noSendHost.removeAllListeners();
|
||||
});
|
||||
|
||||
it("rejects when childProcess.send callback returns an error", async () => {
|
||||
const errChild = createMockChildProcess({
|
||||
send: vi.fn((...args: any[]) => {
|
||||
// Find the callback argument (last function arg)
|
||||
const callback = args.find((a: unknown) => typeof a === "function");
|
||||
if (callback) callback(new Error("Send failed"));
|
||||
return false;
|
||||
}) as any,
|
||||
}) as ChildProcess & EventEmitter;
|
||||
const errHost = new IpcHost(errChild);
|
||||
|
||||
await expect(errHost.sendCommand("GET_STATUS", {})).rejects.toThrow("Failed to send command: Send failed");
|
||||
errHost.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// ── ping ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ping", () => {
|
||||
it("calls sendCommand('PING', {}, 5000) and resolves with timestamp", async () => {
|
||||
const sendFn = child.send as ReturnType<typeof vi.fn>;
|
||||
const promise = host.ping();
|
||||
|
||||
const sentMessage = sendFn.mock.calls[0][0];
|
||||
expect(sentMessage.type).toBe("PING");
|
||||
|
||||
child.emit("message", {
|
||||
type: PONG,
|
||||
id: sentMessage.id,
|
||||
payload: { timestamp: "2026-04-01T12:00:00.000Z" },
|
||||
});
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ timestamp: "2026-04-01T12:00:00.000Z" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Event forwarding ────────────────────────────────────────────────
|
||||
|
||||
describe("event forwarding", () => {
|
||||
it("incoming event messages are emitted on IpcHost with the event type and payload", () => {
|
||||
const handler = vi.fn();
|
||||
host.on(TASK_CREATED, handler);
|
||||
|
||||
const payload = { task: { id: "KB-001", title: "Test" } };
|
||||
child.emit("message", {
|
||||
type: TASK_CREATED,
|
||||
id: "evt-1",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it('generic "message" event is also emitted for every incoming event message', () => {
|
||||
const handler = vi.fn();
|
||||
host.on("message", handler);
|
||||
|
||||
const message = { type: TASK_CREATED, id: "evt-2", payload: { task: {} } };
|
||||
child.emit("message", message);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(message);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Malformed messages ──────────────────────────────────────────────
|
||||
|
||||
describe("malformed messages", () => {
|
||||
it("silently ignores message missing type", () => {
|
||||
const handler = vi.fn();
|
||||
host.on("message", handler);
|
||||
|
||||
// Missing type
|
||||
child.emit("message", { id: "x", payload: {} });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silently ignores message missing id", () => {
|
||||
const handler = vi.fn();
|
||||
host.on("message", handler);
|
||||
|
||||
child.emit("message", { type: "SOME_TYPE", payload: {} });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silently ignores message missing payload", () => {
|
||||
const handler = vi.fn();
|
||||
host.on("message", handler);
|
||||
|
||||
child.emit("message", { type: "SOME_TYPE", id: "x" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silently ignores non-object messages", () => {
|
||||
const handler = vi.fn();
|
||||
host.on("message", handler);
|
||||
|
||||
child.emit("message", "not an object");
|
||||
child.emit("message", null);
|
||||
child.emit("message", 42);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores response for unknown correlation ID without crashing", () => {
|
||||
// Should not throw
|
||||
child.emit("message", {
|
||||
type: OK,
|
||||
id: "unknown-correlation-id",
|
||||
payload: { data: "phantom" },
|
||||
});
|
||||
|
||||
expect(host.getPendingCommandCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Disconnection cascade ───────────────────────────────────────────
|
||||
|
||||
describe("disconnection", () => {
|
||||
it("child error event rejects all pending commands with 'IPC disconnected' error and emits 'disconnect'", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const disconnectHandler = vi.fn();
|
||||
host.on("disconnect", disconnectHandler);
|
||||
|
||||
const promise = host.sendCommand("GET_STATUS", {});
|
||||
expect(host.getPendingCommandCount()).toBe(1);
|
||||
|
||||
child.emit("error", new Error("child crash"));
|
||||
|
||||
await expect(promise).rejects.toThrow("IPC disconnected");
|
||||
expect(host.getPendingCommandCount()).toBe(0);
|
||||
expect(disconnectHandler).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("child exit event (with code) triggers disconnection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const disconnectHandler = vi.fn();
|
||||
host.on("disconnect", disconnectHandler);
|
||||
|
||||
const promise = host.sendCommand("GET_STATUS", {});
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(promise).rejects.toThrow("IPC disconnected");
|
||||
expect(disconnectHandler).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("child exit event (with signal) triggers disconnection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const disconnectHandler = vi.fn();
|
||||
host.on("disconnect", disconnectHandler);
|
||||
|
||||
const promise = host.sendCommand("GET_STATUS", {});
|
||||
|
||||
child.emit("exit", null, "SIGTERM");
|
||||
|
||||
await expect(promise).rejects.toThrow("IPC disconnected");
|
||||
expect(disconnectHandler).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("child disconnect event triggers disconnection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const disconnectHandler = vi.fn();
|
||||
host.on("disconnect", disconnectHandler);
|
||||
|
||||
const promise = host.sendCommand("GET_STATUS", {});
|
||||
|
||||
child.emit("disconnect");
|
||||
|
||||
await expect(promise).rejects.toThrow("IPC disconnected");
|
||||
expect(disconnectHandler).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("double disconnection is idempotent (no re-reject or double-emit)", () => {
|
||||
const disconnectHandler = vi.fn();
|
||||
host.on("disconnect", disconnectHandler);
|
||||
|
||||
child.emit("disconnect");
|
||||
child.emit("disconnect");
|
||||
|
||||
expect(disconnectHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disconnect() method rejects pending commands, calls childProcess.disconnect(), removes all listeners", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const promise = host.sendCommand("GET_STATUS", {});
|
||||
expect(host.getPendingCommandCount()).toBe(1);
|
||||
|
||||
host.disconnect();
|
||||
|
||||
await expect(promise).rejects.toThrow("IPC disconnected");
|
||||
expect(host.getPendingCommandCount()).toBe(0);
|
||||
expect((child as any).disconnect).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("disconnect() skips childProcess.disconnect() when already disconnected", () => {
|
||||
// Simulate child already disconnected
|
||||
Object.defineProperty(child, "connected", {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
host.disconnect();
|
||||
// disconnect() should not call child.disconnect() since connected is false
|
||||
expect((child as any).disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
175
packages/engine/src/ipc/__tests__/ipc-protocol.test.ts
Normal file
175
packages/engine/src/ipc/__tests__/ipc-protocol.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
START_RUNTIME,
|
||||
STOP_RUNTIME,
|
||||
GET_STATUS,
|
||||
GET_METRICS,
|
||||
GET_TASK_STORE,
|
||||
GET_SCHEDULER,
|
||||
PING,
|
||||
OK,
|
||||
ERROR,
|
||||
PONG,
|
||||
TASK_CREATED,
|
||||
TASK_MOVED,
|
||||
TASK_UPDATED,
|
||||
ERROR_EVENT,
|
||||
HEALTH_CHANGED,
|
||||
isIpcCommand,
|
||||
isIpcResponse,
|
||||
isIpcEvent,
|
||||
createCommand,
|
||||
createResponse,
|
||||
createEvent,
|
||||
generateCorrelationId,
|
||||
} from "../ipc-protocol.js";
|
||||
|
||||
describe("IPC Protocol", () => {
|
||||
describe("constants", () => {
|
||||
it("should export all command types", () => {
|
||||
expect(START_RUNTIME).toBe("START_RUNTIME");
|
||||
expect(STOP_RUNTIME).toBe("STOP_RUNTIME");
|
||||
expect(GET_STATUS).toBe("GET_STATUS");
|
||||
expect(GET_METRICS).toBe("GET_METRICS");
|
||||
expect(GET_TASK_STORE).toBe("GET_TASK_STORE");
|
||||
expect(GET_SCHEDULER).toBe("GET_SCHEDULER");
|
||||
expect(PING).toBe("PING");
|
||||
});
|
||||
|
||||
it("should export all response types", () => {
|
||||
expect(OK).toBe("OK");
|
||||
expect(ERROR).toBe("ERROR");
|
||||
expect(PONG).toBe("PONG");
|
||||
});
|
||||
|
||||
it("should export all event types", () => {
|
||||
expect(TASK_CREATED).toBe("TASK_CREATED");
|
||||
expect(TASK_MOVED).toBe("TASK_MOVED");
|
||||
expect(TASK_UPDATED).toBe("TASK_UPDATED");
|
||||
expect(ERROR_EVENT).toBe("ERROR_EVENT");
|
||||
expect(HEALTH_CHANGED).toBe("HEALTH_CHANGED");
|
||||
});
|
||||
|
||||
it("should have distinct ERROR and ERROR_EVENT values", () => {
|
||||
expect(ERROR).toBe("ERROR");
|
||||
expect(ERROR_EVENT).toBe("ERROR_EVENT");
|
||||
expect(ERROR).not.toBe(ERROR_EVENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIpcCommand", () => {
|
||||
it("should return true for command types", () => {
|
||||
expect(isIpcCommand({ type: START_RUNTIME, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcCommand({ type: STOP_RUNTIME, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcCommand({ type: GET_STATUS, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcCommand({ type: PING, id: "1", payload: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for response types", () => {
|
||||
expect(isIpcCommand({ type: OK, id: "1", payload: {} })).toBe(false);
|
||||
expect(isIpcCommand({ type: ERROR, id: "1", payload: {} })).toBe(false);
|
||||
expect(isIpcCommand({ type: PONG, id: "1", payload: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for event types", () => {
|
||||
expect(isIpcCommand({ type: TASK_CREATED, id: "1", payload: {} })).toBe(false);
|
||||
expect(isIpcCommand({ type: HEALTH_CHANGED, id: "1", payload: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIpcResponse", () => {
|
||||
it("should return true for response types", () => {
|
||||
expect(isIpcResponse({ type: OK, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcResponse({ type: ERROR, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcResponse({ type: PONG, id: "1", payload: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for command types", () => {
|
||||
expect(isIpcResponse({ type: START_RUNTIME, id: "1", payload: {} })).toBe(false);
|
||||
expect(isIpcResponse({ type: PING, id: "1", payload: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for event types", () => {
|
||||
expect(isIpcResponse({ type: TASK_CREATED, id: "1", payload: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIpcEvent", () => {
|
||||
it("should return true for event types", () => {
|
||||
expect(isIpcEvent({ type: TASK_CREATED, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcEvent({ type: TASK_MOVED, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcEvent({ type: TASK_UPDATED, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcEvent({ type: ERROR_EVENT, id: "1", payload: {} })).toBe(true);
|
||||
expect(isIpcEvent({ type: HEALTH_CHANGED, id: "1", payload: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for command types", () => {
|
||||
expect(isIpcEvent({ type: START_RUNTIME, id: "1", payload: {} })).toBe(false);
|
||||
expect(isIpcEvent({ type: PING, id: "1", payload: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for response types", () => {
|
||||
expect(isIpcEvent({ type: OK, id: "1", payload: {} })).toBe(false);
|
||||
expect(isIpcEvent({ type: ERROR, id: "1", payload: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCommand", () => {
|
||||
it("should create a command message", () => {
|
||||
const payload = { config: { projectId: "test" } };
|
||||
const message = createCommand(START_RUNTIME, "cmd-1", payload);
|
||||
|
||||
expect(message).toEqual({
|
||||
type: START_RUNTIME,
|
||||
id: "cmd-1",
|
||||
payload,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponse", () => {
|
||||
it("should create a response message", () => {
|
||||
const payload = { data: { status: "active" } };
|
||||
const message = createResponse(OK, "cmd-1", payload);
|
||||
|
||||
expect(message).toEqual({
|
||||
type: OK,
|
||||
id: "cmd-1",
|
||||
payload,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEvent", () => {
|
||||
it("should create an event message", () => {
|
||||
const payload = { task: { id: "KB-001" } };
|
||||
const message = createEvent(TASK_CREATED, "evt-1", payload);
|
||||
|
||||
expect(message).toEqual({
|
||||
type: TASK_CREATED,
|
||||
id: "evt-1",
|
||||
payload,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateCorrelationId", () => {
|
||||
it("should generate unique IDs", () => {
|
||||
const id1 = generateCorrelationId();
|
||||
const id2 = generateCorrelationId();
|
||||
|
||||
expect(id1).toBeDefined();
|
||||
expect(id2).toBeDefined();
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it("should generate string IDs with timestamp and random parts", () => {
|
||||
const id = generateCorrelationId();
|
||||
const parts = id.split("-");
|
||||
|
||||
expect(parts.length).toBeGreaterThanOrEqual(2);
|
||||
// First part should be a timestamp (number)
|
||||
expect(Number.parseInt(parts[0], 10)).not.toBeNaN();
|
||||
});
|
||||
});
|
||||
});
|
||||
510
packages/engine/src/ipc/__tests__/ipc-worker.test.ts
Normal file
510
packages/engine/src/ipc/__tests__/ipc-worker.test.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Unit tests for IpcWorker — the child-process-side IPC handler that receives
|
||||
* commands from a host, dispatches to registered handlers, and sends responses/events.
|
||||
*
|
||||
* Coverage:
|
||||
* - Constructor: process.send validation, listener registration, initial state
|
||||
* - PING auto-response (no handler needed)
|
||||
* - onCommand / offCommand: handler registration and dispatch
|
||||
* - Command execution: OK response, ERROR response (Error and non-Error), NO_HANDLER, UNKNOWN_COMMAND, MALFORMED_MESSAGE
|
||||
* - sendEvent / sendErrorEvent: event message construction
|
||||
* - sendResponse: response message construction
|
||||
* - shutdown: idempotent, suppresses further sends, emits event
|
||||
* - disconnect event forwarding
|
||||
* - Edge cases: process.send undefined after construction, graceful fallback
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { PING, PONG, OK, ERROR, TASK_CREATED, ERROR_EVENT } from "../ipc-protocol.js";
|
||||
import { ipcLog } from "../../logger.js";
|
||||
|
||||
// ── Mock logger to suppress console output ──────────────────────────────
|
||||
vi.mock("../../logger.js", () => ({
|
||||
ipcLog: {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Process mock utilities ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* We need to mock process.send and intercept process.on("message") handlers
|
||||
* without breaking the real process. Strategy:
|
||||
* - Set process.send to a vi.fn() before creating IpcWorker
|
||||
* - Track message handlers registered via process.on("message")
|
||||
* - Simulate incoming messages by calling those handlers directly
|
||||
*/
|
||||
|
||||
// Store the original process.send to restore after tests
|
||||
const originalProcessSend = process.send;
|
||||
|
||||
// Track registered message/disconnect handlers so we can invoke them
|
||||
let messageHandlers: Array<(msg: unknown) => void> = [];
|
||||
let disconnectHandlers: Array<() => void> = [];
|
||||
|
||||
// Spies for process.on and process.removeAllListeners
|
||||
let processOnSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function setupProcessMocks() {
|
||||
// Set up process.send as a mock function
|
||||
process.send = vi.fn((_msg: unknown, _handle?: unknown, _options?: unknown, callback?: (err: Error | null) => void) => {
|
||||
if (typeof callback === "function") callback(null);
|
||||
return true;
|
||||
});
|
||||
|
||||
messageHandlers = [];
|
||||
disconnectHandlers = [];
|
||||
|
||||
// Intercept process.on to capture message/disconnect handlers
|
||||
const originalProcessOn = process.on.bind(process);
|
||||
processOnSpy = vi.fn((event: string, handler: (...args: any[]) => void) => {
|
||||
if (event === "message") {
|
||||
messageHandlers.push(handler);
|
||||
} else if (event === "disconnect") {
|
||||
disconnectHandlers.push(handler);
|
||||
}
|
||||
// Don't register signal handlers on real process during tests
|
||||
if (event === "SIGTERM" || event === "SIGINT" || event === "uncaughtException" || event === "unhandledRejection") {
|
||||
return process;
|
||||
}
|
||||
return originalProcessOn(event, handler);
|
||||
});
|
||||
process.on = processOnSpy as any;
|
||||
}
|
||||
|
||||
function teardownProcessMocks() {
|
||||
// Restore process.send
|
||||
if (originalProcessSend === undefined) {
|
||||
delete (process as any).send;
|
||||
} else {
|
||||
process.send = originalProcessSend;
|
||||
}
|
||||
|
||||
// Remove any listeners we added during the test
|
||||
for (const handler of messageHandlers) {
|
||||
process.removeListener("message", handler);
|
||||
}
|
||||
for (const handler of disconnectHandlers) {
|
||||
process.removeListener("disconnect", handler);
|
||||
}
|
||||
messageHandlers = [];
|
||||
disconnectHandlers = [];
|
||||
}
|
||||
|
||||
/** Simulate an incoming message from the host */
|
||||
function simulateMessage(msg: unknown) {
|
||||
for (const handler of messageHandlers) {
|
||||
handler(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulate a disconnect event */
|
||||
function simulateDisconnect() {
|
||||
for (const handler of disconnectHandlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("IpcWorker", () => {
|
||||
// We need to dynamically import IpcWorker after mocks are set up
|
||||
let IpcWorker: typeof import("../ipc-worker.js").IpcWorker;
|
||||
|
||||
beforeEach(async () => {
|
||||
setupProcessMocks();
|
||||
// Dynamic import to get fresh module (the mock setup needs to be in place)
|
||||
const mod = await import("../ipc-worker.js");
|
||||
IpcWorker = mod.IpcWorker;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardownProcessMocks();
|
||||
});
|
||||
|
||||
// ── Constructor & initial state ──────────────────────────────────────
|
||||
|
||||
describe("constructor and initial state", () => {
|
||||
it("throws when process.send is undefined", async () => {
|
||||
teardownProcessMocks(); // Remove mock
|
||||
// Ensure process.send is undefined
|
||||
delete (process as any).send;
|
||||
|
||||
expect(() => new IpcWorker()).toThrow(
|
||||
"IpcWorker can only be instantiated in a forked child process"
|
||||
);
|
||||
|
||||
// Re-set up for afterEach
|
||||
setupProcessMocks();
|
||||
const mod = await import("../ipc-worker.js");
|
||||
IpcWorker = mod.IpcWorker;
|
||||
});
|
||||
|
||||
it("registers listeners on process for message and disconnect events", () => {
|
||||
const worker = new IpcWorker();
|
||||
expect(messageHandlers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(disconnectHandlers.length).toBeGreaterThanOrEqual(1);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("getHandlerCount() returns 0 initially", () => {
|
||||
const worker = new IpcWorker();
|
||||
expect(worker.getHandlerCount()).toBe(0);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("isShuttingDown() returns false initially", () => {
|
||||
const worker = new IpcWorker();
|
||||
expect(worker.isShuttingDown()).toBe(false);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: creates a worker and returns it along with its dedicated message handler.
|
||||
* Also clears the process.send mock so each test starts fresh.
|
||||
*/
|
||||
function createWorker() {
|
||||
const msgCountBefore = messageHandlers.length;
|
||||
const discCountBefore = disconnectHandlers.length;
|
||||
const worker = new IpcWorker();
|
||||
const sendFn = process.send as ReturnType<typeof vi.fn>;
|
||||
sendFn.mockClear();
|
||||
|
||||
// The worker's handlers are the ones added after the counts
|
||||
const workerMsgHandler = messageHandlers[messageHandlers.length - 1];
|
||||
const workerDiscHandler = disconnectHandlers[disconnectHandlers.length - 1];
|
||||
|
||||
/** Send a message to this worker's handler */
|
||||
const sendMessage = (msg: unknown) => workerMsgHandler(msg);
|
||||
|
||||
/** Simulate disconnect for this specific worker */
|
||||
const triggerDisconnect = () => workerDiscHandler?.();
|
||||
|
||||
/** Get all messages sent to parent via process.send since last clear */
|
||||
const getSentMessages = () => sendFn.mock.calls.map((call: any[]) => call[0]);
|
||||
|
||||
/** Find the first sent message matching a type */
|
||||
const findSent = (type: string) =>
|
||||
sendFn.mock.calls.find((call: any[]) => call[0]?.type === type)?.[0];
|
||||
|
||||
return { worker, sendMessage, triggerDisconnect, sendFn, getSentMessages, findSent };
|
||||
}
|
||||
|
||||
// ── PING auto-response ──────────────────────────────────────────────
|
||||
|
||||
describe("PING handling", () => {
|
||||
it("incoming PING message automatically responds with PONG containing a timestamp", async () => {
|
||||
const { worker, sendMessage, findSent } = createWorker();
|
||||
|
||||
sendMessage({ type: PING, id: "ping-1", payload: {} });
|
||||
|
||||
// handleMessage is async, give it a tick
|
||||
await vi.waitFor(() => {
|
||||
expect(findSent(PONG)).toBeDefined();
|
||||
});
|
||||
|
||||
const response = findSent(PONG);
|
||||
expect(response.type).toBe(PONG);
|
||||
expect(response.id).toBe("ping-1");
|
||||
expect(typeof response.payload.timestamp).toBe("string");
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Command handling ────────────────────────────────────────────────
|
||||
|
||||
describe("command handling", () => {
|
||||
it("onCommand() registers a handler: getHandlerCount() increments", () => {
|
||||
const { worker } = createWorker();
|
||||
expect(worker.getHandlerCount()).toBe(0);
|
||||
|
||||
worker.onCommand("START_RUNTIME", async () => ({ success: true }));
|
||||
expect(worker.getHandlerCount()).toBe(1);
|
||||
|
||||
worker.onCommand("STOP_RUNTIME", async () => {});
|
||||
expect(worker.getHandlerCount()).toBe(2);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("offCommand() removes a handler: getHandlerCount() decrements", () => {
|
||||
const { worker } = createWorker();
|
||||
worker.onCommand("START_RUNTIME", async () => {});
|
||||
expect(worker.getHandlerCount()).toBe(1);
|
||||
|
||||
worker.offCommand("START_RUNTIME");
|
||||
expect(worker.getHandlerCount()).toBe(0);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("receiving a registered command invokes the handler with the message payload", async () => {
|
||||
const handler = vi.fn().mockResolvedValue("ok");
|
||||
const { worker, sendMessage } = createWorker();
|
||||
worker.onCommand("GET_STATUS", handler);
|
||||
|
||||
const payload = { detail: "test" };
|
||||
sendMessage({ type: "GET_STATUS", id: "cmd-1", payload });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handler).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("handler returning a value sends OK response with { data: returnValue }", async () => {
|
||||
const { worker, sendMessage, findSent } = createWorker();
|
||||
worker.onCommand("GET_METRICS", async () => ({ tasks: 10 }));
|
||||
|
||||
sendMessage({ type: "GET_METRICS", id: "cmd-2", payload: {} });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findSent(OK)).toBeDefined();
|
||||
});
|
||||
|
||||
const response = findSent(OK);
|
||||
expect(response.type).toBe(OK);
|
||||
expect(response.id).toBe("cmd-2");
|
||||
expect(response.payload).toEqual({ data: { tasks: 10 } });
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("handler throwing an error sends ERROR response with { message, code: 'HANDLER_ERROR' }", async () => {
|
||||
const { worker, sendMessage, findSent } = createWorker();
|
||||
worker.onCommand("GET_STATUS", async () => {
|
||||
throw new Error("Something broke");
|
||||
});
|
||||
|
||||
sendMessage({ type: "GET_STATUS", id: "cmd-3", payload: {} });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findSent(ERROR)).toBeDefined();
|
||||
});
|
||||
|
||||
const response = findSent(ERROR);
|
||||
expect(response.type).toBe(ERROR);
|
||||
expect(response.id).toBe("cmd-3");
|
||||
expect(response.payload.message).toBe("Something broke");
|
||||
expect(response.payload.code).toBe("HANDLER_ERROR");
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("handler throwing a non-Error value still sends ERROR response with stringified message", async () => {
|
||||
const { worker, sendMessage, findSent } = createWorker();
|
||||
worker.onCommand("GET_STATUS", async () => {
|
||||
throw "string error";
|
||||
});
|
||||
|
||||
sendMessage({ type: "GET_STATUS", id: "cmd-4", payload: {} });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findSent(ERROR)).toBeDefined();
|
||||
});
|
||||
|
||||
const response = findSent(ERROR);
|
||||
expect(response.type).toBe(ERROR);
|
||||
expect(response.id).toBe("cmd-4");
|
||||
expect(response.payload.message).toBe("string error");
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("receiving a command with no registered handler sends ERROR with code: 'NO_HANDLER'", async () => {
|
||||
const { worker, sendMessage, findSent } = createWorker();
|
||||
// Don't register any handler for START_RUNTIME
|
||||
sendMessage({ type: "START_RUNTIME", id: "cmd-5", payload: {} });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findSent(ERROR)).toBeDefined();
|
||||
});
|
||||
|
||||
const response = findSent(ERROR);
|
||||
expect(response.payload.code).toBe("NO_HANDLER");
|
||||
expect(response.id).toBe("cmd-5");
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("receiving a non-command (unknown type) sends ERROR with code: 'UNKNOWN_COMMAND'", async () => {
|
||||
const { worker, sendMessage, findSent } = createWorker();
|
||||
sendMessage({ type: "TOTALLY_UNKNOWN", id: "cmd-6", payload: {} });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findSent(ERROR)).toBeDefined();
|
||||
});
|
||||
|
||||
const response = findSent(ERROR);
|
||||
expect(response.payload.code).toBe("UNKNOWN_COMMAND");
|
||||
expect(response.id).toBe("cmd-6");
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("receiving a malformed message (not a valid IpcMessage) sends ERROR with code: 'MALFORMED_MESSAGE'", async () => {
|
||||
const { worker, sendMessage, findSent } = createWorker();
|
||||
sendMessage({ noType: true }); // Missing type, id, payload
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findSent(ERROR)).toBeDefined();
|
||||
});
|
||||
|
||||
const response = findSent(ERROR);
|
||||
expect(response.payload.code).toBe("MALFORMED_MESSAGE");
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// ── sendEvent / sendErrorEvent ──────────────────────────────────────
|
||||
|
||||
describe("sendEvent and sendErrorEvent", () => {
|
||||
it("sendEvent() sends an IpcMessage with the given event type, a generated correlation ID, and payload", () => {
|
||||
const { worker, sendFn } = createWorker();
|
||||
|
||||
worker.sendEvent(TASK_CREATED, { task: { id: "KB-001" } });
|
||||
|
||||
expect(sendFn).toHaveBeenCalledTimes(1);
|
||||
const msg = sendFn.mock.calls[0][0];
|
||||
expect(msg.type).toBe(TASK_CREATED);
|
||||
expect(typeof msg.id).toBe("string");
|
||||
expect(msg.id.length).toBeGreaterThan(0);
|
||||
expect(msg.payload).toEqual({ task: { id: "KB-001" } });
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("sendErrorEvent() sends an ERROR_EVENT typed message with error message and code", () => {
|
||||
const { worker, sendFn } = createWorker();
|
||||
|
||||
const err = new Error("Runtime crashed");
|
||||
(err as any).code = "RUNTIME_ERROR";
|
||||
worker.sendErrorEvent(err);
|
||||
|
||||
expect(sendFn).toHaveBeenCalledTimes(1);
|
||||
const msg = sendFn.mock.calls[0][0];
|
||||
expect(msg.type).toBe(ERROR_EVENT);
|
||||
expect(msg.payload).toEqual({
|
||||
message: "Runtime crashed",
|
||||
code: "RUNTIME_ERROR",
|
||||
});
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shutdown ────────────────────────────────────────────────────────
|
||||
|
||||
describe("shutdown", () => {
|
||||
it("sets isShuttingDown() to true", () => {
|
||||
const { worker } = createWorker();
|
||||
expect(worker.isShuttingDown()).toBe(false);
|
||||
worker.shutdown();
|
||||
expect(worker.isShuttingDown()).toBe(true);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("sends a SHUTDOWN message to parent via process.send", () => {
|
||||
const { worker, sendFn } = createWorker();
|
||||
worker.shutdown();
|
||||
|
||||
expect(sendFn).toHaveBeenCalledTimes(1);
|
||||
const msg = sendFn.mock.calls[0][0];
|
||||
expect(msg.type).toBe("SHUTDOWN");
|
||||
expect(typeof msg.id).toBe("string");
|
||||
expect(msg.payload).toEqual({});
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("logs warning when process.send throws during shutdown", () => {
|
||||
const { worker, sendFn } = createWorker();
|
||||
vi.mocked(ipcLog.warn).mockClear();
|
||||
|
||||
sendFn.mockImplementation(() => {
|
||||
throw new Error("channel closed");
|
||||
});
|
||||
|
||||
worker.shutdown();
|
||||
|
||||
expect(vi.mocked(ipcLog.warn)).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to send SHUTDOWN message to parent: channel closed"),
|
||||
);
|
||||
expect(worker.isShuttingDown()).toBe(true);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it('emits "shutdown" event on the IpcWorker instance', () => {
|
||||
const { worker } = createWorker();
|
||||
const handler = vi.fn();
|
||||
worker.on("shutdown", handler);
|
||||
worker.shutdown();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("is idempotent (calling twice only sends one SHUTDOWN message)", () => {
|
||||
const { worker, sendFn } = createWorker();
|
||||
worker.shutdown();
|
||||
worker.shutdown();
|
||||
|
||||
// Only one SHUTDOWN message should be sent
|
||||
expect(sendFn).toHaveBeenCalledTimes(1);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("after shutdown(), sendEvent() and sendResponse() are no-ops", () => {
|
||||
const { worker, sendFn } = createWorker();
|
||||
worker.shutdown();
|
||||
sendFn.mockClear();
|
||||
|
||||
worker.sendEvent(TASK_CREATED, { task: {} });
|
||||
worker.sendResponse(OK, "some-id", { data: null });
|
||||
|
||||
expect(sendFn).not.toHaveBeenCalled();
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Disconnect ──────────────────────────────────────────────────────
|
||||
|
||||
describe("disconnect", () => {
|
||||
it('process disconnect event emits "disconnect" on IpcWorker', () => {
|
||||
const { worker, triggerDisconnect } = createWorker();
|
||||
const handler = vi.fn();
|
||||
worker.on("disconnect", handler);
|
||||
|
||||
triggerDisconnect();
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────────────
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("sendEvent() when process.send is undefined does not throw (graceful fallback)", () => {
|
||||
const { worker } = createWorker();
|
||||
|
||||
// Remove process.send after construction
|
||||
const savedSend = process.send;
|
||||
delete (process as any).send;
|
||||
|
||||
expect(() => {
|
||||
worker.sendEvent(TASK_CREATED, { task: {} });
|
||||
}).not.toThrow();
|
||||
|
||||
// Restore
|
||||
process.send = savedSend;
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
|
||||
it("sendResponse() sends correctly structured IpcMessage with type, id, and payload", () => {
|
||||
const { worker, sendFn } = createWorker();
|
||||
|
||||
worker.sendResponse(OK, "resp-id-1", { data: { status: "active" } });
|
||||
|
||||
expect(sendFn).toHaveBeenCalledTimes(1);
|
||||
const msg = sendFn.mock.calls[0][0];
|
||||
expect(msg).toEqual({
|
||||
type: OK,
|
||||
id: "resp-id-1",
|
||||
payload: { data: { status: "active" } },
|
||||
});
|
||||
worker.removeAllListeners();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,715 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { CentralCore, Task } from "@fusion/core";
|
||||
import { ChildProcessRuntime } from "../child-process-runtime.js";
|
||||
import type {
|
||||
ProjectRuntimeConfig,
|
||||
RuntimeMetrics,
|
||||
RuntimeStatus,
|
||||
} from "../../project-runtime.js";
|
||||
import { runtimeLog } from "../../logger.js";
|
||||
import {
|
||||
START_RUNTIME,
|
||||
STOP_RUNTIME,
|
||||
GET_METRICS,
|
||||
TASK_CREATED,
|
||||
TASK_MOVED,
|
||||
TASK_UPDATED,
|
||||
ERROR_EVENT,
|
||||
HEALTH_CHANGED,
|
||||
OK,
|
||||
ERROR,
|
||||
PONG,
|
||||
} from "../../ipc/ipc-protocol.js";
|
||||
|
||||
type Listener = (...args: any[]) => void;
|
||||
|
||||
type CommandMessage = {
|
||||
type: string;
|
||||
id: string;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
type MockChildOptions = {
|
||||
pingResults?: boolean[];
|
||||
metricsResponse?: RuntimeMetrics;
|
||||
sendCallbackErrors?: Partial<Record<string, Error>>;
|
||||
markKilledOnSigterm?: boolean;
|
||||
emitExitOnKill?: boolean;
|
||||
};
|
||||
|
||||
type MockChildProcess = {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
connected: boolean;
|
||||
killed: boolean;
|
||||
sentMessages: CommandMessage[];
|
||||
};
|
||||
|
||||
const forkedChildren: MockChildProcess[] = [];
|
||||
const queuedForkOptions: MockChildOptions[] = [];
|
||||
|
||||
function createMockChildProcess(options: MockChildOptions = {}): MockChildProcess {
|
||||
const listeners = new Map<string, Listener[]>();
|
||||
const pingResults = [...(options.pingResults ?? [])];
|
||||
|
||||
const child: MockChildProcess = {
|
||||
on: vi.fn((event: string, handler: Listener) => {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(handler);
|
||||
listeners.set(event, existing);
|
||||
return child;
|
||||
}),
|
||||
send: vi.fn((message: CommandMessage, callback?: (error: Error | null) => void) => {
|
||||
child.sentMessages.push(message);
|
||||
|
||||
const sendError = options.sendCallbackErrors?.[message.type];
|
||||
if (sendError) {
|
||||
callback?.(sendError);
|
||||
return false;
|
||||
}
|
||||
|
||||
callback?.(null);
|
||||
|
||||
const respond = (type: string, payload: unknown) => {
|
||||
Promise.resolve().then(() => {
|
||||
child.emit("message", {
|
||||
type,
|
||||
id: message.id,
|
||||
payload,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (message.type === START_RUNTIME) {
|
||||
respond(OK, { data: { status: "active" } });
|
||||
} else if (message.type === STOP_RUNTIME) {
|
||||
respond(OK, { data: { stopped: true } });
|
||||
} else if (message.type === GET_METRICS) {
|
||||
respond(OK, {
|
||||
data:
|
||||
options.metricsResponse ??
|
||||
{
|
||||
inFlightTasks: 4,
|
||||
activeAgents: 2,
|
||||
lastActivityAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
} else if (message.type === "PING") {
|
||||
const pingOk = pingResults.shift() ?? true;
|
||||
if (pingOk) {
|
||||
respond(PONG, { timestamp: "2026-04-08T00:00:00.000Z" });
|
||||
} else {
|
||||
respond(ERROR, { message: "Ping failed", code: "PING_FAILED" });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
kill: vi.fn((signal?: string | number) => {
|
||||
if (signal === "SIGKILL" || (signal === "SIGTERM" && options.markKilledOnSigterm !== false)) {
|
||||
child.killed = true;
|
||||
}
|
||||
|
||||
if (options.emitExitOnKill) {
|
||||
child.emit("exit", signal === "SIGKILL" ? 137 : 0, typeof signal === "string" ? signal : null);
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
disconnect: vi.fn(() => {
|
||||
child.connected = false;
|
||||
child.emit("disconnect");
|
||||
}),
|
||||
emit: (event: string, ...args: unknown[]) => {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
handler(...(args as any[]));
|
||||
}
|
||||
},
|
||||
connected: true,
|
||||
killed: false,
|
||||
sentMessages: [],
|
||||
};
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
const mockFork = vi.fn(() => {
|
||||
const options = queuedForkOptions.shift() ?? {};
|
||||
const child = createMockChildProcess(options);
|
||||
forkedChildren.push(child);
|
||||
return child;
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
fork: (...args: unknown[]) => (mockFork as (...mockArgs: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
|
||||
function queueChild(options: MockChildOptions = {}): void {
|
||||
queuedForkOptions.push(options);
|
||||
}
|
||||
|
||||
function getLatestChild(): MockChildProcess {
|
||||
const child = forkedChildren.at(-1);
|
||||
if (!child) {
|
||||
throw new Error("Expected a forked child process");
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
function getMessages(child: MockChildProcess, type: string): CommandMessage[] {
|
||||
return child.sentMessages.filter((message) => message.type === type);
|
||||
}
|
||||
|
||||
function createMockTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
title: `${id} title`,
|
||||
description: `${id} description`,
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
size: "M",
|
||||
reviewLevel: 1,
|
||||
log: [],
|
||||
attachments: [],
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("ChildProcessRuntime", () => {
|
||||
let runtime: ChildProcessRuntime;
|
||||
let runtimeAny: any;
|
||||
|
||||
const testConfig: ProjectRuntimeConfig = {
|
||||
projectId: "proj_test123",
|
||||
workingDirectory: "/tmp/test-project",
|
||||
isolationMode: "child-process",
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFork.mockClear();
|
||||
forkedChildren.length = 0;
|
||||
queuedForkOptions.length = 0;
|
||||
|
||||
const mockCentralCore = {
|
||||
getGlobalConcurrencyState: vi.fn().mockResolvedValue({
|
||||
globalMaxConcurrent: 4,
|
||||
currentlyActive: 0,
|
||||
queuedCount: 0,
|
||||
projectsActive: {},
|
||||
}),
|
||||
} as unknown as CentralCore;
|
||||
|
||||
runtime = new ChildProcessRuntime(testConfig, mockCentralCore);
|
||||
runtimeAny = runtime as any;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await runtime.stop();
|
||||
} catch {
|
||||
// Ignore cleanup failures
|
||||
}
|
||||
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("startup sequence", () => {
|
||||
it("transitions stopped → starting → active, forks worker path, and sends START_RUNTIME config", async () => {
|
||||
queueChild();
|
||||
|
||||
const transitions: RuntimeStatus[] = [];
|
||||
runtime.on("health-changed", (data) => transitions.push(data.status));
|
||||
|
||||
await runtime.start();
|
||||
|
||||
const child = getLatestChild();
|
||||
|
||||
expect(transitions).toEqual(["starting", "active"]);
|
||||
expect(runtime.getStatus()).toBe("active");
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/child-process-worker\.(ts|js)$/),
|
||||
[],
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
execArgv: [],
|
||||
})
|
||||
);
|
||||
|
||||
const startMessages = getMessages(child, START_RUNTIME);
|
||||
expect(startMessages).toHaveLength(1);
|
||||
expect(startMessages[0]?.payload).toEqual({ config: testConfig });
|
||||
});
|
||||
|
||||
it("sets status to errored and emits error when startup fails", async () => {
|
||||
queueChild({
|
||||
sendCallbackErrors: {
|
||||
[START_RUNTIME]: new Error("start send failed"),
|
||||
},
|
||||
});
|
||||
|
||||
const errorSpy = vi.fn();
|
||||
runtime.on("error", errorSpy);
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("Failed to send command: start send failed");
|
||||
expect(runtime.getStatus()).toBe("errored");
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("throws when start() is called in non-stopped states", async () => {
|
||||
const blockedStates: RuntimeStatus[] = ["starting", "active", "stopping"];
|
||||
|
||||
for (const status of blockedStates) {
|
||||
runtimeAny.status = status;
|
||||
await expect(runtime.start()).rejects.toThrow(`Cannot start runtime: current status is ${status}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("shutdown sequence", () => {
|
||||
it("transitions active → stopping → stopped and sends STOP_RUNTIME with timeout", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
const transitions: RuntimeStatus[] = [];
|
||||
runtime.on("health-changed", (data) => transitions.push(data.status));
|
||||
|
||||
await runtime.stop();
|
||||
|
||||
expect(transitions).toEqual(["stopping", "stopped"]);
|
||||
expect(runtime.getStatus()).toBe("stopped");
|
||||
expect(getMessages(child, STOP_RUNTIME)).toHaveLength(1);
|
||||
expect(getMessages(child, STOP_RUNTIME)[0]?.payload).toEqual({ timeoutMs: 30000 });
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("is idempotent and does not send duplicate STOP_RUNTIME commands", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
await runtime.stop();
|
||||
await runtime.stop();
|
||||
|
||||
expect(getMessages(child, STOP_RUNTIME)).toHaveLength(1);
|
||||
expect(child.kill).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns without error when stop() is called while already stopped", async () => {
|
||||
await expect(runtime.stop()).resolves.toBeUndefined();
|
||||
expect(runtime.getStatus()).toBe("stopped");
|
||||
});
|
||||
|
||||
it("handles stop() gracefully when IPC is already disconnected", async () => {
|
||||
queueChild();
|
||||
runtime.on("error", () => {
|
||||
// swallow asynchronous error events from disconnection path
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
child.connected = false;
|
||||
child.emit("disconnect");
|
||||
|
||||
await expect(runtime.stop()).resolves.toBeUndefined();
|
||||
expect(runtime.getStatus()).toBe("stopped");
|
||||
});
|
||||
|
||||
it("force-kills with SIGKILL after 5s timeout when child remains alive", async () => {
|
||||
vi.useFakeTimers();
|
||||
queueChild({ markKilledOnSigterm: false });
|
||||
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
await runtime.stop();
|
||||
|
||||
// Keep a live child reference so the delayed SIGKILL callback can execute the force-kill path.
|
||||
runtimeAny.child = child;
|
||||
child.killed = false;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("health monitoring and restart", () => {
|
||||
it("starts health monitoring after start() and performs periodic pings", async () => {
|
||||
vi.useFakeTimers();
|
||||
queueChild({ pingResults: [true, true] });
|
||||
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
expect(getMessages(child, "PING")).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(getMessages(child, "PING")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("resets missed heartbeat count to 0 after a successful ping", async () => {
|
||||
vi.useFakeTimers();
|
||||
queueChild({ pingResults: [false, true] });
|
||||
runtime.on("error", () => {
|
||||
// swallow
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(runtimeAny.healthMonitor.getMissedHeartbeats()).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(runtimeAny.healthMonitor.getMissedHeartbeats()).toBe(0);
|
||||
});
|
||||
|
||||
it("triggers handleUnhealthy after three missed heartbeats", async () => {
|
||||
vi.useFakeTimers();
|
||||
queueChild({ pingResults: [false, false, false] });
|
||||
|
||||
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||
|
||||
await runtime.start();
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
|
||||
expect(unhealthySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses exponential restart delays: 1000ms, 5000ms, 15000ms", () => {
|
||||
vi.useFakeTimers();
|
||||
runtimeAny.status = "active";
|
||||
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
runtimeAny.handleUnhealthy();
|
||||
runtimeAny.handleUnhealthy();
|
||||
runtimeAny.handleUnhealthy();
|
||||
|
||||
const delays = timeoutSpy.mock.calls.map((call) => Number(call[1]));
|
||||
expect(delays.slice(0, 3)).toEqual([1000, 5000, 15000]);
|
||||
});
|
||||
|
||||
it("transitions to errored and emits error after max restart attempts", () => {
|
||||
runtimeAny.status = "active";
|
||||
|
||||
const errorSpy = vi.fn();
|
||||
runtime.on("error", errorSpy);
|
||||
|
||||
runtimeAny.handleUnhealthy();
|
||||
runtimeAny.handleUnhealthy();
|
||||
runtimeAny.handleUnhealthy();
|
||||
runtimeAny.handleUnhealthy();
|
||||
|
||||
expect(runtime.getStatus()).toBe("errored");
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||
expect((errorSpy.mock.calls[0]?.[0] as Error).message).toContain("max restart attempts");
|
||||
});
|
||||
|
||||
it("resets restart attempt counter after a successful health check", async () => {
|
||||
vi.useFakeTimers();
|
||||
queueChild({ pingResults: [true] });
|
||||
|
||||
await runtime.start();
|
||||
|
||||
runtimeAny.healthMonitor.incrementRestartAttempts();
|
||||
runtimeAny.healthMonitor.incrementRestartAttempts();
|
||||
expect(runtimeAny.healthMonitor.getRestartAttempts()).toBe(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(runtimeAny.healthMonitor.getRestartAttempts()).toBe(0);
|
||||
});
|
||||
|
||||
it("stops health checks after stop()", async () => {
|
||||
vi.useFakeTimers();
|
||||
queueChild({ pingResults: [true, true, true] });
|
||||
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
const pingCountBeforeStop = getMessages(child, "PING").length;
|
||||
|
||||
await runtime.stop();
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
|
||||
expect(getMessages(child, "PING").length).toBe(pingCountBeforeStop);
|
||||
});
|
||||
});
|
||||
|
||||
describe("child process exit and disconnect", () => {
|
||||
it("unexpected child exit while active triggers restart handling", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
|
||||
const child = getLatestChild();
|
||||
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
expect(unhealthySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("child exit while stopping does not trigger restart", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
|
||||
const child = getLatestChild();
|
||||
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||
runtimeAny.status = "stopping";
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
expect(unhealthySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("child exit while stopped does not trigger restart", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
|
||||
const child = getLatestChild();
|
||||
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||
runtimeAny.status = "stopped";
|
||||
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
expect(unhealthySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("IPC disconnect while active triggers restart handling", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
|
||||
const child = getLatestChild();
|
||||
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||
|
||||
child.emit("disconnect");
|
||||
|
||||
expect(unhealthySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("IPC disconnect while stopping does not trigger restart", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
|
||||
const child = getLatestChild();
|
||||
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||
runtimeAny.status = "stopping";
|
||||
|
||||
child.emit("disconnect");
|
||||
|
||||
expect(unhealthySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event forwarding", () => {
|
||||
it("forwards TASK_CREATED as task:created", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
const task = createMockTask("FN-1279-A");
|
||||
const createdSpy = vi.fn();
|
||||
runtime.on("task:created", createdSpy);
|
||||
|
||||
child.emit("message", {
|
||||
type: TASK_CREATED,
|
||||
id: "evt-created",
|
||||
payload: { task },
|
||||
});
|
||||
|
||||
expect(createdSpy).toHaveBeenCalledWith(task);
|
||||
});
|
||||
|
||||
it("forwards TASK_MOVED as task:moved with { task, from, to } shape", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
const task = createMockTask("FN-1279-B");
|
||||
const movedSpy = vi.fn();
|
||||
runtime.on("task:moved", movedSpy);
|
||||
|
||||
child.emit("message", {
|
||||
type: TASK_MOVED,
|
||||
id: "evt-moved",
|
||||
payload: { task, from: "todo", to: "in-progress" },
|
||||
});
|
||||
|
||||
expect(movedSpy).toHaveBeenCalledWith({ task, from: "todo", to: "in-progress" });
|
||||
});
|
||||
|
||||
it("forwards TASK_UPDATED as task:updated", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
const task = createMockTask("FN-1279-C");
|
||||
const updatedSpy = vi.fn();
|
||||
runtime.on("task:updated", updatedSpy);
|
||||
|
||||
child.emit("message", {
|
||||
type: TASK_UPDATED,
|
||||
id: "evt-updated",
|
||||
payload: { task },
|
||||
});
|
||||
|
||||
expect(updatedSpy).toHaveBeenCalledWith(task);
|
||||
});
|
||||
|
||||
it("forwards ERROR_EVENT as Error instance and preserves error code", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
const errorSpy = vi.fn();
|
||||
runtime.on("error", errorSpy);
|
||||
|
||||
child.emit("message", {
|
||||
type: ERROR_EVENT,
|
||||
id: "evt-error",
|
||||
payload: { message: "worker failed", code: "WORKER_FAILURE" },
|
||||
});
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const forwardedError = errorSpy.mock.calls[0]?.[0] as Error & { code?: string };
|
||||
expect(forwardedError).toBeInstanceOf(Error);
|
||||
expect(forwardedError.message).toBe("worker failed");
|
||||
expect(forwardedError.code).toBe("WORKER_FAILURE");
|
||||
});
|
||||
|
||||
it("applies HEALTH_CHANGED payload to status and emits health-changed", async () => {
|
||||
queueChild();
|
||||
await runtime.start();
|
||||
const child = getLatestChild();
|
||||
|
||||
const healthSpy = vi.fn();
|
||||
runtime.on("health-changed", healthSpy);
|
||||
healthSpy.mockClear();
|
||||
|
||||
child.emit("message", {
|
||||
type: HEALTH_CHANGED,
|
||||
id: "evt-health",
|
||||
payload: { status: "paused", previous: "active" },
|
||||
});
|
||||
|
||||
expect(runtime.getStatus()).toBe("paused");
|
||||
expect(healthSpy).toHaveBeenCalledWith({ status: "paused", previous: "active" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("metrics and inaccessible accessors", () => {
|
||||
it("returns cached metrics when IPC is disconnected", () => {
|
||||
runtimeAny.lastMetrics = {
|
||||
inFlightTasks: 9,
|
||||
activeAgents: 3,
|
||||
lastActivityAt: "2026-04-08T01:00:00.000Z",
|
||||
};
|
||||
|
||||
const metrics = runtime.getMetrics();
|
||||
|
||||
expect(metrics.inFlightTasks).toBe(9);
|
||||
expect(metrics.activeAgents).toBe(3);
|
||||
expect(typeof metrics.lastActivityAt).toBe("string");
|
||||
});
|
||||
|
||||
it("updates cached metrics when GET_METRICS response is received", async () => {
|
||||
queueChild({
|
||||
metricsResponse: {
|
||||
inFlightTasks: 12,
|
||||
activeAgents: 5,
|
||||
lastActivityAt: "2026-04-08T02:00:00.000Z",
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
|
||||
runtime.getMetrics();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeAny.lastMetrics).toEqual({
|
||||
inFlightTasks: 12,
|
||||
activeAgents: 5,
|
||||
lastActivityAt: "2026-04-08T02:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores GET_METRICS IPC errors and returns the last known metrics", async () => {
|
||||
queueChild({
|
||||
sendCallbackErrors: {
|
||||
[GET_METRICS]: new Error("metrics unavailable"),
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
|
||||
runtimeAny.lastMetrics = {
|
||||
inFlightTasks: 21,
|
||||
activeAgents: 8,
|
||||
lastActivityAt: "2026-04-08T03:00:00.000Z",
|
||||
};
|
||||
|
||||
const metrics = runtime.getMetrics();
|
||||
|
||||
expect(metrics.inFlightTasks).toBe(21);
|
||||
expect(metrics.activeAgents).toBe(8);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(runtimeAny.lastMetrics).toEqual({
|
||||
inFlightTasks: 21,
|
||||
activeAgents: 8,
|
||||
lastActivityAt: "2026-04-08T03:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("logs warning when GET_METRICS IPC query fails", async () => {
|
||||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
|
||||
|
||||
queueChild({
|
||||
sendCallbackErrors: {
|
||||
[GET_METRICS]: new Error("metrics unavailable"),
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
|
||||
runtimeAny.lastMetrics = {
|
||||
inFlightTasks: 1,
|
||||
activeAgents: 0,
|
||||
lastActivityAt: "2026-04-08T04:00:00.000Z",
|
||||
};
|
||||
|
||||
const metrics = runtime.getMetrics();
|
||||
expect(metrics.inFlightTasks).toBe(1);
|
||||
expect(metrics.activeAgents).toBe(0);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GET_METRICS IPC query failed, using cached value"),
|
||||
);
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("metrics unavailable"));
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("getTaskStore() always throws not accessible error", () => {
|
||||
expect(() => runtime.getTaskStore()).toThrow("not accessible in ChildProcessRuntime");
|
||||
});
|
||||
|
||||
it("getScheduler() always throws not accessible error", () => {
|
||||
expect(() => runtime.getScheduler()).toThrow("not accessible in ChildProcessRuntime");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { RuntimeMetrics, RuntimeStatus, ProjectRuntimeConfig } from "../../project-runtime.js";
|
||||
import {
|
||||
START_RUNTIME,
|
||||
STOP_RUNTIME,
|
||||
GET_STATUS,
|
||||
GET_METRICS,
|
||||
ERROR_EVENT,
|
||||
} from "../../ipc/ipc-protocol.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
ipcWorkers: [] as any[],
|
||||
runtimes: [] as any[],
|
||||
}));
|
||||
|
||||
vi.mock("../../logger.js", () => {
|
||||
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return {
|
||||
runtimeLog: mockLogger,
|
||||
createLogger: () => mockLogger,
|
||||
schedulerLog: mockLogger,
|
||||
triageLog: mockLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
CentralCore: class MockCentralCore {},
|
||||
}));
|
||||
|
||||
vi.mock("../../ipc/ipc-worker.js", () => {
|
||||
class MockIpcWorker {
|
||||
handlers = new Map<string, (payload: unknown) => Promise<unknown> | unknown>();
|
||||
onCommand = vi.fn((type: string, handler: (payload: unknown) => Promise<unknown> | unknown) => {
|
||||
this.handlers.set(type, handler);
|
||||
});
|
||||
sendEvent = vi.fn();
|
||||
shutdown = vi.fn();
|
||||
|
||||
constructor() {
|
||||
mockState.ipcWorkers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return { IpcWorker: MockIpcWorker };
|
||||
});
|
||||
|
||||
vi.mock("../in-process-runtime.js", () => {
|
||||
class MockInProcessRuntime {
|
||||
status: RuntimeStatus = "stopped";
|
||||
metrics: RuntimeMetrics = {
|
||||
inFlightTasks: 1,
|
||||
activeAgents: 1,
|
||||
lastActivityAt: "2026-04-08T00:00:00.000Z",
|
||||
};
|
||||
listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
start = vi.fn(async () => {
|
||||
this.status = "active";
|
||||
});
|
||||
|
||||
stop = vi.fn(async () => {
|
||||
this.status = "stopped";
|
||||
});
|
||||
|
||||
getStatus = vi.fn(() => this.status);
|
||||
|
||||
getMetrics = vi.fn(() => this.metrics);
|
||||
|
||||
on = vi.fn((event: string, handler: (...args: any[]) => void) => {
|
||||
const existing = this.listeners.get(event) ?? [];
|
||||
existing.push(handler);
|
||||
this.listeners.set(event, existing);
|
||||
return this;
|
||||
});
|
||||
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const handler of this.listeners.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
public config: ProjectRuntimeConfig,
|
||||
public centralCore: unknown
|
||||
) {
|
||||
mockState.runtimes.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return { InProcessRuntime: MockInProcessRuntime };
|
||||
});
|
||||
|
||||
vi.mock("../../project-engine.js", async () => {
|
||||
const { InProcessRuntime } = await import("../in-process-runtime.js");
|
||||
class MockProjectEngine {
|
||||
private runtime: any;
|
||||
constructor(config: any, centralCore: any, _options?: any) {
|
||||
this.runtime = new InProcessRuntime(config, centralCore);
|
||||
}
|
||||
start = vi.fn(async () => { await this.runtime.start(); });
|
||||
stop = vi.fn(async () => { await this.runtime.stop(); });
|
||||
getRuntime = vi.fn(() => this.runtime);
|
||||
getTaskStore = vi.fn(() => null);
|
||||
}
|
||||
return { ProjectEngine: MockProjectEngine };
|
||||
});
|
||||
|
||||
type MockWorker = {
|
||||
handlers: Map<string, (payload: unknown) => Promise<unknown> | unknown>;
|
||||
onCommand: ReturnType<typeof vi.fn>;
|
||||
sendEvent: ReturnType<typeof vi.fn>;
|
||||
shutdown: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockRuntime = {
|
||||
config: ProjectRuntimeConfig;
|
||||
centralCore: {
|
||||
getGlobalConcurrencyState?: () => Promise<unknown>;
|
||||
recordTaskCompletion?: () => Promise<void>;
|
||||
};
|
||||
status: RuntimeStatus;
|
||||
metrics: RuntimeMetrics;
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
getStatus: ReturnType<typeof vi.fn>;
|
||||
getMetrics: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
const testConfig: ProjectRuntimeConfig = {
|
||||
projectId: "proj_worker_test",
|
||||
workingDirectory: "/tmp/test-worker",
|
||||
isolationMode: "in-process",
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
};
|
||||
|
||||
async function loadWorkerModule(): Promise<MockWorker> {
|
||||
await import("../child-process-worker.js");
|
||||
|
||||
const ipcWorker = mockState.ipcWorkers.at(-1) as MockWorker | undefined;
|
||||
if (!ipcWorker) {
|
||||
throw new Error("Expected child-process-worker to instantiate IpcWorker");
|
||||
}
|
||||
|
||||
return ipcWorker;
|
||||
}
|
||||
|
||||
function getHandler<T = unknown>(
|
||||
worker: MockWorker,
|
||||
type: string
|
||||
): (payload: unknown) => Promise<T> {
|
||||
const handler = worker.handlers.get(type);
|
||||
if (!handler) {
|
||||
throw new Error(`Missing handler for ${type}`);
|
||||
}
|
||||
return handler as (payload: unknown) => Promise<T>;
|
||||
}
|
||||
|
||||
describe("child-process-worker", () => {
|
||||
type SignalListener = (...args: unknown[]) => void;
|
||||
const originalProcessSend = process.send;
|
||||
let sigtermBaseline: SignalListener[] = [];
|
||||
let sigintBaseline: SignalListener[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockState.ipcWorkers.length = 0;
|
||||
mockState.runtimes.length = 0;
|
||||
|
||||
sigtermBaseline = process.listeners("SIGTERM") as unknown as SignalListener[];
|
||||
sigintBaseline = process.listeners("SIGINT") as unknown as SignalListener[];
|
||||
|
||||
(process as NodeJS.Process & { send?: (...args: unknown[]) => unknown }).send = vi.fn(() => true);
|
||||
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const listener of process.listeners("SIGTERM")) {
|
||||
if (!sigtermBaseline.some((l) => l === listener)) {
|
||||
process.removeListener("SIGTERM", listener as unknown as SignalListener);
|
||||
}
|
||||
}
|
||||
|
||||
for (const listener of process.listeners("SIGINT")) {
|
||||
if (!sigintBaseline.some((l) => l === listener)) {
|
||||
process.removeListener("SIGINT", listener as unknown as SignalListener);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalProcessSend) {
|
||||
process.send = originalProcessSend;
|
||||
} else {
|
||||
delete (process as NodeJS.Process & { send?: unknown }).send;
|
||||
}
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("instantiates IpcWorker and registers START/STOP/GET_STATUS/GET_METRICS handlers", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
|
||||
expect(mockState.ipcWorkers).toHaveLength(1);
|
||||
expect(worker.onCommand).toHaveBeenCalledTimes(4);
|
||||
expect(worker.onCommand).toHaveBeenCalledWith(START_RUNTIME, expect.any(Function));
|
||||
expect(worker.onCommand).toHaveBeenCalledWith(STOP_RUNTIME, expect.any(Function));
|
||||
expect(worker.onCommand).toHaveBeenCalledWith(GET_STATUS, expect.any(Function));
|
||||
expect(worker.onCommand).toHaveBeenCalledWith(GET_METRICS, expect.any(Function));
|
||||
expect(worker.handlers.size).toBe(4);
|
||||
});
|
||||
|
||||
it("START_RUNTIME creates and starts InProcessRuntime, then returns status", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler<{ status: RuntimeStatus }>(worker, START_RUNTIME);
|
||||
|
||||
const result = await startHandler({ config: testConfig });
|
||||
|
||||
expect(result).toEqual({ status: "active" });
|
||||
expect(mockState.runtimes).toHaveLength(1);
|
||||
|
||||
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||
expect(runtime.config).toEqual(testConfig);
|
||||
expect(runtime.start).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getStatus).toHaveBeenCalled();
|
||||
expect(typeof runtime.centralCore.getGlobalConcurrencyState).toBe("function");
|
||||
expect(typeof runtime.centralCore.recordTaskCompletion).toBe("function");
|
||||
});
|
||||
|
||||
it("START_RUNTIME throws if runtime is already started", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler(worker, START_RUNTIME);
|
||||
|
||||
await startHandler({ config: testConfig });
|
||||
await expect(startHandler({ config: testConfig })).rejects.toThrow("Runtime already started");
|
||||
});
|
||||
|
||||
it("START_RUNTIME forwards runtime events via ipcWorker.sendEvent", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler(worker, START_RUNTIME);
|
||||
|
||||
await startHandler({ config: testConfig });
|
||||
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||
|
||||
const task = {
|
||||
id: "FN-1279",
|
||||
title: "task",
|
||||
description: "desc",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
size: "M",
|
||||
reviewLevel: 1,
|
||||
log: [],
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
runtime.emit("task:created", task);
|
||||
runtime.emit("task:moved", { task, from: "todo", to: "in-progress" });
|
||||
runtime.emit("task:updated", task);
|
||||
const runtimeError = new Error("runtime boom") as Error & { code?: string };
|
||||
runtimeError.code = "RUNTIME_ERR";
|
||||
runtime.emit("error", runtimeError);
|
||||
runtime.emit("health-changed", { status: "active", previous: "starting" });
|
||||
|
||||
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_CREATED", { task });
|
||||
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_MOVED", {
|
||||
task,
|
||||
from: "todo",
|
||||
to: "in-progress",
|
||||
});
|
||||
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_UPDATED", { task });
|
||||
expect(worker.sendEvent).toHaveBeenCalledWith(ERROR_EVENT, {
|
||||
message: "runtime boom",
|
||||
code: "RUNTIME_ERR",
|
||||
});
|
||||
expect(worker.sendEvent).toHaveBeenCalledWith("HEALTH_CHANGED", {
|
||||
status: "active",
|
||||
previous: "starting",
|
||||
});
|
||||
});
|
||||
|
||||
it("STOP_RUNTIME stops existing runtime and returns stopped true", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler(worker, START_RUNTIME);
|
||||
const stopHandler = getHandler<{ stopped: boolean }>(worker, STOP_RUNTIME);
|
||||
|
||||
await startHandler({ config: testConfig });
|
||||
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||
|
||||
const result = await stopHandler({ timeoutMs: 12345 });
|
||||
|
||||
expect(result).toEqual({ stopped: true });
|
||||
expect(runtime.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("STOP_RUNTIME throws when runtime has not been started", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const stopHandler = getHandler(worker, STOP_RUNTIME);
|
||||
|
||||
await expect(stopHandler({ timeoutMs: 30000 })).rejects.toThrow("Runtime not started");
|
||||
});
|
||||
|
||||
it("GET_STATUS returns stopped when runtime is null", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const getStatusHandler = getHandler<{ status: RuntimeStatus }>(worker, GET_STATUS);
|
||||
|
||||
await expect(getStatusHandler({})).resolves.toEqual({ status: "stopped" });
|
||||
});
|
||||
|
||||
it("GET_STATUS returns runtime status when runtime exists", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler(worker, START_RUNTIME);
|
||||
const getStatusHandler = getHandler<{ status: RuntimeStatus }>(worker, GET_STATUS);
|
||||
|
||||
await startHandler({ config: testConfig });
|
||||
|
||||
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||
runtime.status = "paused";
|
||||
|
||||
await expect(getStatusHandler({})).resolves.toEqual({ status: "paused" });
|
||||
});
|
||||
|
||||
it("GET_METRICS returns default metrics when runtime is null", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const getMetricsHandler = getHandler<RuntimeMetrics>(worker, GET_METRICS);
|
||||
|
||||
const result = await getMetricsHandler({});
|
||||
|
||||
expect(result.inFlightTasks).toBe(0);
|
||||
expect(result.activeAgents).toBe(0);
|
||||
expect(typeof result.lastActivityAt).toBe("string");
|
||||
});
|
||||
|
||||
it("GET_METRICS returns runtime metrics when runtime exists", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler(worker, START_RUNTIME);
|
||||
const getMetricsHandler = getHandler<RuntimeMetrics>(worker, GET_METRICS);
|
||||
|
||||
await startHandler({ config: testConfig });
|
||||
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||
runtime.metrics = {
|
||||
inFlightTasks: 7,
|
||||
activeAgents: 4,
|
||||
lastActivityAt: "2026-04-08T05:00:00.000Z",
|
||||
};
|
||||
|
||||
await expect(getMetricsHandler({})).resolves.toEqual(runtime.metrics);
|
||||
expect(runtime.getMetrics).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("SIGTERM stops runtime and shuts down IPC worker", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler(worker, START_RUNTIME);
|
||||
|
||||
await startHandler({ config: testConfig });
|
||||
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||
|
||||
process.emit("SIGTERM");
|
||||
await vi.waitFor(() => {
|
||||
expect(runtime.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(worker.shutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("SIGINT stops runtime and shuts down IPC worker", async () => {
|
||||
const worker = await loadWorkerModule();
|
||||
const startHandler = getHandler(worker, START_RUNTIME);
|
||||
|
||||
await startHandler({ config: testConfig });
|
||||
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||
|
||||
process.emit("SIGINT");
|
||||
await vi.waitFor(() => {
|
||||
expect(runtime.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(worker.shutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
1163
packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts
Normal file
1163
packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeMetrics } from "../../project-runtime.js";
|
||||
import { RemoteNodeClient } from "../remote-node-client.js";
|
||||
|
||||
const BASE_URL = "https://node.example.com";
|
||||
const API_KEY = "secret-token";
|
||||
|
||||
describe("RemoteNodeClient", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("health() parses successful response and sends auth header", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 123 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
const health = await client.health();
|
||||
|
||||
expect(health).toEqual({ status: "ok", version: "1.0.0", uptime: 123 });
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/health`, expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("getMetrics() parses runtime metrics", async () => {
|
||||
const metrics: RuntimeMetrics = {
|
||||
inFlightTasks: 4,
|
||||
activeAgents: 2,
|
||||
lastActivityAt: "2026-04-08T00:00:00.000Z",
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(metrics), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
await expect(client.getMetrics()).resolves.toEqual(metrics);
|
||||
});
|
||||
|
||||
it("createTask() sends POST with JSON body", async () => {
|
||||
const createdTask = {
|
||||
id: "KB-001",
|
||||
description: "Create me",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status: "pending",
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
size: "M",
|
||||
reviewLevel: 1,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(createdTask), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
await client.createTask({ description: "Create me" });
|
||||
|
||||
const options = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/tasks`, expect.any(Object));
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers).toEqual(expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
}));
|
||||
expect(options.body).toBe(JSON.stringify({ description: "Create me" }));
|
||||
});
|
||||
|
||||
it("listTasks() sends optional query params", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
await client.listTasks({ column: "in-progress", limit: 10 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/api/tasks?column=in-progress&limit=10`,
|
||||
expect.objectContaining({ method: "GET" })
|
||||
);
|
||||
});
|
||||
|
||||
it("executeTask() posts to execute endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ acknowledged: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
const result = await client.executeTask("KB-123");
|
||||
|
||||
expect(result).toEqual({ acknowledged: true });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/api/tasks/KB-123/execute`,
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
|
||||
it("streamEvents() yields parsed events from SSE stream", async () => {
|
||||
const sseBody = [
|
||||
"event: task:created",
|
||||
'data: {"type":"task:created","payload":{"id":"KB-1"},"timestamp":"2026-04-08T00:00:00.000Z"}',
|
||||
"",
|
||||
"event: task:updated",
|
||||
'data: {"type":"task:updated","payload":{"id":"KB-1","column":"in-progress"},"timestamp":"2026-04-08T00:01:00.000Z"}',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(sseBody, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
|
||||
const events: unknown[] = [];
|
||||
for await (const event of client.streamEvents()) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "task:created",
|
||||
payload: { id: "KB-1" },
|
||||
timestamp: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
type: "task:updated",
|
||||
payload: { id: "KB-1", column: "in-progress" },
|
||||
timestamp: "2026-04-08T00:01:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries on network errors", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("network down"))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 123 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
|
||||
const request = client.health();
|
||||
const expectation = expect(request).resolves.toEqual({
|
||||
status: "ok",
|
||||
version: "1.0.0",
|
||||
uptime: 123,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expectation;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry on 4xx responses", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
|
||||
await expect(client.health()).rejects.toThrow("401 Unauthorized");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries on 5xx responses", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("server error", { status: 500, statusText: "Internal Server Error" })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response("server error", { status: 502, statusText: "Bad Gateway" })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 999 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
|
||||
const request = client.health();
|
||||
const expectation = expect(request).resolves.toEqual({
|
||||
status: "ok",
|
||||
version: "1.0.0",
|
||||
uptime: 999,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expectation;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("aborts requests after timeoutMs", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((_: unknown, init?: RequestInit) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
signal?.addEventListener("abort", () => {
|
||||
const abortError = new Error("aborted");
|
||||
abortError.name = "AbortError";
|
||||
reject(abortError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({
|
||||
baseUrl: BASE_URL,
|
||||
apiKey: API_KEY,
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
const request = client.health();
|
||||
const expectation = expect(request).rejects.toThrow("timed out");
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await expectation;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4); // initial + 3 retries
|
||||
});
|
||||
|
||||
it("sends auth header on all request methods", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 1 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ inFlightTasks: 0, activeAgents: 0, lastActivityAt: "now" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ acknowledged: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response("event: ping\ndata: {}\n\n", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
);
|
||||
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
||||
|
||||
await client.health();
|
||||
await client.getMetrics();
|
||||
await client.listTasks();
|
||||
await client.executeTask("KB-777");
|
||||
for await (const _event of client.streamEvents()) {
|
||||
// Drain one-response event stream
|
||||
}
|
||||
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
const options = call[1] as RequestInit;
|
||||
expect(options.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
import type { RuntimeMetrics } from "../../project-runtime.js";
|
||||
import { RemoteNodeRuntime } from "../remote-node-runtime.js";
|
||||
|
||||
const mockClientConstructor = vi.hoisted(() => vi.fn());
|
||||
const mockHealth = vi.hoisted(() => vi.fn());
|
||||
const mockGetMetrics = vi.hoisted(() => vi.fn());
|
||||
const mockStreamEvents = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../remote-node-client.js", () => ({
|
||||
RemoteNodeClient: vi.fn().mockImplementation((options: unknown) => {
|
||||
mockClientConstructor(options);
|
||||
return {
|
||||
health: mockHealth,
|
||||
getMetrics: mockGetMetrics,
|
||||
streamEvents: mockStreamEvents,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const NOW = "2026-04-08T00:00:00.000Z";
|
||||
|
||||
function createNode(overrides?: Partial<NodeConfig>): NodeConfig {
|
||||
return {
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "https://remote.example.com",
|
||||
apiKey: "token-123",
|
||||
status: "online",
|
||||
maxConcurrent: 4,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function* idleStream(signal?: AbortSignal): AsyncIterable<unknown> {
|
||||
while (!signal?.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
// Yield to satisfy TypeScript/ESLint generator requirements
|
||||
yield;
|
||||
}
|
||||
|
||||
async function* eventStream(events: unknown[], signal?: AbortSignal): AsyncIterable<unknown> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
|
||||
while (!signal?.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
describe("RemoteNodeRuntime", () => {
|
||||
beforeEach(() => {
|
||||
mockClientConstructor.mockReset();
|
||||
mockHealth.mockReset();
|
||||
mockGetMetrics.mockReset();
|
||||
mockStreamEvents.mockReset();
|
||||
|
||||
mockHealth.mockResolvedValue({ status: "ok", version: "1.0.0", uptime: 100 });
|
||||
mockGetMetrics.mockResolvedValue({
|
||||
inFlightTasks: 1,
|
||||
activeAgents: 2,
|
||||
lastActivityAt: NOW,
|
||||
} satisfies RuntimeMetrics);
|
||||
mockStreamEvents.mockImplementation(({ signal }: { signal?: AbortSignal } = {}) =>
|
||||
idleStream(signal)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("start() transitions stopped -> starting -> active and starts stream", async () => {
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode(),
|
||||
projectId: "proj_1",
|
||||
projectName: "Project 1",
|
||||
});
|
||||
|
||||
const healthEvents: string[] = [];
|
||||
runtime.on("health-changed", ({ status }) => {
|
||||
healthEvents.push(status);
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
expect(runtime.getStatus()).toBe("active");
|
||||
expect(healthEvents).toEqual(["starting", "active"]);
|
||||
expect(mockHealth).toHaveBeenCalled();
|
||||
expect(mockStreamEvents).toHaveBeenCalled();
|
||||
expect(mockClientConstructor).toHaveBeenCalledWith({
|
||||
baseUrl: "https://remote.example.com",
|
||||
apiKey: "token-123",
|
||||
});
|
||||
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("stop() transitions to stopped and is idempotent", async () => {
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode(),
|
||||
projectId: "proj_2",
|
||||
projectName: "Project 2",
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await runtime.stop();
|
||||
|
||||
expect(runtime.getStatus()).toBe("stopped");
|
||||
|
||||
await expect(runtime.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("getTaskStore() throws descriptive error", () => {
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode(),
|
||||
projectId: "proj_3",
|
||||
projectName: "Project 3",
|
||||
});
|
||||
|
||||
expect(() => runtime.getTaskStore()).toThrow(
|
||||
"TaskStore not accessible for remote node runtime"
|
||||
);
|
||||
});
|
||||
|
||||
it("getScheduler() throws descriptive error", () => {
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode(),
|
||||
projectId: "proj_4",
|
||||
projectName: "Project 4",
|
||||
});
|
||||
|
||||
expect(() => runtime.getScheduler()).toThrow("Scheduler not accessible for remote node runtime");
|
||||
});
|
||||
|
||||
it("getMetrics() returns fetched metrics on success and fallback on failure", async () => {
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode(),
|
||||
projectId: "proj_5",
|
||||
projectName: "Project 5",
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
expect(runtime.getMetrics()).toEqual({
|
||||
inFlightTasks: 1,
|
||||
activeAgents: 2,
|
||||
lastActivityAt: NOW,
|
||||
});
|
||||
|
||||
mockGetMetrics.mockRejectedValueOnce(new Error("metrics unavailable"));
|
||||
|
||||
runtime.getMetrics();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runtime.getMetrics()).toEqual({
|
||||
inFlightTasks: 0,
|
||||
activeAgents: 0,
|
||||
lastActivityAt: NOW,
|
||||
});
|
||||
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("forwards remote task and error events", async () => {
|
||||
const createdHandler = vi.fn();
|
||||
const movedHandler = vi.fn();
|
||||
const updatedHandler = vi.fn();
|
||||
const errorHandler = vi.fn();
|
||||
|
||||
mockStreamEvents.mockImplementation(({ signal }: { signal?: AbortSignal } = {}) =>
|
||||
eventStream(
|
||||
[
|
||||
{
|
||||
type: "task:created",
|
||||
payload: { id: "KB-1" },
|
||||
timestamp: NOW,
|
||||
},
|
||||
{
|
||||
type: "task:moved",
|
||||
payload: { task: { id: "KB-1" }, from: "todo", to: "in-progress" },
|
||||
timestamp: NOW,
|
||||
},
|
||||
{
|
||||
type: "task:updated",
|
||||
payload: { id: "KB-1", column: "done" },
|
||||
timestamp: NOW,
|
||||
},
|
||||
{
|
||||
type: "error",
|
||||
payload: { message: "boom" },
|
||||
timestamp: NOW,
|
||||
},
|
||||
],
|
||||
signal
|
||||
)
|
||||
);
|
||||
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode(),
|
||||
projectId: "proj_6",
|
||||
projectName: "Project 6",
|
||||
});
|
||||
|
||||
runtime.on("task:created", createdHandler);
|
||||
runtime.on("task:moved", movedHandler);
|
||||
runtime.on("task:updated", updatedHandler);
|
||||
runtime.on("error", errorHandler);
|
||||
|
||||
await runtime.start();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createdHandler).toHaveBeenCalledWith({ id: "KB-1" });
|
||||
expect(movedHandler).toHaveBeenCalledWith({
|
||||
task: { id: "KB-1" },
|
||||
from: "todo",
|
||||
to: "in-progress",
|
||||
});
|
||||
expect(updatedHandler).toHaveBeenCalledWith({ id: "KB-1", column: "done" });
|
||||
expect(errorHandler).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("reconnects when stream ends unexpectedly and transitions to errored after max attempts", async () => {
|
||||
mockStreamEvents.mockImplementation(async function* () {
|
||||
// Immediate end to force reconnect loop.
|
||||
});
|
||||
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode(),
|
||||
projectId: "proj_7",
|
||||
projectName: "Project 7",
|
||||
});
|
||||
|
||||
(runtime as unknown as { reconnectBaseDelayMs: number }).reconnectBaseDelayMs = 1;
|
||||
(runtime as unknown as { maxReconnectDelayMs: number }).maxReconnectDelayMs = 1;
|
||||
(runtime as unknown as { maxReconnectAttempts: number }).maxReconnectAttempts = 3;
|
||||
|
||||
await runtime.start();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtime.getStatus()).toBe("errored");
|
||||
});
|
||||
|
||||
expect(mockStreamEvents.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("validates remote node config on start", async () => {
|
||||
const runtime = new RemoteNodeRuntime({
|
||||
nodeConfig: createNode({ type: "local", url: undefined, apiKey: undefined }),
|
||||
projectId: "proj_8",
|
||||
projectName: "Project 8",
|
||||
});
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("requires a remote node configuration");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user