Story 26: Establish TDD workflow and quality gates
Add workflow engine with acceptance gates, test recording, and review queue. Frontend displays gate status (blocked/ready), test summaries, failing badges, and warnings. Proceed action is disabled when gates are not met. Includes 13 unit tests (Vitest) and 9 E2E tests (Playwright) covering all five acceptance criteria. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
393
frontend/src/components/Chat.test.tsx
Normal file
393
frontend/src/components/Chat.test.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
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";
|
||||
import type { ReviewStory } from "../api/workflow";
|
||||
import { workflowApi } from "../api/workflow";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
vi.mock("../api/client", () => {
|
||||
const api = {
|
||||
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(),
|
||||
getReviewQueue: vi.fn(),
|
||||
getReviewQueueAll: vi.fn(),
|
||||
ensureAcceptance: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockedApi = {
|
||||
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||
getAnthropicModels: vi.mocked(api.getAnthropicModels),
|
||||
getModelPreference: vi.mocked(api.getModelPreference),
|
||||
setModelPreference: vi.mocked(api.setModelPreference),
|
||||
cancelChat: vi.mocked(api.cancelChat),
|
||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||
};
|
||||
|
||||
const mockedWorkflow = {
|
||||
getAcceptance: vi.mocked(workflowApi.getAcceptance),
|
||||
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
||||
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||||
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||||
};
|
||||
|
||||
describe("Chat review panel", () => {
|
||||
beforeEach(() => {
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||
|
||||
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||
can_accept: false,
|
||||
reasons: ["No test results recorded for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("shows an empty review queue state", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Stories Awaiting Review"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("0 ready / 0 total")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("No stories waiting for review."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const updatedLabels = await screen.findAllByText(/Updated/i);
|
||||
expect(updatedLabels.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("renders review stories and proceeds", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 3, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll
|
||||
.mockResolvedValueOnce({ stories: [story] })
|
||||
.mockResolvedValueOnce({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||||
|
||||
const proceedButton = screen.getByRole("button", { name: "Proceed" });
|
||||
await userEvent.click(proceedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||||
story_id: story.story_id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("No stories waiting for review."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a review error when the queue fails to load", async () => {
|
||||
mockedWorkflow.getReviewQueueAll.mockRejectedValueOnce(
|
||||
new Error("Review queue failed"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(/Review queue failed/i)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/Use Refresh to try again\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Retry" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes the review queue when clicking refresh", async () => {
|
||||
mockedWorkflow.getReviewQueueAll
|
||||
.mockResolvedValueOnce({ stories: [] })
|
||||
.mockResolvedValueOnce({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const refreshButtons = await screen.findAllByRole("button", {
|
||||
name: "Refresh",
|
||||
});
|
||||
const refreshButton = refreshButtons[0];
|
||||
|
||||
await userEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedWorkflow.getReviewQueueAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables proceed when a story is blocked", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: false,
|
||||
reasons: ["Missing unit tests"],
|
||||
warning: null,
|
||||
summary: { total: 1, passed: 0, failed: 1 },
|
||||
missing_categories: ["unit"],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||||
|
||||
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||||
expect(blockedButton).toBeDisabled();
|
||||
|
||||
expect(await screen.findByText("Missing: unit")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Missing unit tests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows gate panel blocked status with reasons (AC1/AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["No approved test plan for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("No approved test plan for the story."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Missing: unit, integration"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/0\/0 passing, 0 failing/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows gate panel ready status when all tests pass (AC1/AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/5\/5 passing, 0 failing/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows failing badge and count in review panel (AC4/AC5)", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: false,
|
||||
reasons: ["3 tests are failing."],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 5, passed: 2, failed: 3 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Failing 3")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Warning")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("3 tests are failing.")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/2\/5 passing, 3 failing/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||||
expect(blockedButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows gate warning when multiple tests fail (AC5)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["2 tests are failing."],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 4, passed: 2, failed: 2 },
|
||||
missing_categories: [],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/2\/4 passing, 2 failing/),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("2 tests are failing.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call ensureAcceptance when clicking a blocked proceed button (AC4)", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: false,
|
||||
reasons: ["Tests are failing."],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 1, failed: 2 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const blockedButton = await screen.findByRole("button", {
|
||||
name: "Blocked",
|
||||
});
|
||||
expect(blockedButton).toBeDisabled();
|
||||
|
||||
// Clear any prior calls then attempt click on disabled button
|
||||
mockedWorkflow.ensureAcceptance.mockClear();
|
||||
await userEvent.click(blockedButton);
|
||||
|
||||
expect(mockedWorkflow.ensureAcceptance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows proceed error when ensureAcceptance fails", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 3, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
mockedWorkflow.ensureAcceptance.mockRejectedValueOnce(
|
||||
new Error("Acceptance blocked: tests still failing"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const proceedButton = await screen.findByRole("button", {
|
||||
name: "Proceed",
|
||||
});
|
||||
await userEvent.click(proceedButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Acceptance blocked: tests still failing"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows gate error when acceptance endpoint fails", async () => {
|
||||
mockedWorkflow.getAcceptance.mockRejectedValueOnce(
|
||||
new Error("Server unreachable"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Server unreachable")).toBeInTheDocument();
|
||||
|
||||
const retryButtons = await screen.findAllByRole("button", {
|
||||
name: "Retry",
|
||||
});
|
||||
expect(retryButtons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("refreshes gate status after proceeding on the current story", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 2, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getAcceptance
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["No test results recorded for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 2, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
|
||||
mockedWorkflow.getReviewQueueAll
|
||||
.mockResolvedValueOnce({ stories: [story] })
|
||||
.mockResolvedValueOnce({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const proceedButton = await screen.findByRole("button", {
|
||||
name: "Proceed",
|
||||
});
|
||||
await userEvent.click(proceedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||||
story_id: story.story_id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user