diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
new file mode 100644
index 0000000..a949750
--- /dev/null
+++ b/frontend/src/App.test.tsx
@@ -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();
+ }
+
+ 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();
+ });
+ });
+});
diff --git a/frontend/src/components/GatePanel.test.tsx b/frontend/src/components/GatePanel.test.tsx
new file mode 100644
index 0000000..fa53004
--- /dev/null
+++ b/frontend/src/components/GatePanel.test.tsx
@@ -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();
+ expect(screen.getByText("No workflow data yet.")).toBeInTheDocument();
+ });
+
+ it("shows loading message when isGateLoading is true", () => {
+ render();
+ expect(
+ screen.getByText("Loading workflow gates..."),
+ ).toBeInTheDocument();
+ });
+
+ it("shows error with retry button", async () => {
+ const onRefresh = vi.fn();
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+ expect(screen.getByText("Blocked")).toBeInTheDocument();
+ });
+
+ it("shows test summary when gateState is provided", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText(/5\/5 passing, 0 failing/),
+ ).toBeInTheDocument();
+ });
+
+ it("shows missing categories", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("Missing: unit, integration"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows warning text", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("Multiple tests failing — fix one at a time."),
+ ).toBeInTheDocument();
+ });
+
+ it("shows reasons as list items", () => {
+ render(
+ ,
+ );
+ 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();
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Refresh" }),
+ );
+ expect(onRefresh).toHaveBeenCalledOnce();
+ });
+
+ it("disables Refresh button when loading", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
+ });
+});
diff --git a/frontend/src/components/ReviewPanel.test.tsx b/frontend/src/components/ReviewPanel.test.tsx
new file mode 100644
index 0000000..00e00bb
--- /dev/null
+++ b/frontend/src/components/ReviewPanel.test.tsx
@@ -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();
+ expect(
+ screen.getByText("No stories waiting for review."),
+ ).toBeInTheDocument();
+ });
+
+ it("shows loading state", () => {
+ render();
+ expect(screen.getByText("Loading review queue...")).toBeInTheDocument();
+ });
+
+ it("shows error with retry button", async () => {
+ const onRefresh = vi.fn();
+ render(
+ ,
+ );
+
+ 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();
+
+ 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();
+
+ 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();
+ expect(screen.getByText("Failing 2")).toBeInTheDocument();
+ });
+
+ it("shows warning badge", () => {
+ render();
+ expect(screen.getByText("Warning")).toBeInTheDocument();
+ });
+
+ it("shows test summary per story", () => {
+ render();
+ expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument();
+ });
+
+ it("shows missing categories", () => {
+ const missingStory: ReviewStory = {
+ ...blockedStory,
+ missing_categories: ["unit", "integration"],
+ };
+ render();
+ expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
+ });
+
+ it("calls onProceed when Proceed is clicked", async () => {
+ const onProceed = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
+ expect(onProceed).toHaveBeenCalledWith("29_backfill_tests");
+ });
+
+ it("shows queue counts in header", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument();
+ });
+
+ it("shows proceedError message", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("Acceptance blocked: tests failing"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows proceedSuccess message", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Story accepted successfully")).toBeInTheDocument();
+ });
+
+ it("shows reasons as list items", () => {
+ render();
+ expect(screen.getByText("2 tests are failing.")).toBeInTheDocument();
+ });
+});