huskies: merge 804
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { 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;
|
||||
|
||||
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() {}
|
||||
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("Chat two-column layout", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders left and right column containers (AC1, AC2)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("chat-content-area")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("chat-left-column")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("chat-right-column")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chat input inside the left column (AC2, AC5)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const leftColumn = await screen.findByTestId("chat-left-column");
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
expect(leftColumn).toContainElement(input);
|
||||
});
|
||||
|
||||
it("renders panels inside the right column (AC2)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const rightColumn = await screen.findByTestId("chat-right-column");
|
||||
const agentsPanel = await screen.findByText("Agents");
|
||||
expect(rightColumn).toContainElement(agentsPanel);
|
||||
});
|
||||
|
||||
it("uses row flex-direction on wide screens (AC3)", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const contentArea = await screen.findByTestId("chat-content-area");
|
||||
expect(contentArea).toHaveStyle({ flexDirection: "row" });
|
||||
});
|
||||
|
||||
it("uses column flex-direction on narrow screens (AC4)", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 600,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const contentArea = await screen.findByTestId("chat-content-area");
|
||||
expect(contentArea).toHaveStyle({ flexDirection: "column" });
|
||||
|
||||
// Restore wide width for subsequent tests
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat input Shift+Enter behavior", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders a textarea element for the chat input (AC3)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
const input = screen.getByPlaceholderText("Send a message...");
|
||||
expect(input.tagName.toLowerCase()).toBe("textarea");
|
||||
});
|
||||
|
||||
it("sends message on Enter key press without Shift (AC2)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
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 waitFor(() => {
|
||||
expect((input as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send message on Shift+Enter (AC1)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
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: true });
|
||||
});
|
||||
|
||||
expect((input as HTMLTextAreaElement).value).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat reconciliation banner", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("shows banner when a non-done reconciliation event is received", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"42_story_test",
|
||||
"checking",
|
||||
"Checking for committed work in 2_current/",
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("reconciliation-banner"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Reconciling startup state..."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows event message in the banner", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"42_story_test",
|
||||
"gates_running",
|
||||
"Running acceptance gates…",
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Running acceptance gates/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dismisses banner when done event is received", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"42_story_test",
|
||||
"checking",
|
||||
"Checking for committed work",
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("reconciliation-banner"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
capturedWsHandlers?.onReconciliationProgress(
|
||||
"",
|
||||
"done",
|
||||
"Startup reconciliation complete.",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("reconciliation-banner"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user