WIP: Batch 4 — App, GatePanel, ReviewPanel frontend tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
158
frontend/src/App.test.tsx
Normal file
158
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api } from "./api/client";
|
||||||
|
|
||||||
|
vi.mock("./api/client", () => {
|
||||||
|
const api = {
|
||||||
|
getCurrentProject: vi.fn(),
|
||||||
|
getKnownProjects: vi.fn(),
|
||||||
|
getHomeDirectory: vi.fn(),
|
||||||
|
openProject: vi.fn(),
|
||||||
|
closeProject: vi.fn(),
|
||||||
|
forgetKnownProject: vi.fn(),
|
||||||
|
listDirectoryAbsolute: vi.fn(),
|
||||||
|
getOllamaModels: vi.fn(),
|
||||||
|
getAnthropicApiKeyExists: vi.fn(),
|
||||||
|
getAnthropicModels: vi.fn(),
|
||||||
|
getModelPreference: vi.fn(),
|
||||||
|
setModelPreference: vi.fn(),
|
||||||
|
cancelChat: vi.fn(),
|
||||||
|
setAnthropicApiKey: vi.fn(),
|
||||||
|
};
|
||||||
|
class ChatWebSocket {
|
||||||
|
connect() {}
|
||||||
|
close() {}
|
||||||
|
sendChat() {}
|
||||||
|
cancel() {}
|
||||||
|
}
|
||||||
|
return { api, ChatWebSocket };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./api/workflow", () => {
|
||||||
|
return {
|
||||||
|
workflowApi: {
|
||||||
|
getAcceptance: vi.fn().mockResolvedValue({
|
||||||
|
can_accept: false,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
}),
|
||||||
|
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
|
||||||
|
recordTests: vi.fn(),
|
||||||
|
ensureAcceptance: vi.fn(),
|
||||||
|
getReviewQueue: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedApi = vi.mocked(api);
|
||||||
|
|
||||||
|
describe("App", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedApi.getKnownProjects.mockResolvedValue([]);
|
||||||
|
mockedApi.getHomeDirectory.mockResolvedValue("/home/user");
|
||||||
|
mockedApi.listDirectoryAbsolute.mockResolvedValue([]);
|
||||||
|
mockedApi.getOllamaModels.mockResolvedValue([]);
|
||||||
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||||
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||||
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderApp() {
|
||||||
|
const { default: App } = await import("./App");
|
||||||
|
return render(<App />);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders the selection screen when no project is open", async () => {
|
||||||
|
await renderApp();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("populates path input with home directory", async () => {
|
||||||
|
mockedApi.getHomeDirectory.mockResolvedValue("/Users/dave");
|
||||||
|
|
||||||
|
await renderApp();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const input = screen.getByPlaceholderText(
|
||||||
|
/\/path\/to\/project/i,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input.value).toBe("/Users/dave/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens project and shows chat view", async () => {
|
||||||
|
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
|
||||||
|
|
||||||
|
await renderApp();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(
|
||||||
|
/\/path\/to\/project/i,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "/home/user/myproject");
|
||||||
|
|
||||||
|
const openButton = screen.getByRole("button", { name: /open project/i });
|
||||||
|
await userEvent.click(openButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.openProject).toHaveBeenCalledWith(
|
||||||
|
"/home/user/myproject",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when openProject fails", async () => {
|
||||||
|
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
|
||||||
|
|
||||||
|
await renderApp();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(
|
||||||
|
/\/path\/to\/project/i,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "/bad/path");
|
||||||
|
|
||||||
|
const openButton = screen.getByRole("button", { name: /open project/i });
|
||||||
|
await userEvent.click(openButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows known projects list", async () => {
|
||||||
|
mockedApi.getKnownProjects.mockResolvedValue([
|
||||||
|
"/home/user/project1",
|
||||||
|
"/home/user/project2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await renderApp();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTitle("/home/user/project1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle("/home/user/project2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
145
frontend/src/components/GatePanel.test.tsx
Normal file
145
frontend/src/components/GatePanel.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { GatePanel } from "./GatePanel";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
gateState: null,
|
||||||
|
gateStatusLabel: "Unknown",
|
||||||
|
gateStatusColor: "#aaa",
|
||||||
|
isGateLoading: false,
|
||||||
|
gateError: null,
|
||||||
|
lastGateRefresh: null,
|
||||||
|
onRefresh: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("GatePanel", () => {
|
||||||
|
it("shows 'no workflow data' when gateState is null", () => {
|
||||||
|
render(<GatePanel {...baseProps} />);
|
||||||
|
expect(screen.getByText("No workflow data yet.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading message when isGateLoading is true", () => {
|
||||||
|
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Loading workflow gates..."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error with retry button", async () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
render(
|
||||||
|
<GatePanel
|
||||||
|
{...baseProps}
|
||||||
|
gateError="Connection failed"
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Connection failed")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole("button", { name: "Retry" });
|
||||||
|
await userEvent.click(retryButton);
|
||||||
|
expect(onRefresh).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows gate status label and color", () => {
|
||||||
|
render(
|
||||||
|
<GatePanel
|
||||||
|
{...baseProps}
|
||||||
|
gateStatusLabel="Blocked"
|
||||||
|
gateStatusColor="#ff7b72"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Blocked")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows test summary when gateState is provided", () => {
|
||||||
|
render(
|
||||||
|
<GatePanel
|
||||||
|
{...baseProps}
|
||||||
|
gateState={{
|
||||||
|
canAccept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 5, passed: 5, failed: 0 },
|
||||||
|
missingCategories: [],
|
||||||
|
}}
|
||||||
|
gateStatusLabel="Ready to accept"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/5\/5 passing, 0 failing/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows missing categories", () => {
|
||||||
|
render(
|
||||||
|
<GatePanel
|
||||||
|
{...baseProps}
|
||||||
|
gateState={{
|
||||||
|
canAccept: false,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
|
missingCategories: ["unit", "integration"],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Missing: unit, integration"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows warning text", () => {
|
||||||
|
render(
|
||||||
|
<GatePanel
|
||||||
|
{...baseProps}
|
||||||
|
gateState={{
|
||||||
|
canAccept: false,
|
||||||
|
reasons: [],
|
||||||
|
warning: "Multiple tests failing — fix one at a time.",
|
||||||
|
summary: { total: 4, passed: 2, failed: 2 },
|
||||||
|
missingCategories: [],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Multiple tests failing — fix one at a time."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows reasons as list items", () => {
|
||||||
|
render(
|
||||||
|
<GatePanel
|
||||||
|
{...baseProps}
|
||||||
|
gateState={{
|
||||||
|
canAccept: false,
|
||||||
|
reasons: ["No approved test plan.", "Tests are failing."],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 2, passed: 1, failed: 1 },
|
||||||
|
missingCategories: [],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("No approved test plan."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRefresh when Refresh button is clicked", async () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Refresh" }),
|
||||||
|
);
|
||||||
|
expect(onRefresh).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Refresh button when loading", () => {
|
||||||
|
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||||
|
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
157
frontend/src/components/ReviewPanel.test.tsx
Normal file
157
frontend/src/components/ReviewPanel.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ReviewStory } from "../api/workflow";
|
||||||
|
import { ReviewPanel } from "./ReviewPanel";
|
||||||
|
|
||||||
|
const readyStory: ReviewStory = {
|
||||||
|
story_id: "29_backfill_tests",
|
||||||
|
can_accept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 5, passed: 5, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockedStory: ReviewStory = {
|
||||||
|
story_id: "26_tdd_gates",
|
||||||
|
can_accept: false,
|
||||||
|
reasons: ["2 tests are failing."],
|
||||||
|
warning: "Multiple tests failing — fix one at a time.",
|
||||||
|
summary: { total: 5, passed: 3, failed: 2 },
|
||||||
|
missing_categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
reviewQueue: [] as ReviewStory[],
|
||||||
|
isReviewLoading: false,
|
||||||
|
reviewError: null,
|
||||||
|
proceedingStoryId: null,
|
||||||
|
storyId: "",
|
||||||
|
isGateLoading: false,
|
||||||
|
proceedError: null,
|
||||||
|
proceedSuccess: null,
|
||||||
|
lastReviewRefresh: null,
|
||||||
|
onRefresh: vi.fn(),
|
||||||
|
onProceed: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ReviewPanel", () => {
|
||||||
|
it("shows empty state when no stories", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("No stories waiting for review."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} isReviewLoading={true} />);
|
||||||
|
expect(screen.getByText("Loading review queue...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error with retry button", async () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
render(
|
||||||
|
<ReviewPanel
|
||||||
|
{...baseProps}
|
||||||
|
reviewError="Network error"
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Network error.*Use Refresh to try again\./),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(onRefresh).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ready story with Proceed button", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("29_backfill_tests")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Ready")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Proceed" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders blocked story with disabled button", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("26_tdd_gates")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Blocked")).toHaveLength(2);
|
||||||
|
expect(screen.getByRole("button", { name: "Blocked" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows failing badge with count", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||||
|
expect(screen.getByText("Failing 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows warning badge", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||||
|
expect(screen.getByText("Warning")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows test summary per story", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
|
||||||
|
expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows missing categories", () => {
|
||||||
|
const missingStory: ReviewStory = {
|
||||||
|
...blockedStory,
|
||||||
|
missing_categories: ["unit", "integration"],
|
||||||
|
};
|
||||||
|
render(<ReviewPanel {...baseProps} reviewQueue={[missingStory]} />);
|
||||||
|
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onProceed when Proceed is clicked", async () => {
|
||||||
|
const onProceed = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
<ReviewPanel
|
||||||
|
{...baseProps}
|
||||||
|
reviewQueue={[readyStory]}
|
||||||
|
onProceed={onProceed}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
|
||||||
|
expect(onProceed).toHaveBeenCalledWith("29_backfill_tests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows queue counts in header", () => {
|
||||||
|
render(
|
||||||
|
<ReviewPanel {...baseProps} reviewQueue={[readyStory, blockedStory]} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows proceedError message", () => {
|
||||||
|
render(
|
||||||
|
<ReviewPanel
|
||||||
|
{...baseProps}
|
||||||
|
proceedError="Acceptance blocked: tests failing"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Acceptance blocked: tests failing"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows proceedSuccess message", () => {
|
||||||
|
render(
|
||||||
|
<ReviewPanel
|
||||||
|
{...baseProps}
|
||||||
|
proceedSuccess="Story accepted successfully"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Story accepted successfully")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows reasons as list items", () => {
|
||||||
|
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||||
|
expect(screen.getByText("2 tests are failing.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user