Spike 61: filesystem watcher and UI simplification
Add notify-based filesystem watcher for .story_kit/work/ that auto-commits changes with deterministic messages and broadcasts events over WebSocket. Push full pipeline state (Upcoming, Current, QA, To Merge) to frontend on connect and after every watcher event. Strip dead UI: remove ReviewPanel, GatePanel, TodoPanel, UpcomingPanel and all associated REST polling. Replace with 4 generic StagePanel components driven by WebSocket. Simplify AgentPanel to roster-only. Delete all 11 workflow HTTP endpoints and 16 request/response types from the server. Clean dead code from workflow module. MCP tools call Rust functions directly and need none of the HTTP layer. Net: ~4,100 lines deleted, ~400 added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import { act, 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 type { Message } from "../types";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
@@ -39,21 +36,6 @@ vi.mock("../api/client", () => {
|
||||
return { api, ChatWebSocket };
|
||||
});
|
||||
|
||||
vi.mock("../api/workflow", () => {
|
||||
return {
|
||||
workflowApi: {
|
||||
getAcceptance: vi.fn(),
|
||||
getReviewQueue: vi.fn(),
|
||||
getReviewQueueAll: vi.fn(),
|
||||
ensureAcceptance: vi.fn(),
|
||||
recordCoverage: vi.fn(),
|
||||
collectCoverage: vi.fn(),
|
||||
getStoryTodos: vi.fn(),
|
||||
getUpcomingStories: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockedApi = {
|
||||
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||
@@ -64,587 +46,20 @@ const mockedApi = {
|
||||
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),
|
||||
getStoryTodos: vi.mocked(workflowApi.getStoryTodos),
|
||||
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories),
|
||||
};
|
||||
|
||||
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);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("shows coverage below threshold in gate panel (AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["Coverage below threshold (55.0% < 80.0%)."],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 3, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 55.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 55\.0%/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/threshold: 80\.0%/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Coverage below threshold (55.0% < 80.0%)."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows coverage regression in review panel (AC4)", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "27_protect_tests_and_coverage",
|
||||
can_accept: false,
|
||||
reasons: ["Coverage regression: 90.0% → 82.0% (threshold: 80.0%)."],
|
||||
warning: null,
|
||||
summary: { total: 4, passed: 4, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 82.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: 90.0,
|
||||
},
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Coverage regression: 90.0% → 82.0% (threshold: 80.0%).",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 82\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows green coverage when above threshold (AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 92.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: 90.0,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches upcoming stories on mount and renders panel", async () => {
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
|
||||
stories: [
|
||||
{
|
||||
story_id: "31_view_upcoming",
|
||||
name: "View Upcoming Stories",
|
||||
error: null,
|
||||
},
|
||||
{ story_id: "32_worktree", name: null, error: null },
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument();
|
||||
// Both AgentPanel and ReviewPanel display story names, so multiple elements are expected
|
||||
const storyNameElements = await screen.findAllByText(
|
||||
"View Upcoming Stories",
|
||||
);
|
||||
expect(storyNameElements.length).toBeGreaterThan(0);
|
||||
const worktreeElements = await screen.findAllByText("32_worktree");
|
||||
expect(worktreeElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("collect coverage button triggers collection and refreshes gate", async () => {
|
||||
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
|
||||
mockedCollectCoverage.mockResolvedValueOnce({
|
||||
current_percent: 85.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
});
|
||||
|
||||
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: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 85.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const collectButton = await screen.findByRole("button", {
|
||||
name: "Collect Coverage",
|
||||
});
|
||||
await userEvent.click(collectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCollectCoverage).toHaveBeenCalledWith({
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Coverage: 85\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows story TODOs when unchecked criteria exist", async () => {
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
|
||||
stories: [
|
||||
{
|
||||
story_id: "28_ui_show_test_todos",
|
||||
story_name: "Show Remaining Test TODOs in the UI",
|
||||
todos: [
|
||||
"The UI lists unchecked acceptance criteria.",
|
||||
"Each TODO is displayed as its full text.",
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("The UI lists unchecked acceptance criteria."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Each TODO is displayed as its full text."),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("2 remaining")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows completion message when all criteria are checked", async () => {
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
|
||||
stories: [
|
||||
{
|
||||
story_id: "28_ui_show_test_todos",
|
||||
story_name: "Show Remaining Test TODOs in the UI",
|
||||
todos: [],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("All acceptance criteria complete."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows TODO error when endpoint fails", async () => {
|
||||
mockedWorkflow.getStoryTodos.mockRejectedValueOnce(
|
||||
new Error("Cannot read stories"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Cannot read stories")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch Anthropic models when no API key exists", async () => {
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||
mockedApi.getAnthropicModels.mockClear();
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
function setupMocks() {
|
||||
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);
|
||||
}
|
||||
|
||||
describe("Chat message rendering — unified tool call UI", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
|
||||
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders tool call badge for assistant message with tool_calls (AC3)", async () => {
|
||||
@@ -675,7 +90,6 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText("I'll read that file.")).toBeInTheDocument();
|
||||
// Tool call badge should appear showing the function name and first arg
|
||||
expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -709,7 +123,6 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
capturedWsHandlers?.onUpdate(messages);
|
||||
});
|
||||
|
||||
// Tool output section should be collapsible
|
||||
expect(await screen.findByText(/Tool Output/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("The file contains a main function."),
|
||||
@@ -733,7 +146,6 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
expect(
|
||||
await screen.findByText("Hi there! How can I help?"),
|
||||
).toBeInTheDocument();
|
||||
// No tool call badges should appear
|
||||
expect(screen.queryByText(/Tool Output/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -769,30 +181,25 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch Anthropic models when no API key exists", async () => {
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||
mockedApi.getAnthropicModels.mockClear();
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat two-column layout", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
|
||||
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders left and right column containers (AC1, AC2)", async () => {
|
||||
@@ -812,13 +219,11 @@ describe("Chat two-column layout", () => {
|
||||
});
|
||||
|
||||
it("renders panels inside the right column (AC2)", async () => {
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const rightColumn = await screen.findByTestId("chat-right-column");
|
||||
const reviewPanel = await screen.findByText("Stories Awaiting Review");
|
||||
expect(rightColumn).toContainElement(reviewPanel);
|
||||
const agentsPanel = await screen.findByText("Agents");
|
||||
expect(rightColumn).toContainElement(agentsPanel);
|
||||
});
|
||||
|
||||
it("uses row flex-direction on wide screens (AC3)", async () => {
|
||||
|
||||
Reference in New Issue
Block a user