512 lines
18 KiB
TypeScript
512 lines
18 KiB
TypeScript
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { api } from "../api/client";
|
|
import type { Message } from "../types";
|
|
import { Chat } from "./Chat";
|
|
|
|
// Module-level store for the WebSocket handlers captured during connect().
|
|
type WsHandlers = {
|
|
onToken: (content: string) => void;
|
|
onUpdate: (history: Message[]) => void;
|
|
onSessionId: (sessionId: string) => void;
|
|
onError: (message: string) => void;
|
|
onActivity: (toolName: string) => void;
|
|
onReconciliationProgress: (
|
|
storyId: string,
|
|
status: string,
|
|
message: string,
|
|
) => void;
|
|
};
|
|
let capturedWsHandlers: WsHandlers | null = null;
|
|
// Captures the last sendChat call's arguments for assertion.
|
|
let lastSendChatArgs: { messages: Message[]; config: unknown } | 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(),
|
|
readFile: vi.fn(),
|
|
listProjectFiles: vi.fn(),
|
|
botCommand: vi.fn(),
|
|
};
|
|
class ChatWebSocket {
|
|
connect(handlers: WsHandlers) {
|
|
capturedWsHandlers = handlers;
|
|
}
|
|
close() {}
|
|
sendChat(messages: Message[], config: unknown) {
|
|
lastSendChatArgs = { messages, config };
|
|
}
|
|
cancel() {}
|
|
}
|
|
return { api, ChatWebSocket };
|
|
});
|
|
|
|
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),
|
|
readFile: vi.mocked(api.readFile),
|
|
listProjectFiles: vi.mocked(api.listProjectFiles),
|
|
botCommand: vi.mocked(api.botCommand),
|
|
};
|
|
|
|
function setupMocks() {
|
|
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
|
mockedApi.setModelPreference.mockResolvedValue(true);
|
|
mockedApi.readFile.mockResolvedValue("");
|
|
mockedApi.listProjectFiles.mockResolvedValue([]);
|
|
mockedApi.cancelChat.mockResolvedValue(true);
|
|
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
|
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
|
|
}
|
|
|
|
describe("Remove bubble styling from streaming messages (Story 163)", () => {
|
|
beforeEach(() => {
|
|
capturedWsHandlers = null;
|
|
setupMocks();
|
|
});
|
|
|
|
it("AC1: streaming assistant message uses transparent background, no extra padding, no border-radius", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
// Send a message to put chat into loading state
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "Hello" } });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
// Simulate streaming tokens arriving
|
|
await act(async () => {
|
|
capturedWsHandlers?.onToken("Streaming response text");
|
|
});
|
|
|
|
// Find the streaming message container (the inner div wrapping the Markdown)
|
|
const streamingText = await screen.findByText("Streaming response text");
|
|
// The markdown-body wrapper is the parent, and the styled div is its parent
|
|
const styledDiv = streamingText.closest(".markdown-body")
|
|
?.parentElement as HTMLElement;
|
|
|
|
expect(styledDiv).toBeTruthy();
|
|
const styleAttr = styledDiv.getAttribute("style") ?? "";
|
|
expect(styleAttr).toContain("background: transparent");
|
|
expect(styleAttr).toContain("padding: 0px");
|
|
expect(styleAttr).toContain("border-radius: 0px");
|
|
expect(styleAttr).toContain("max-width: 100%");
|
|
});
|
|
|
|
it("AC1: streaming message wraps Markdown in markdown-body class", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "Hello" } });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedWsHandlers?.onToken("Some markdown content");
|
|
});
|
|
|
|
const streamingText = await screen.findByText("Some markdown content");
|
|
const markdownBody = streamingText.closest(".markdown-body");
|
|
expect(markdownBody).toBeTruthy();
|
|
});
|
|
|
|
it("AC2: no visual change when streaming ends and message transitions to completed", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
// Send a message to start streaming
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "Hello" } });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
// Simulate streaming tokens
|
|
await act(async () => {
|
|
capturedWsHandlers?.onToken("Final response");
|
|
});
|
|
|
|
// Capture streaming message style attribute
|
|
const streamingText = await screen.findByText("Final response");
|
|
const streamingStyledDiv = streamingText.closest(".markdown-body")
|
|
?.parentElement as HTMLElement;
|
|
const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? "";
|
|
|
|
// Transition: onUpdate completes the message
|
|
await act(async () => {
|
|
capturedWsHandlers?.onUpdate([
|
|
{ role: "user", content: "Hello" },
|
|
{ role: "assistant", content: "Final response" },
|
|
]);
|
|
});
|
|
|
|
// Find the completed message — it should have the same styling
|
|
const completedText = await screen.findByText("Final response");
|
|
const completedMarkdownBody = completedText.closest(".markdown-body");
|
|
const completedStyledDiv =
|
|
completedMarkdownBody?.parentElement as HTMLElement;
|
|
|
|
expect(completedStyledDiv).toBeTruthy();
|
|
const completedStyleAttr = completedStyledDiv.getAttribute("style") ?? "";
|
|
|
|
// Both streaming and completed use transparent bg, 0 padding, 0 border-radius
|
|
expect(completedStyleAttr).toContain("background: transparent");
|
|
expect(completedStyleAttr).toContain("padding: 0px");
|
|
expect(completedStyleAttr).toContain("border-radius: 0px");
|
|
expect(streamingStyleAttr).toContain("background: transparent");
|
|
expect(streamingStyleAttr).toContain("padding: 0px");
|
|
expect(streamingStyleAttr).toContain("border-radius: 0px");
|
|
|
|
// Both have the markdown-body class wrapper
|
|
expect(streamingStyledDiv.querySelector(".markdown-body")).toBeTruthy();
|
|
});
|
|
|
|
it("AC3: completed assistant messages retain transparent background and no border-radius", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
await act(async () => {
|
|
capturedWsHandlers?.onUpdate([
|
|
{ role: "user", content: "Hi" },
|
|
{ role: "assistant", content: "Hello there!" },
|
|
]);
|
|
});
|
|
|
|
const assistantText = await screen.findByText("Hello there!");
|
|
const markdownBody = assistantText.closest(".markdown-body");
|
|
const styledDiv = markdownBody?.parentElement as HTMLElement;
|
|
|
|
expect(styledDiv).toBeTruthy();
|
|
const styleAttr = styledDiv.getAttribute("style") ?? "";
|
|
expect(styleAttr).toContain("background: transparent");
|
|
expect(styleAttr).toContain("padding: 0px");
|
|
expect(styleAttr).toContain("border-radius: 0px");
|
|
expect(styleAttr).toContain("max-width: 100%");
|
|
});
|
|
|
|
it("AC3: completed user messages still have their bubble styling", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
await act(async () => {
|
|
capturedWsHandlers?.onUpdate([
|
|
{ role: "user", content: "I am a user message" },
|
|
{ role: "assistant", content: "I am a response" },
|
|
]);
|
|
});
|
|
|
|
// findByText finds the text element; traverse up to the styled bubble div
|
|
const userText = await screen.findByText("I am a user message");
|
|
// User messages are rendered via markdown, so text is inside a <p> inside .user-markdown-body
|
|
// Walk up to find the styled bubble container
|
|
const bubbleDiv = userText.closest("[style*='padding: 10px 16px']");
|
|
expect(bubbleDiv).toBeTruthy();
|
|
const styleAttr = bubbleDiv?.getAttribute("style") ?? "";
|
|
// User messages retain bubble: distinct background, padding, rounded corners
|
|
expect(styleAttr).toContain("padding: 10px 16px");
|
|
expect(styleAttr).toContain("border-radius: 20px");
|
|
expect(styleAttr).not.toContain("background: transparent");
|
|
});
|
|
});
|
|
|
|
describe("Slash command handling (Story 374)", () => {
|
|
beforeEach(() => {
|
|
capturedWsHandlers = null;
|
|
lastSendChatArgs = null;
|
|
setupMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("AC: /status calls botCommand and displays response", async () => {
|
|
mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/status" } });
|
|
});
|
|
// First Enter selects the command from the picker; second Enter submits it
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
|
"status",
|
|
"",
|
|
);
|
|
});
|
|
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
|
|
// Should NOT go to LLM
|
|
expect(lastSendChatArgs).toBeNull();
|
|
});
|
|
|
|
it("AC: /status <number> passes args to botCommand", async () => {
|
|
mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/status 42" } });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
|
"status",
|
|
"42",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("AC: /start <number> calls botCommand", async () => {
|
|
mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/start 42 opus" } });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
|
"start",
|
|
"42 opus",
|
|
);
|
|
});
|
|
expect(await screen.findByText("Started agent")).toBeInTheDocument();
|
|
});
|
|
|
|
it("AC: /git calls botCommand", async () => {
|
|
mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/git" } });
|
|
});
|
|
// First Enter selects the command from the picker; second Enter submits it
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "");
|
|
});
|
|
});
|
|
|
|
it("AC: /cost calls botCommand", async () => {
|
|
mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/cost" } });
|
|
});
|
|
// First Enter selects the command from the picker; second Enter submits it
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "");
|
|
});
|
|
});
|
|
|
|
it("AC: /reset clears messages and session without LLM", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
// First add a message so there is history to clear
|
|
await act(async () => {
|
|
capturedWsHandlers?.onUpdate([
|
|
{ role: "user", content: "hello" },
|
|
{ role: "assistant", content: "world" },
|
|
]);
|
|
});
|
|
expect(await screen.findByText("world")).toBeInTheDocument();
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/reset" } });
|
|
});
|
|
// First Enter selects the command from the picker; second Enter submits it
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
// LLM must NOT be invoked
|
|
expect(lastSendChatArgs).toBeNull();
|
|
// botCommand must NOT be invoked (reset is frontend-only)
|
|
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
|
// Confirmation message should appear
|
|
expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("AC: unrecognised slash command shows error message", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/foobar" } });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
|
|
// Should NOT go to LLM
|
|
expect(lastSendChatArgs).toBeNull();
|
|
// Should NOT call botCommand
|
|
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("AC: /help calls botCommand and displays response", async () => {
|
|
mockedApi.botCommand.mockResolvedValue({
|
|
response: "Available commands: status, help, ...",
|
|
});
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/help" } });
|
|
});
|
|
// First Enter selects the command from the picker; second Enter submits it
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "");
|
|
});
|
|
expect(lastSendChatArgs).toBeNull();
|
|
});
|
|
|
|
it("AC: botCommand API error shows error message in chat", async () => {
|
|
mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "/git" } });
|
|
});
|
|
// First Enter selects the command from the picker; second Enter submits it
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
await act(async () => {
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
});
|
|
|
|
expect(
|
|
await screen.findByText(/Error running command/),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Bug 450: WebSocket error messages displayed in chat", () => {
|
|
beforeEach(() => {
|
|
capturedWsHandlers = null;
|
|
setupMocks();
|
|
});
|
|
|
|
it("AC1: WebSocket error message is shown in chat as an assistant message", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
await act(async () => {
|
|
capturedWsHandlers?.onError("Something went wrong on the server.");
|
|
});
|
|
|
|
expect(
|
|
await screen.findByText("Something went wrong on the server."),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => {
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
await act(async () => {
|
|
capturedWsHandlers?.onError(
|
|
"OAuth login required. Please visit: https://example.com/oauth/login",
|
|
);
|
|
});
|
|
|
|
const link = await screen.findByRole("link", {
|
|
name: /https:\/\/example\.com\/oauth\/login/,
|
|
});
|
|
expect(link).toBeInTheDocument();
|
|
expect(link).toHaveAttribute("href", "https://example.com/oauth/login");
|
|
});
|
|
});
|