Files
fusion/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx

388 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { DiscoveryResult } from "../../artifacts/discovery.js";
// Mock the network layer so the view renders from seeded discovery results.
const listArtifacts = vi.fn(async (_projectId?: string): Promise<DiscoveryResult> => {
throw new Error("listArtifacts mock not configured");
});
const listSessions = vi.fn(async (): Promise<CeSession[]> => []);
const deleteSession = vi.fn(async (_id: string, _projectId?: string): Promise<void> => undefined);
const cancelSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => {
throw new Error("cancelSession mock not configured");
});
const getSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => {
throw new Error("getSession mock not configured");
});
const startSession = vi.fn(async (_stage: string, _opts?: unknown): Promise<CeSession> => {
throw new Error("startSession mock not configured");
});
vi.mock("../hooks/api.js", () => ({
listArtifacts: (projectId?: string) => listArtifacts(projectId),
getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
listSessions: () => listSessions(),
deleteSession: (id: string, projectId?: string) => deleteSession(id, projectId),
cancelSession: (id: string, projectId?: string) => cancelSession(id, projectId),
getSession: (id: string, projectId?: string) => getSession(id, projectId),
startSession: (stage: string, opts?: unknown) => startSession(stage, opts),
answerSession: vi.fn(),
resumeSession: vi.fn(),
}));
import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
import type { CeSession } from "../../session/session-store.js";
function mkCeSession(over: Partial<CeSession>): CeSession {
return {
id: "sess-1",
stage: "brainstorm",
status: "awaiting_input",
currentQuestion: null,
conversationHistory: [],
projectId: "p1",
artifactPath: null,
error: null,
turnIntervalMs: 1000,
lastActivityAt: Date.now(),
createdAt: "2026-06-03T00:00:00Z",
updatedAt: "2026-06-03T00:00:00Z",
...over,
};
}
const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [
{ stage: "strategy", label: "Strategy" },
{ stage: "ideate", label: "Ideate" },
{ stage: "plan", label: "Brainstorm / Plan" },
{ stage: "work", label: "Work" },
{ stage: "debug", label: "Debug" },
{ stage: "solution", label: "Solutions" },
{ stage: "concepts", label: "Concepts" },
];
function makeResult(overrides: Partial<Record<DiscoveryResult["groups"][number]["stage"], DiscoveryResult["groups"][number]["entries"]>>): DiscoveryResult {
const groups = ALL_STAGES.map(({ stage, label }) => ({
stage,
label,
present: Boolean(overrides[stage]?.length),
entries: overrides[stage] ?? [],
}));
let totalArtifacts = 0;
let totalErrors = 0;
for (const g of groups) {
for (const e of g.entries) {
if (e.kind === "artifact") totalArtifacts += 1;
else totalErrors += 1;
}
}
return { groups, totalArtifacts, totalErrors };
}
describe("CompoundEngineeringView", () => {
beforeEach(() => {
window.history.replaceState({}, "", "/");
__test_clearArtifactsCache();
listArtifacts.mockReset();
listSessions.mockReset();
listSessions.mockResolvedValue([]);
deleteSession.mockReset();
deleteSession.mockResolvedValue(undefined);
cancelSession.mockReset();
cancelSession.mockImplementation(async (id: string, projectId?: string) => mkCeSession({ id, projectId: projectId ?? null, status: "interrupted", error: "Cancelled by user" }));
getSession.mockReset();
startSession.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("renders the empty / first-run state with an orientation + start action", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-empty-state");
expect(listArtifacts).toHaveBeenCalledWith("p1");
expect(screen.getByText(/Start your compounding pipeline/i)).toBeInTheDocument();
const start = screen.getByTestId("ce-start-action");
expect(start).toBeInTheDocument();
// Start affordance is wired to a placeholder (toast); clicking does not throw.
fireEvent.click(start);
});
it("renders the partial-discovery state (some categories present, others empty)", async () => {
listArtifacts.mockResolvedValue(
makeResult({
strategy: [
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
],
plan: [
{ kind: "artifact", id: "plan:docs/plans/p.md", stage: "plan", path: "docs/plans/p.md", name: "p.md", size: 5, updatedAt: 2 },
],
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-summary");
// Partial flag surfaces in the summary and on the groups container.
expect(screen.getByTestId("ce-summary").textContent).toMatch(/partial/i);
const groups = screen.getByTestId("ce-summary").closest(".ce-view")!.querySelector(".ce-groups");
expect(groups?.getAttribute("data-partial")).toBe("true");
// Populated groups render artifacts; empty ones render an empty hint.
expect(screen.getAllByTestId("ce-artifact")).toHaveLength(2);
expect(screen.getAllByTestId("ce-group-empty").length).toBeGreaterThan(0);
});
it("opens an artifact in the built-in file viewer via context.openFile", async () => {
const openFile = vi.fn();
listArtifacts.mockResolvedValue(
makeResult({
strategy: [
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
],
}),
);
render(
<CompoundEngineeringView
projectId="p1"
enabledOverride
context={{ openFile, tasks: [], workflowSteps: [], openTaskDetail: vi.fn() }}
/>,
);
await screen.findByTestId("ce-artifact");
fireEvent.click(screen.getByTestId("ce-artifact-open"));
expect(openFile).toHaveBeenCalledWith("STRATEGY.md", { workspace: "project" });
});
it("renders artifact open button without crashing when openFile is not in context", async () => {
listArtifacts.mockResolvedValue(
makeResult({
strategy: [
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
],
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-artifact");
const open = screen.getByTestId("ce-artifact-open");
expect(open).toBeInTheDocument();
fireEvent.click(open);
});
it("renders an error entry for an unreadable artifact (not a crash or silent drop)", async () => {
listArtifacts.mockResolvedValue(
makeResult({
plan: [
{ kind: "error", id: "plan:docs/plans/bad.md", stage: "plan", path: "docs/plans/bad.md", name: "bad.md", error: "EIO: simulated read failure" },
],
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
const errorEntry = await screen.findByTestId("ce-artifact-error");
expect(errorEntry).toBeInTheDocument();
expect(errorEntry.textContent).toMatch(/simulated read failure/i);
// Surfaced as an unreadable count in the summary.
expect(screen.getByTestId("ce-summary").textContent).toMatch(/unreadable/i);
});
it("lists multiple sessions with status badges; terminal sessions get a discard affordance", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
mkCeSession({ id: "b", stage: "plan", status: "active" }),
mkCeSession({ id: "c", stage: "work", status: "completed" }),
]);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
const rows = screen.getAllByTestId("ce-session-row");
expect(rows).toHaveLength(3);
expect(rows.map((r) => r.getAttribute("data-status"))).toEqual([
"awaiting_input",
"active",
"completed",
]);
// Awaiting sessions advertise that they need the user.
expect(rows[0].textContent).toMatch(/needs your input/i);
// Only non-terminal sessions can be cancelled; only terminal sessions can be discarded.
const cancelButtons = screen.getAllByTestId("ce-session-cancel");
expect(cancelButtons).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Cancel session" })).toHaveLength(2);
for (const cancelButton of cancelButtons) {
expect(cancelButton).toHaveAccessibleName("Cancel session");
expect(cancelButton).toHaveAttribute("title", "Cancel session");
expect(cancelButton).not.toHaveTextContent(/cancel/i);
}
expect(screen.getAllByTestId("ce-session-discard")).toHaveLength(1);
expect(rows[0].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument();
expect(rows[1].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument();
expect(rows[2].querySelector("[data-testid='ce-session-cancel']")).not.toBeInTheDocument();
expect(screen.getByTestId("ce-resume-latest")).toHaveTextContent(/input needed/i);
expect(screen.getAllByTestId("ce-pipeline-stage")).toHaveLength(5);
});
it("renders no cancel affordance for an empty sessions list", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([]);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-empty-state");
expect(screen.queryByTestId("ce-session-cancel")).not.toBeInTheDocument();
});
it("opens an existing session from the list into the flow (and back without losing it)", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
mkCeSession({ id: "b", stage: "plan", status: "active" }),
]);
getSession.mockResolvedValue(
mkCeSession({
id: "a",
status: "awaiting_input",
currentQuestion: { id: "q1", type: "text", question: "Topic?" },
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
fireEvent.click(screen.getAllByTestId("ce-session-open")[0]);
// The flow surface opens on the adopted session…
const flow = await screen.findByTestId("ce-flow");
expect(flow.getAttribute("data-stage")).toBe("brainstorm");
expect(getSession).toHaveBeenCalledWith("a", "p1");
// …while the sessions panel stays visible for switching, with the open
// session marked active.
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
const rows = screen.getAllByTestId("ce-session-row");
expect(rows[0].className).toMatch(/is-active/);
// Closing returns to the overview; the session list survives (the session
// itself keeps running server-side — close does not delete anything).
fireEvent.click(screen.getByText("Close"));
await screen.findByTestId("ce-empty-state");
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
expect(deleteSession).not.toHaveBeenCalled();
});
it("cancels an in-flight session via the list", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([mkCeSession({ id: "running", stage: "plan", status: "active" })]);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
listSessions.mockResolvedValue([mkCeSession({ id: "running", stage: "plan", status: "interrupted", error: "Cancelled by user" })]);
fireEvent.click(screen.getByTestId("ce-session-cancel"));
await waitFor(() => expect(cancelSession).toHaveBeenCalledWith("running", "p1"));
await waitFor(() => expect(screen.queryByTestId("ce-session-cancel")).not.toBeInTheDocument());
expect(screen.getByTestId("ce-session-discard")).toBeInTheDocument();
});
it("cancels an open flow and returns to the refreshed sessions overview", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([mkCeSession({ id: "flow", stage: "plan", status: "active" })]);
getSession.mockResolvedValue(mkCeSession({ id: "flow", stage: "plan", status: "active" }));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
fireEvent.click(screen.getByTestId("ce-session-open"));
await screen.findByTestId("ce-flow");
expect(new URLSearchParams(window.location.search).get("ceSession")).toBe("flow");
listSessions.mockResolvedValue([mkCeSession({ id: "flow", stage: "plan", status: "interrupted", error: "Cancelled by user" })]);
fireEvent.click(screen.getByTestId("ce-flow-cancel"));
await waitFor(() => expect(cancelSession).toHaveBeenCalledWith("flow", "p1"));
await waitFor(() => expect(screen.queryByTestId("ce-flow")).not.toBeInTheDocument());
expect(new URLSearchParams(window.location.search).has("ceSession")).toBe(false);
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
expect(screen.getByTestId("ce-session-discard")).toBeInTheDocument();
});
it("writes a newly launched session to the URL", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
startSession.mockResolvedValue(mkCeSession({ id: "new-strategy", stage: "strategy", status: "active" }));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
fireEvent.click(await screen.findByTestId("ce-start-action"));
const strategy = (await screen.findAllByTestId("ce-launcher-stage")).find(
(node) => node.getAttribute("data-stage") === "strategy",
)!;
fireEvent.click(strategy);
await screen.findByTestId("ce-flow");
expect(new URLSearchParams(window.location.search).get("ceSession")).toBe("new-strategy");
});
it("discards a terminal session via the list", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([mkCeSession({ id: "done", stage: "plan", status: "completed" })]);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-sessions");
listSessions.mockResolvedValue([]);
fireEvent.click(screen.getByTestId("ce-session-discard"));
expect(deleteSession).not.toHaveBeenCalled();
expect(screen.getByTestId("ce-discard-confirm")).toHaveTextContent(/delete permanently/i);
fireEvent.click(screen.getByRole("button", { name: "Delete permanently" }));
await waitFor(() => expect(deleteSession).toHaveBeenCalledWith("done", "p1"));
await waitFor(() => expect(screen.queryByTestId("ce-sessions")).not.toBeInTheDocument());
});
it("shows unified plan readiness and derives rail completion from artifacts", async () => {
listArtifacts.mockResolvedValue(makeResult({
plan: [{
kind: "artifact",
id: "plan:docs/plans/ready.md",
stage: "plan",
path: "docs/plans/ready.md",
name: "ready.md",
size: 10,
updatedAt: 2,
artifactContract: "ce-unified-plan/v1",
artifactReadiness: "implementation-ready",
}],
}));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
expect(await screen.findByText("Implementation ready")).toBeInTheDocument();
const plan = screen.getAllByTestId("ce-pipeline-stage").find((node) => node.getAttribute("data-stage") === "plan")!;
expect(plan).toHaveTextContent("Complete");
const brainstorm = screen.getAllByTestId("ce-pipeline-stage").find((node) => node.getAttribute("data-stage") === "brainstorm")!;
expect(brainstorm).toHaveTextContent("Complete");
});
it("keeps Plan incomplete when only a requirements artifact exists", async () => {
listArtifacts.mockResolvedValue(makeResult({
plan: [{
kind: "artifact",
id: "plan:docs/plans/requirements.md",
stage: "plan",
path: "docs/plans/requirements.md",
name: "requirements.md",
size: 10,
updatedAt: 2,
artifactContract: "ce-unified-plan/v1",
artifactReadiness: "requirements-only",
}],
}));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
const stages = await screen.findAllByTestId("ce-pipeline-stage");
expect(stages.find((node) => node.getAttribute("data-stage") === "brainstorm")).toHaveTextContent("Complete");
expect(stages.find((node) => node.getAttribute("data-stage") === "plan")).toHaveTextContent("Not started");
});
it("does not fetch when the viewport-gated flag is disabled", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
render(<CompoundEngineeringView projectId="p1" enabledOverride={false} />);
// Give effects a tick.
await waitFor(() => expect(screen.getByTestId("compound-engineering-view")).toBeInTheDocument());
expect(listArtifacts).not.toHaveBeenCalled();
});
});