test(FN-1282): expand dashboard hook and modal coverage
- Add tests for useAgents and useProjectHealth hooks, including loading, success, and error flows - Add useFileBrowser and useFileEditor hook tests for file navigation, save behavior, and edge cases - Add component tests for AgentImportModal and AgentGenerationModal interaction paths - Add MissionInterviewModal tests covering question flow, submission, and state transitions - Stabilize dashboard test suite cleanup and mocking patterns to reduce flakiness
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AgentGenerationModal } from "../AgentGenerationModal";
|
||||
import * as api from "../../api";
|
||||
import type { AgentGenerationSpec } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
startAgentGeneration: vi.fn(),
|
||||
generateAgentSpec: vi.fn(),
|
||||
cancelAgentGeneration: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockStartAgentGeneration = vi.mocked(api.startAgentGeneration);
|
||||
const mockGenerateAgentSpec = vi.mocked(api.generateAgentSpec);
|
||||
const mockCancelAgentGeneration = vi.mocked(api.cancelAgentGeneration);
|
||||
|
||||
const generatedSpec: AgentGenerationSpec = {
|
||||
title: "Accessibility Reviewer",
|
||||
icon: "♿",
|
||||
role: "reviewer",
|
||||
description: "Reviews React code for accessibility compliance",
|
||||
systemPrompt: "You are an expert accessibility reviewer...",
|
||||
thinkingLevel: "high",
|
||||
maxTurns: 12,
|
||||
};
|
||||
|
||||
describe("AgentGenerationModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const onGenerated = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStartAgentGeneration.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
roleDescription: "review accessibility",
|
||||
});
|
||||
mockGenerateAgentSpec.mockResolvedValue({
|
||||
spec: generatedSpec,
|
||||
});
|
||||
mockCancelAgentGeneration.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderModal(isOpen = true) {
|
||||
return render(
|
||||
<AgentGenerationModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onGenerated={onGenerated}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function startGeneration(description = "Build an accessibility-focused reviewer") {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("Role Description"), description);
|
||||
await user.click(screen.getByRole("button", { name: "Generate" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Use This" })).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
it("returns null when isOpen=false", () => {
|
||||
renderModal(false);
|
||||
expect(screen.queryByText("Generate Agent")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders title and role description textarea when open", () => {
|
||||
renderModal(true);
|
||||
|
||||
expect(screen.getByText("Generate Agent")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Role Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("focuses role description textarea on open", () => {
|
||||
renderModal(true);
|
||||
|
||||
expect(screen.getByLabelText("Role Description")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("disables Generate when description has fewer than 3 chars and enables at 3+", async () => {
|
||||
renderModal(true);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const generateButton = screen.getByRole("button", { name: "Generate" });
|
||||
|
||||
expect(generateButton).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByLabelText("Role Description"), "ab");
|
||||
expect(generateButton).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByLabelText("Role Description"), "c");
|
||||
expect(generateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows character counter for role description", async () => {
|
||||
renderModal(true);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("Role Description"), "abcd");
|
||||
|
||||
expect(screen.getByText("4/1000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleGenerate calls startAgentGeneration then generateAgentSpec and transitions to preview", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await startGeneration();
|
||||
|
||||
expect(mockStartAgentGeneration).toHaveBeenCalledWith(
|
||||
"Build an accessibility-focused reviewer",
|
||||
undefined,
|
||||
);
|
||||
expect(mockGenerateAgentSpec).toHaveBeenCalledWith("session-1", undefined);
|
||||
});
|
||||
|
||||
it("supports Enter key shortcut to generate when valid", async () => {
|
||||
renderModal(true);
|
||||
|
||||
const textarea = screen.getByLabelText("Role Description");
|
||||
fireEvent.change(textarea, { target: { value: "Generate with enter" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartAgentGeneration).toHaveBeenCalledWith("Generate with enter", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("preview shows title/icon/role/description/thinking/maxTurns with system prompt collapsed", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await startGeneration();
|
||||
|
||||
expect(screen.getByText(/Accessibility Reviewer/)).toBeInTheDocument();
|
||||
expect(screen.getByText("reviewer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reviews React code for accessibility compliance")).toBeInTheDocument();
|
||||
expect(screen.getByText("high")).toBeInTheDocument();
|
||||
expect(screen.getByText("12")).toBeInTheDocument();
|
||||
expect(screen.getByText("System Prompt")).toBeInTheDocument();
|
||||
expect(screen.getByText("Expand")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Use This calls onGenerated with spec and closes modal", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await startGeneration();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Use This" }));
|
||||
|
||||
expect(onGenerated).toHaveBeenCalledWith(generatedSpec);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Regenerate cancels existing session and re-runs generation", async () => {
|
||||
mockStartAgentGeneration
|
||||
.mockResolvedValueOnce({ sessionId: "session-1", roleDescription: "first" })
|
||||
.mockResolvedValueOnce({ sessionId: "session-2", roleDescription: "first" });
|
||||
mockGenerateAgentSpec
|
||||
.mockResolvedValueOnce({ spec: generatedSpec })
|
||||
.mockResolvedValueOnce({ spec: { ...generatedSpec, title: "Second Spec" } });
|
||||
|
||||
renderModal(true);
|
||||
|
||||
await startGeneration();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Regenerate" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelAgentGeneration).toHaveBeenCalledWith("session-1", undefined);
|
||||
expect(screen.getByText(/Second Spec/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("Cancel button calls onClose and cancels active session", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await startGeneration();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockCancelAgentGeneration).toHaveBeenCalledWith("session-1", undefined);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Escape key triggers cancel", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await startGeneration();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelAgentGeneration).toHaveBeenCalledWith("session-1", undefined);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error and returns to input when startAgentGeneration fails", async () => {
|
||||
mockStartAgentGeneration.mockRejectedValueOnce(new Error("start failed"));
|
||||
|
||||
renderModal(true);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("Role Description"), "some role");
|
||||
await user.click(screen.getByRole("button", { name: "Generate" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("start failed")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Role Description")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows friendly message for 429/rate-limit errors", async () => {
|
||||
mockStartAgentGeneration.mockRejectedValueOnce(new Error("429 Too Many Requests"));
|
||||
|
||||
renderModal(true);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("Role Description"), "rate limit role");
|
||||
await user.click(screen.getByRole("button", { name: "Generate" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Too many requests. Please wait a moment and try again.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels active session when modal closes via prop change", async () => {
|
||||
const { rerender } = render(
|
||||
<AgentGenerationModal isOpen={true} onClose={onClose} onGenerated={onGenerated} />,
|
||||
);
|
||||
|
||||
await startGeneration();
|
||||
|
||||
rerender(<AgentGenerationModal isOpen={false} onClose={onClose} onGenerated={onGenerated} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelAgentGeneration).toHaveBeenCalledWith("session-1", undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AgentImportModal } from "../AgentImportModal";
|
||||
|
||||
interface MockResponse {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
function mockResponse({ ok, status = ok ? 200 : 400, body }: MockResponse): Promise<Response> {
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe("AgentImportModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const onImported = vi.fn();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function renderModal(isOpen = true) {
|
||||
return render(
|
||||
<AgentImportModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onImported={onImported}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function goToPreview(manifest = "---\nname: Reviewer\nrole: reviewer\n---") {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
await mockResponse({
|
||||
ok: true,
|
||||
body: {
|
||||
companyName: "Acme AI",
|
||||
agents: [
|
||||
{ name: "Reviewer", role: "reviewer", title: "Code Reviewer", skills: ["review"] },
|
||||
{ name: "Planner", role: "triage", title: "Planner" },
|
||||
],
|
||||
created: ["Reviewer", "Planner"],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
dryRun: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("Manifest content"), manifest);
|
||||
await user.click(screen.getByRole("button", { name: "Preview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Acme AI")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 agents found")).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
it("returns null when isOpen=false", () => {
|
||||
renderModal(false);
|
||||
expect(screen.queryByText("Import Agents")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders title when open", () => {
|
||||
renderModal(true);
|
||||
expect(screen.getByText("Import Agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows file upload area and textarea in input step", () => {
|
||||
renderModal(true);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Choose File" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Select Directory" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Manifest content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Preview when manifest is empty and enables after typing", async () => {
|
||||
renderModal(true);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const preview = screen.getByRole("button", { name: "Preview" });
|
||||
expect(preview).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByLabelText("Manifest content"), "name: test-agent");
|
||||
expect(preview).toBeEnabled();
|
||||
});
|
||||
|
||||
it("handleParse posts dryRun import request and moves to preview step", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await goToPreview();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/agents/import",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(vi.mocked(globalThis.fetch).mock.calls[0][1]!.body as string);
|
||||
expect(body).toMatchObject({
|
||||
dryRun: true,
|
||||
manifest: expect.stringContaining("name: Reviewer"),
|
||||
});
|
||||
});
|
||||
|
||||
it("preview shows company name, count, and agent list", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await goToPreview();
|
||||
|
||||
expect(screen.getByText("Acme AI")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 agents found")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reviewer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Planner")).toBeInTheDocument();
|
||||
expect(screen.getByText(/reviewer/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/triage/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Back button returns to input step", async () => {
|
||||
renderModal(true);
|
||||
await goToPreview();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
|
||||
expect(screen.getByLabelText("Manifest content")).toBeInTheDocument();
|
||||
expect(screen.queryByText("2 agents found")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleImport posts live import request and transitions to result step", async () => {
|
||||
renderModal(true);
|
||||
|
||||
await goToPreview();
|
||||
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
await mockResponse({
|
||||
ok: true,
|
||||
body: {
|
||||
companyName: "Acme AI",
|
||||
created: [{ id: "agent-1", name: "Reviewer" }],
|
||||
skipped: ["Planner"],
|
||||
errors: [{ name: "Writer", error: "Invalid role" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /Import 2 Agents/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Import Complete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const body = JSON.parse(vi.mocked(globalThis.fetch).mock.calls[1][1]!.body as string);
|
||||
expect(body).toMatchObject({
|
||||
manifest: expect.stringContaining("name: Reviewer"),
|
||||
skipExisting: true,
|
||||
});
|
||||
expect(body).not.toHaveProperty("dryRun");
|
||||
});
|
||||
|
||||
it("result step shows created/skipped/error counts and created names", async () => {
|
||||
renderModal(true);
|
||||
await goToPreview();
|
||||
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
await mockResponse({
|
||||
ok: true,
|
||||
body: {
|
||||
companyName: "Acme AI",
|
||||
created: [{ id: "agent-1", name: "Reviewer" }, { id: "agent-2", name: "Planner" }],
|
||||
skipped: ["Writer"],
|
||||
errors: [{ name: "Ops", error: "Bad schema" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /Import 2 Agents/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2 created/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 skipped/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 error/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Reviewer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Planner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onImported after successful import", async () => {
|
||||
renderModal(true);
|
||||
await goToPreview();
|
||||
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
await mockResponse({
|
||||
ok: true,
|
||||
body: {
|
||||
companyName: "Acme AI",
|
||||
created: [{ id: "agent-1", name: "Reviewer" }],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /Import 2 Agents/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onImported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows parse API errors", async () => {
|
||||
renderModal(true);
|
||||
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
await mockResponse({ ok: false, body: { error: "No agents found" } }),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("Manifest content"), "invalid manifest");
|
||||
await user.click(screen.getByRole("button", { name: "Preview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No agents found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows import API errors", async () => {
|
||||
renderModal(true);
|
||||
await goToPreview();
|
||||
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
await mockResponse({ ok: false, body: { error: "Import failed" } }),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /Import 2 Agents/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Import failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("Cancel/Close calls onClose and resets state", async () => {
|
||||
const { rerender } = renderModal(true);
|
||||
|
||||
await goToPreview();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
expect(screen.getByLabelText("Manifest content")).toHaveValue("");
|
||||
expect(screen.queryByText("2 agents found")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking overlay triggers handleClose", () => {
|
||||
const { container } = renderModal(true);
|
||||
|
||||
const overlay = container.querySelector(".agent-dialog-overlay");
|
||||
expect(overlay).toBeTruthy();
|
||||
|
||||
fireEvent.click(overlay!);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { MissionInterviewModal } from "../MissionInterviewModal";
|
||||
import * as api from "../../api";
|
||||
import * as modalPersistence from "../../hooks/modalPersistence";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
startMissionInterview: vi.fn(),
|
||||
respondToMissionInterview: vi.fn(),
|
||||
cancelMissionInterview: vi.fn(),
|
||||
createMissionFromInterview: vi.fn(),
|
||||
connectMissionInterviewStream: vi.fn(),
|
||||
fetchAiSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/modalPersistence", () => ({
|
||||
saveMissionGoal: vi.fn(),
|
||||
getMissionGoal: vi.fn(() => ""),
|
||||
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 mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
|
||||
|
||||
const sampleQuestionSingle: PlanningQuestion = {
|
||||
id: "scope",
|
||||
type: "single_select",
|
||||
question: "What is the target scope?",
|
||||
description: "Pick a scope",
|
||||
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();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
closeStream = vi.fn();
|
||||
streamHandlers = undefined;
|
||||
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
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);
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockGetMissionGoal.mockReturnValue("");
|
||||
|
||||
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>>) {
|
||||
return render(
|
||||
<MissionInterviewModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onMissionCreated={onMissionCreated}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith(goal, 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);
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
expect(await screen.findByPlaceholderText("Type your answer here...")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.(sampleQuestionSingle);
|
||||
});
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stream onSummary transitions to summary view with editable fields and hierarchy", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onSummary?.(sampleSummary as any);
|
||||
});
|
||||
|
||||
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" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateMissionFromInterview).toHaveBeenCalledWith(
|
||||
"mission-session-1",
|
||||
expect.objectContaining({ missionTitle: "Mission: Collaboration Platform" }),
|
||||
undefined,
|
||||
);
|
||||
expect(onMissionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "MS-001" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("Start Over resets to initial view", async () => {
|
||||
renderModal();
|
||||
await startInterview();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onSummary?.(sampleSummary as any);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
act(() => {
|
||||
streamHandlers?.onQuestion?.(sampleQuestionSingle);
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
"Are you sure you want to close? Your interview progress will be lost.",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByLabelText("Close"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("initialGoal prop auto-starts interview", async () => {
|
||||
renderModal({ initialGoal: "Auto-start goal" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Auto-start goal", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
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({ resumeSessionId: "resume-1" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("resume-1");
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("unmount cleanup closes active stream connection", async () => {
|
||||
const { unmount } = renderModal();
|
||||
await startInterview();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(closeStream).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
197
packages/dashboard/app/hooks/__tests__/useAgents.test.ts
Normal file
197
packages/dashboard/app/hooks/__tests__/useAgents.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useAgents } from "../useAgents";
|
||||
import * as api from "../../api";
|
||||
import type { Agent, AgentCapability, AgentState, AgentStats } from "../../api";
|
||||
import { MockEventSource } from "../../../vitest.setup";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgents: vi.fn(),
|
||||
fetchAgentStats: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchAgents = vi.mocked(api.fetchAgents);
|
||||
const mockFetchAgentStats = vi.mocked(api.fetchAgentStats);
|
||||
|
||||
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
name: "Agent One",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "idle" as AgentState,
|
||||
metadata: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultStats: AgentStats = {
|
||||
activeCount: 1,
|
||||
assignedTaskCount: 2,
|
||||
completedRuns: 10,
|
||||
failedRuns: 1,
|
||||
successRate: 0.9,
|
||||
};
|
||||
|
||||
describe("useAgents", () => {
|
||||
beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
mockFetchAgents.mockReset().mockResolvedValue([]);
|
||||
mockFetchAgentStats.mockReset().mockResolvedValue(defaultStats);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const es of MockEventSource.instances) {
|
||||
es.close();
|
||||
}
|
||||
MockEventSource.instances = [];
|
||||
});
|
||||
|
||||
it("returns empty agents and null stats initially; loading settles after fetch", async () => {
|
||||
const { result } = renderHook(() => useAgents());
|
||||
|
||||
expect(result.current.agents).toEqual([]);
|
||||
expect(result.current.stats).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockFetchAgents).toHaveBeenCalled();
|
||||
expect(mockFetchAgentStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches agents and stats on mount and populates state", async () => {
|
||||
const agents = [
|
||||
createAgent({ id: "a-1", name: "Alpha", state: "active" }),
|
||||
createAgent({ id: "a-2", name: "Beta", state: "idle" }),
|
||||
];
|
||||
mockFetchAgents.mockResolvedValueOnce(agents);
|
||||
mockFetchAgentStats.mockResolvedValueOnce({ ...defaultStats, activeCount: 1 });
|
||||
|
||||
const { result } = renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.agents).toEqual(agents);
|
||||
expect(result.current.stats).toEqual({ ...defaultStats, activeCount: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it("filters active agents from mixed states", async () => {
|
||||
const agents = [
|
||||
createAgent({ id: "a-idle", state: "idle" }),
|
||||
createAgent({ id: "a-active", state: "active" }),
|
||||
createAgent({ id: "a-running", state: "running" }),
|
||||
createAgent({ id: "a-error", state: "error" }),
|
||||
];
|
||||
mockFetchAgents.mockResolvedValueOnce(agents);
|
||||
|
||||
const { result } = renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeAgents.map((a) => a.id)).toEqual(["a-active", "a-running"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("loadAgents accepts optional filters and passes them to fetchAgents", async () => {
|
||||
const { result } = renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadAgents({ state: "active", role: "executor" });
|
||||
});
|
||||
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor" }, undefined);
|
||||
});
|
||||
|
||||
it("handles fetchAgents rejection gracefully", async () => {
|
||||
mockFetchAgents.mockRejectedValueOnce(new Error("agents failed"));
|
||||
|
||||
const { result } = renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.agents).toEqual([]);
|
||||
expect(console.error).toHaveBeenCalledWith("Failed to load agents:", expect.any(Error));
|
||||
});
|
||||
|
||||
it("handles fetchAgentStats rejection gracefully", async () => {
|
||||
mockFetchAgentStats.mockRejectedValueOnce(new Error("stats failed"));
|
||||
|
||||
const { result } = renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result.current.stats).toBeNull();
|
||||
expect(console.error).toHaveBeenCalledWith("Failed to load agent stats:", expect.any(Error));
|
||||
});
|
||||
|
||||
it("creates SSE subscription with correct URL without projectId", async () => {
|
||||
renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = MockEventSource.instances.map((es) => es.url);
|
||||
expect(urls).toContain("/api/events");
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes agents and stats on supported SSE events", async () => {
|
||||
renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
|
||||
mockFetchAgents.mockClear();
|
||||
mockFetchAgentStats.mockClear();
|
||||
|
||||
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged"]) {
|
||||
act(() => {
|
||||
es._emit(event);
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(4);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes SSE subscription on unmount", async () => {
|
||||
const { unmount } = renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
|
||||
unmount();
|
||||
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with projectId passes projectId to fetch calls and EventSource URL", async () => {
|
||||
const projectId = "proj-123";
|
||||
|
||||
renderHook(() => useAgents(projectId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, projectId);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledWith(projectId);
|
||||
});
|
||||
|
||||
const urls = MockEventSource.instances.map((es) => es.url);
|
||||
expect(urls).toContain(`/api/events?projectId=${encodeURIComponent(projectId)}`);
|
||||
});
|
||||
});
|
||||
185
packages/dashboard/app/hooks/__tests__/useFileBrowser.test.ts
Normal file
185
packages/dashboard/app/hooks/__tests__/useFileBrowser.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useFileBrowser } from "../useFileBrowser";
|
||||
import * as api from "../../api";
|
||||
import type { FileListResponse } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchFileList: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchFileList = vi.mocked(api.fetchFileList);
|
||||
|
||||
function response(path: string, names: string[]): FileListResponse {
|
||||
return {
|
||||
path,
|
||||
entries: names.map((name) => ({
|
||||
name,
|
||||
type: name.includes(".") ? "file" : "directory",
|
||||
mtime: "2026-01-01T00:00:00.000Z",
|
||||
...(name.includes(".") ? { size: 123 } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFileBrowser", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchFileList.mockReset();
|
||||
mockFetchFileList.mockResolvedValue(response(".", ["src", "README.md"]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty entries and loading=false when enabled=false", async () => {
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", false));
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(mockFetchFileList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty entries and loading=false when taskId is empty", async () => {
|
||||
const { result } = renderHook(() => useFileBrowser("", true));
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(mockFetchFileList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches file list when enabled and sets loading then entries", async () => {
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.entries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("populates entries from FileListResponse", async () => {
|
||||
mockFetchFileList.mockResolvedValueOnce(response(".", ["docs", "notes.md", "index.ts"]));
|
||||
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.name)).toEqual(["docs", "notes.md", "index.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles fetch error by setting error message and clearing entries", async () => {
|
||||
mockFetchFileList.mockRejectedValueOnce(new Error("Failed to load files"));
|
||||
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe("Failed to load files");
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("setPath updates currentPath, clears error, and triggers new fetch", async () => {
|
||||
mockFetchFileList
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(response("src", ["index.ts"]));
|
||||
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("boom");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPath("src");
|
||||
});
|
||||
|
||||
expect(result.current.currentPath).toBe("src");
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFileList).toHaveBeenLastCalledWith("FN-001", "src");
|
||||
expect(result.current.entries.map((entry) => entry.name)).toEqual(["index.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes '.' path to undefined when calling fetchFileList", async () => {
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFileList).toHaveBeenCalledWith("FN-001", undefined);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPath(".");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFileList).toHaveBeenLastCalledWith("FN-001", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes non-dot paths directly to fetchFileList", async () => {
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFileList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPath("subdir");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFileList).toHaveBeenLastCalledWith("FN-001", "subdir");
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh increments refresh key and triggers re-fetch", async () => {
|
||||
mockFetchFileList
|
||||
.mockResolvedValueOnce(response(".", ["a.txt"]))
|
||||
.mockResolvedValueOnce(response(".", ["a.txt", "b.txt"]));
|
||||
|
||||
const { result } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.name)).toEqual(["a.txt"]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.name)).toEqual(["a.txt", "b.txt"]);
|
||||
expect(mockFetchFileList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels in-flight fetch on unmount", async () => {
|
||||
let resolveFetch!: (value: FileListResponse) => void;
|
||||
mockFetchFileList.mockReturnValueOnce(
|
||||
new Promise<FileListResponse>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useFileBrowser("FN-001", true));
|
||||
unmount();
|
||||
|
||||
resolveFetch(response(".", ["late.txt"]));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(mockFetchFileList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
275
packages/dashboard/app/hooks/__tests__/useFileEditor.test.ts
Normal file
275
packages/dashboard/app/hooks/__tests__/useFileEditor.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useFileEditor } from "../useFileEditor";
|
||||
import * as api from "../../api";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchFileContent: vi.fn(),
|
||||
saveFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchFileContent = vi.mocked(api.fetchFileContent);
|
||||
const mockSaveFileContent = vi.mocked(api.saveFileContent);
|
||||
|
||||
function contentResponse(content: string, mtime = "2026-01-01T00:00:00.000Z"): FileContentResponse {
|
||||
return {
|
||||
content,
|
||||
mtime,
|
||||
size: content.length,
|
||||
};
|
||||
}
|
||||
|
||||
function saveResponse(mtime = "2026-01-02T00:00:00.000Z", size = 100): SaveFileResponse {
|
||||
return {
|
||||
success: true,
|
||||
mtime,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("useFileEditor", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchFileContent.mockReset();
|
||||
mockSaveFileContent.mockReset();
|
||||
mockFetchFileContent.mockResolvedValue(contentResponse("hello"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty content when disabled or filePath is null", async () => {
|
||||
const disabled = renderHook(() => useFileEditor("FN-001", "README.md", false));
|
||||
expect(disabled.result.current.content).toBe("");
|
||||
expect(disabled.result.current.originalContent).toBe("");
|
||||
expect(disabled.result.current.mtime).toBeNull();
|
||||
|
||||
const noPath = renderHook(() => useFileEditor("FN-001", null, true));
|
||||
expect(noPath.result.current.content).toBe("");
|
||||
expect(noPath.result.current.originalContent).toBe("");
|
||||
expect(noPath.result.current.mtime).toBeNull();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(mockFetchFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches file content when enabled and filePath is set", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("file body"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "README.md", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.content).toBe("file body");
|
||||
});
|
||||
|
||||
expect(mockFetchFileContent).toHaveBeenCalledWith("FN-001", "README.md");
|
||||
});
|
||||
|
||||
it("sets content and originalContent from fetch response", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("abc"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "a.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe("abc");
|
||||
expect(result.current.originalContent).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
it("sets mtime from fetchFileContent response", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("abc", "2026-02-01T10:00:00.000Z"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "a.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mtime).toBe("2026-02-01T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles fetch error by setting error and clearing content/mtime", async () => {
|
||||
mockFetchFileContent.mockRejectedValueOnce(new Error("load failed"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "missing.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe("load failed");
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
|
||||
it("setContent updates content and clears error", async () => {
|
||||
mockFetchFileContent.mockRejectedValueOnce(new Error("broken"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "bad.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("broken");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("new content");
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe("new content");
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("hasChanges is false when content equals originalContent", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("same"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "same.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("hasChanges is true when content differs from originalContent", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("start"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "edit.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe("start");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("changed");
|
||||
});
|
||||
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it("save calls saveFileContent with taskId, filePath, and current content", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("original"));
|
||||
mockSaveFileContent.mockResolvedValueOnce(saveResponse());
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("updated");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveFileContent).toHaveBeenCalledWith("FN-001", "file.txt", "updated");
|
||||
});
|
||||
|
||||
it("save updates originalContent and mtime after success", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("before", "2026-01-01T00:00:00.000Z"));
|
||||
mockSaveFileContent.mockResolvedValueOnce(saveResponse("2026-02-01T00:00:00.000Z"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("after");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(result.current.originalContent).toBe("after");
|
||||
expect(result.current.mtime).toBe("2026-02-01T00:00:00.000Z");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it("save sets error on failure and re-throws", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("before"));
|
||||
mockSaveFileContent.mockRejectedValueOnce(new Error("save failed"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("changed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.save()).rejects.toThrow("save failed");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("save failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("save is a no-op when hasChanges is false", async () => {
|
||||
mockFetchFileContent.mockResolvedValueOnce(contentResponse("same"));
|
||||
|
||||
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels in-flight load on filePath change and ignores stale response", async () => {
|
||||
const first = deferred<FileContentResponse>();
|
||||
const second = deferred<FileContentResponse>();
|
||||
|
||||
mockFetchFileContent
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filePath }) => useFileEditor("FN-001", filePath, true),
|
||||
{ initialProps: { filePath: "first.txt" as string | null } },
|
||||
);
|
||||
|
||||
rerender({ filePath: "second.txt" });
|
||||
|
||||
second.resolve(contentResponse("second-content", "2026-03-02T00:00:00.000Z"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe("second-content");
|
||||
expect(result.current.mtime).toBe("2026-03-02T00:00:00.000Z");
|
||||
});
|
||||
|
||||
first.resolve(contentResponse("stale-first", "2026-03-01T00:00:00.000Z"));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(result.current.content).toBe("second-content");
|
||||
expect(result.current.mtime).toBe("2026-03-02T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
251
packages/dashboard/app/hooks/__tests__/useProjectHealth.test.ts
Normal file
251
packages/dashboard/app/hooks/__tests__/useProjectHealth.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useProjectHealth } from "../useProjectHealth";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectHealth } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectHealth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectHealth = vi.mocked(api.fetchProjectHealth);
|
||||
|
||||
function createHealth(projectId: string, overrides: Partial<ProjectHealth> = {}): ProjectHealth {
|
||||
return {
|
||||
projectId,
|
||||
status: "active",
|
||||
activeTaskCount: 1,
|
||||
inFlightAgentCount: 0,
|
||||
totalTasksCompleted: 10,
|
||||
totalTasksFailed: 1,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function renderUseProjectHealth(projectIds: string[]) {
|
||||
return renderHook(({ ids }) => useProjectHealth(ids), {
|
||||
initialProps: { ids: projectIds },
|
||||
});
|
||||
}
|
||||
|
||||
describe("useProjectHealth", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchProjectHealth.mockReset();
|
||||
mockFetchProjectHealth.mockImplementation(async (id: string) => createHealth(id));
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty healthMap and no error when projectIds is empty", async () => {
|
||||
const { result } = renderUseProjectHealth([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.healthMap).toEqual({});
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockFetchProjectHealth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches health for each project ID and populates healthMap", async () => {
|
||||
const ids = ["p1", "p2", "p3"];
|
||||
const { result } = renderUseProjectHealth(ids);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.healthMap).toEqual({
|
||||
p1: createHealth("p1"),
|
||||
p2: createHealth("p2"),
|
||||
p3: createHealth("p3"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("sets loading true during fetch and false after completion", async () => {
|
||||
const pending = deferred<ProjectHealth>();
|
||||
mockFetchProjectHealth.mockReturnValueOnce(pending.promise);
|
||||
|
||||
const { result } = renderUseProjectHealth(["p1"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
pending.resolve(createHealth("p1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles individual project fetch failures gracefully", async () => {
|
||||
mockFetchProjectHealth.mockImplementation(async (id: string) => {
|
||||
if (id === "p2") {
|
||||
throw new Error("fetch failed");
|
||||
}
|
||||
return createHealth(id);
|
||||
});
|
||||
|
||||
const { result } = renderUseProjectHealth(["p1", "p2", "p3"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.healthMap).toEqual({
|
||||
p1: createHealth("p1"),
|
||||
p2: null,
|
||||
p3: createHealth("p3"),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("batches fetches with 5 concurrent per batch", async () => {
|
||||
const firstBatchDeferred = Array.from({ length: 5 }, () => deferred<ProjectHealth>());
|
||||
const called: string[] = [];
|
||||
|
||||
mockFetchProjectHealth.mockImplementation((id: string) => {
|
||||
called.push(id);
|
||||
const index = Number(id.slice(1)) - 1;
|
||||
if (index < 5) return firstBatchDeferred[index].promise;
|
||||
return Promise.resolve(createHealth(id));
|
||||
});
|
||||
|
||||
const ids = ["p1", "p2", "p3", "p4", "p5", "p6", "p7"];
|
||||
renderUseProjectHealth(ids);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(called).toEqual(["p1", "p2", "p3", "p4", "p5"]);
|
||||
});
|
||||
|
||||
expect(called).not.toContain("p6");
|
||||
expect(called).not.toContain("p7");
|
||||
|
||||
firstBatchDeferred.forEach((d, idx) => {
|
||||
d.resolve(createHealth(`p${idx + 1}`));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(called).toContain("p6");
|
||||
expect(called).toContain("p7");
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh aborts in-flight requests when called again", async () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
const pending = deferred<ProjectHealth>();
|
||||
mockFetchProjectHealth.mockReturnValue(pending.promise);
|
||||
|
||||
const { result } = renderUseProjectHealth(["p1"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
void result.current.refresh();
|
||||
});
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
|
||||
pending.resolve(createHealth("p1"));
|
||||
});
|
||||
|
||||
it("refreshProject updates a single project's health", async () => {
|
||||
mockFetchProjectHealth.mockImplementation(async (id: string) => createHealth(id));
|
||||
|
||||
const { result } = renderUseProjectHealth(["p1", "p2"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.healthMap.p1).toEqual(createHealth("p1"));
|
||||
expect(result.current.healthMap.p2).toEqual(createHealth("p2"));
|
||||
});
|
||||
|
||||
mockFetchProjectHealth.mockResolvedValueOnce(
|
||||
createHealth("p2", { activeTaskCount: 99, totalTasksCompleted: 42 }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshProject("p2");
|
||||
});
|
||||
|
||||
expect(result.current.healthMap.p2).toEqual(
|
||||
createHealth("p2", { activeTaskCount: 99, totalTasksCompleted: 42 }),
|
||||
);
|
||||
expect(result.current.healthMap.p1).toEqual(createHealth("p1"));
|
||||
});
|
||||
|
||||
it("polling sets up interval and refreshes every 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchProjectHealth.mockResolvedValue(createHealth("p1"));
|
||||
|
||||
renderUseProjectHealth(["p1"]);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("polling clears interval on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchProjectHealth.mockResolvedValue(createHealth("p1"));
|
||||
|
||||
const { unmount } = renderUseProjectHealth(["p1"]);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleanup aborts in-flight requests on unmount", async () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
const pending = deferred<ProjectHealth>();
|
||||
mockFetchProjectHealth.mockReturnValue(pending.promise);
|
||||
|
||||
const { unmount } = renderUseProjectHealth(["p1"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
|
||||
pending.resolve(createHealth("p1"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user