Integrate Claude Code provider into the chat UI alongside regular Ollama/Anthropic providers. Updates AgentPanel and Chat components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
773 lines
23 KiB
TypeScript
773 lines
23 KiB
TypeScript
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";
|
|
|
|
// Module-level store for the WebSocket handlers captured during connect().
|
|
// Tests in the "message rendering" suite use this to simulate incoming messages.
|
|
type WsHandlers = {
|
|
onToken: (content: string) => void;
|
|
onUpdate: (history: Message[]) => void;
|
|
onSessionId: (sessionId: string) => void;
|
|
onError: (message: string) => void;
|
|
};
|
|
let capturedWsHandlers: WsHandlers | null = null;
|
|
|
|
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(handlers: WsHandlers) {
|
|
capturedWsHandlers = handlers;
|
|
}
|
|
close() {}
|
|
sendChat() {}
|
|
cancel() {}
|
|
}
|
|
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),
|
|
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),
|
|
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();
|
|
});
|
|
});
|
|
|
|
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: [] });
|
|
});
|
|
|
|
it("renders tool call badge for assistant message with tool_calls (AC3)", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const messages: Message[] = [
|
|
{ role: "user", content: "Read src/main.rs" },
|
|
{
|
|
role: "assistant",
|
|
content: "I'll read that file.",
|
|
tool_calls: [
|
|
{
|
|
id: "toolu_abc",
|
|
type: "function",
|
|
function: {
|
|
name: "Read",
|
|
arguments: '{"file_path":"src/main.rs"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
act(() => {
|
|
capturedWsHandlers?.onUpdate(messages);
|
|
});
|
|
|
|
expect(await screen.findByText("I'll read that file.")).toBeInTheDocument();
|
|
// Tool call badge should appear showing the function name
|
|
expect(await screen.findByText(/Read/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders collapsible tool output for tool role messages (AC3)", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const messages: Message[] = [
|
|
{ role: "user", content: "Check the file" },
|
|
{
|
|
role: "assistant",
|
|
content: "",
|
|
tool_calls: [
|
|
{
|
|
id: "toolu_1",
|
|
type: "function",
|
|
function: { name: "Read", arguments: '{"file_path":"foo.rs"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
content: 'fn main() { println!("hello"); }',
|
|
tool_call_id: "toolu_1",
|
|
},
|
|
{ role: "assistant", content: "The file contains a main function." },
|
|
];
|
|
|
|
act(() => {
|
|
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."),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders plain assistant message without tool call badges (AC5)", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const messages: Message[] = [
|
|
{ role: "user", content: "Hello" },
|
|
{ role: "assistant", content: "Hi there! How can I help?" },
|
|
];
|
|
|
|
act(() => {
|
|
capturedWsHandlers?.onUpdate(messages);
|
|
});
|
|
|
|
expect(
|
|
await screen.findByText("Hi there! How can I help?"),
|
|
).toBeInTheDocument();
|
|
// No tool call badges should appear
|
|
expect(screen.queryByText(/Tool Output/)).toBeNull();
|
|
});
|
|
|
|
it("renders multiple tool calls in a single assistant turn (AC3)", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const messages: Message[] = [
|
|
{ role: "user", content: "Do some work" },
|
|
{
|
|
role: "assistant",
|
|
content: "I'll do multiple things.",
|
|
tool_calls: [
|
|
{
|
|
id: "id1",
|
|
type: "function",
|
|
function: { name: "Bash", arguments: '{"command":"cargo test"}' },
|
|
},
|
|
{
|
|
id: "id2",
|
|
type: "function",
|
|
function: { name: "Read", arguments: '{"file_path":"Cargo.toml"}' },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
act(() => {
|
|
capturedWsHandlers?.onUpdate(messages);
|
|
});
|
|
|
|
expect(await screen.findByText(/Bash/)).toBeInTheDocument();
|
|
expect(await screen.findByText(/Read/)).toBeInTheDocument();
|
|
});
|
|
});
|