592 lines
18 KiB
TypeScript
592 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import type { Agent, ChatSession } from "../../api";
|
|
import * as apiModule from "../../api";
|
|
import { useAgents } from "../../hooks/useAgents";
|
|
import { QuickChatFAB } from "../QuickChatFAB";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchChatSessions: vi.fn(),
|
|
createChatSession: vi.fn(),
|
|
fetchChatMessages: vi.fn(),
|
|
streamChatResponse: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useAgents", () => ({
|
|
useAgents: vi.fn(),
|
|
}));
|
|
|
|
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
|
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
|
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
|
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
|
const mockUseAgents = vi.mocked(useAgents);
|
|
|
|
const mockAgents: Agent[] = [
|
|
{
|
|
id: "agent-001",
|
|
name: "Agent One",
|
|
role: "executor",
|
|
state: "active",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
metadata: {},
|
|
},
|
|
{
|
|
id: "agent-002",
|
|
name: "Agent Two",
|
|
role: "reviewer",
|
|
state: "terminated",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
metadata: {},
|
|
},
|
|
];
|
|
|
|
const mockSession: ChatSession = {
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
status: "active",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
function mockAgentsHook(agents: Agent[], isLoading = false) {
|
|
mockUseAgents.mockReturnValue({
|
|
agents,
|
|
activeAgents: agents.filter((agent) => agent.state === "active" || agent.state === "running"),
|
|
stats: null,
|
|
isLoading,
|
|
loadAgents: vi.fn(),
|
|
loadStats: vi.fn(),
|
|
});
|
|
}
|
|
|
|
function createMockStreamResponse() {
|
|
const handlers: {
|
|
onThinking?: (data: string) => void;
|
|
onText?: (data: string) => void;
|
|
onDone?: (data: { messageId: string }) => void;
|
|
onError?: (data: string) => void;
|
|
onConnectionStateChange?: (state: string) => void;
|
|
} = {};
|
|
|
|
const mockStream = {
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
// Allow setting handlers
|
|
setHandlers: (h: typeof handlers) => {
|
|
Object.assign(handlers, h);
|
|
},
|
|
};
|
|
|
|
// Mock streamChatResponse to capture handlers and return mock stream
|
|
mockStreamChatResponse.mockImplementation((sessionId, content, textHandlers) => {
|
|
// Store handlers for test to invoke
|
|
mockStream.setHandlers(textHandlers as typeof handlers);
|
|
|
|
// Simulate async response
|
|
setTimeout(() => {
|
|
// Simulate streaming text
|
|
textHandlers.onConnectionStateChange?.("connected");
|
|
textHandlers.onText?.("Thinking...");
|
|
textHandlers.onText?.("Here's my response.");
|
|
textHandlers.onDone?.({ messageId: `msg-${Date.now()}` });
|
|
}, 10);
|
|
|
|
return {
|
|
close: mockStream.close,
|
|
isConnected: mockStream.isConnected,
|
|
};
|
|
});
|
|
|
|
return mockStream;
|
|
}
|
|
|
|
describe("QuickChatFAB", () => {
|
|
const addToast = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockAgentsHook(mockAgents);
|
|
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
|
|
mockCreateChatSession.mockResolvedValue({ session: mockSession });
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
createMockStreamResponse();
|
|
});
|
|
|
|
it("renders nothing when no agents exist", () => {
|
|
mockAgentsHook([]);
|
|
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
|
|
});
|
|
|
|
it("renders FAB button when agents exist", () => {
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
expect(screen.getByTestId("quick-chat-fab")).toBeDefined();
|
|
});
|
|
|
|
it("opens chat panel when FAB is clicked", async () => {
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it("closes panel via close button and Escape key", async () => {
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-close"));
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
});
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("shows available agents in selector", async () => {
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
const select = await screen.findByTestId("quick-chat-agent-select");
|
|
expect(select).toBeDefined();
|
|
expect(screen.getByRole("option", { name: "Agent One (executor)" })).toBeDefined();
|
|
expect(screen.getByRole("option", { name: "Agent Two (reviewer)" })).toBeDefined();
|
|
});
|
|
|
|
it("sending a message calls streamChatResponse API with expected params", async () => {
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
// Wait for session initialization
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
const input = await screen.findByTestId("quick-chat-input");
|
|
fireEvent.change(input, { target: { value: "Ship it" } });
|
|
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
|
|
|
// Wait for streamChatResponse to be called
|
|
await waitFor(() => {
|
|
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
|
"session-001",
|
|
"Ship it",
|
|
expect.objectContaining({
|
|
onThinking: expect.any(Function),
|
|
onText: expect.any(Function),
|
|
onDone: expect.any(Function),
|
|
onError: expect.any(Function),
|
|
}),
|
|
"proj-123",
|
|
);
|
|
});
|
|
|
|
// Input should be cleared
|
|
await waitFor(() => {
|
|
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
|
|
});
|
|
});
|
|
|
|
it("streaming state shows streaming message and disables input", async () => {
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
// Wait for session initialization
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
const input = await screen.findByTestId("quick-chat-input");
|
|
fireEvent.change(input, { target: { value: "Hello" } });
|
|
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
|
|
|
// Input should be cleared and disabled during streaming
|
|
await waitFor(() => {
|
|
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
|
|
});
|
|
expect(screen.getByTestId("quick-chat-input")).toBeDisabled();
|
|
});
|
|
|
|
it("after streaming completes, assistant message is shown", async () => {
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
// Wait for session initialization
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
const input = await screen.findByTestId("quick-chat-input");
|
|
fireEvent.change(input, { target: { value: "Hello" } });
|
|
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
|
|
|
// Wait for streaming to complete and message to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
});
|
|
|
|
// After streaming completes, input should be re-enabled
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
it("switching agents creates a new session for the selected agent", async () => {
|
|
// First session exists
|
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [mockSession] });
|
|
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
// Wait for initial session to be created
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
// Switch to agent-002
|
|
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
|
|
target: { value: "agent-002" },
|
|
});
|
|
|
|
// Should create a new session for agent-002
|
|
await waitFor(() => {
|
|
expect(mockCreateChatSession).toHaveBeenCalledWith({ agentId: "agent-002" }, "proj-123");
|
|
});
|
|
});
|
|
|
|
it("shows placeholder text when conversation is empty", async () => {
|
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
|
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No messages yet. Start the conversation!")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it("closes panel when clicking outside", async () => {
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
});
|
|
|
|
fireEvent.mouseDown(document.body);
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("hides FAB button when showFAB is false", () => {
|
|
render(<QuickChatFAB addToast={addToast} showFAB={false} />);
|
|
|
|
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
|
|
});
|
|
|
|
it("still opens panel programmatically when showFAB is false with controlled open prop", async () => {
|
|
render(<QuickChatFAB addToast={addToast} showFAB={false} open={true} />);
|
|
|
|
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
});
|
|
|
|
it("controlled open prop opens panel without clicking FAB", () => {
|
|
render(<QuickChatFAB addToast={addToast} open={true} />);
|
|
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
});
|
|
|
|
it("controlled open prop defaults to closed when not set", () => {
|
|
render(<QuickChatFAB addToast={addToast} />);
|
|
|
|
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
|
});
|
|
|
|
it("onOpenChange callback is called when panel is opened via FAB (controlled mode)", async () => {
|
|
const onOpenChange = vi.fn();
|
|
render(<QuickChatFAB addToast={addToast} open={false} onOpenChange={onOpenChange} />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
await waitFor(() => {
|
|
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
|
|
it("onOpenChange callback is called when panel is closed via FAB", async () => {
|
|
const onOpenChange = vi.fn();
|
|
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={onOpenChange} />);
|
|
|
|
// Panel should be open initially
|
|
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
await waitFor(() => {
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
});
|
|
|
|
it("error handling shows toast on stream error", async () => {
|
|
// Mock streamChatResponse to trigger error
|
|
mockStreamChatResponse.mockImplementationOnce((sessionId, content, textHandlers) => {
|
|
setTimeout(() => {
|
|
textHandlers.onError?.("Stream connection failed");
|
|
}, 10);
|
|
return {
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => false),
|
|
};
|
|
});
|
|
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
|
|
|
// Wait for session initialization
|
|
await waitFor(() => {
|
|
expect(mockFetchChatSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
const input = await screen.findByTestId("quick-chat-input");
|
|
fireEvent.change(input, { target: { value: "Hello" } });
|
|
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
|
|
|
// Wait for error toast
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Failed to send message", "error");
|
|
});
|
|
});
|
|
|
|
// Drag-related tests
|
|
describe("draggable behavior", () => {
|
|
const localStorageMock = {
|
|
store: {} as Record<string, string>,
|
|
getItem: vi.fn((key: string) => localStorageMock.store[key] ?? null),
|
|
setItem: vi.fn((key: string, value: string) => { localStorageMock.store[key] = value; }),
|
|
removeItem: vi.fn((key: string) => { delete localStorageMock.store[key]; }),
|
|
clear: vi.fn(() => { localStorageMock.store = {}; }),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("localStorage", localStorageMock);
|
|
localStorageMock.store = {};
|
|
localStorageMock.getItem.mockClear();
|
|
localStorageMock.setItem.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("FAB can be dragged to a new position", async () => {
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
const fab = screen.getByTestId("quick-chat-fab");
|
|
|
|
// Simulate drag: pointerdown -> pointermove -> pointerup
|
|
const fabRect = { left: window.innerWidth - 72, top: window.innerHeight - 72, width: 48, height: 48 };
|
|
vi.spyOn(fab, "getBoundingClientRect").mockReturnValue(fabRect as DOMRect);
|
|
|
|
// Start drag
|
|
fireEvent.pointerDown(fab, {
|
|
clientX: fabRect.left + 24,
|
|
clientY: fabRect.top + 24,
|
|
button: 0,
|
|
pointerId: 1,
|
|
});
|
|
|
|
// Verify data-dragging attribute is set
|
|
expect(fab.getAttribute("data-dragging")).toBe("true");
|
|
|
|
// Move pointer (drag 50px to the left, which means increasing right offset)
|
|
fireEvent.pointerMove(fab, {
|
|
clientX: fabRect.left + 24 - 50,
|
|
clientY: fabRect.top + 24,
|
|
pointerId: 1,
|
|
});
|
|
|
|
// End drag
|
|
fireEvent.pointerUp(fab, {
|
|
clientX: fabRect.left + 24 - 50,
|
|
clientY: fabRect.top + 24,
|
|
button: 0,
|
|
pointerId: 1,
|
|
});
|
|
|
|
// Verify localStorage was called with the new position
|
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
"fusion-quick-chat-position-proj-123",
|
|
expect.stringContaining('"x":'),
|
|
);
|
|
});
|
|
|
|
it("small movement (< 5px) is treated as click not drag", async () => {
|
|
const onOpenChange = vi.fn();
|
|
render(<QuickChatFAB addToast={addToast} open={false} onOpenChange={onOpenChange} projectId="proj-123" />);
|
|
|
|
const fab = screen.getByTestId("quick-chat-fab");
|
|
|
|
// Simulate click: pointerDown + small pointerMove (< 5px) + pointerUp + click
|
|
// Since movement is < 5px, this should be treated as a click, not a drag
|
|
fireEvent.pointerDown(fab, {
|
|
clientX: 100,
|
|
clientY: 100,
|
|
button: 0,
|
|
pointerId: 1,
|
|
});
|
|
|
|
// Small movement (less than 5px threshold)
|
|
fireEvent.pointerMove(fab, {
|
|
clientX: 102,
|
|
clientY: 100,
|
|
pointerId: 1,
|
|
});
|
|
|
|
fireEvent.pointerUp(fab, {
|
|
clientX: 102,
|
|
clientY: 100,
|
|
button: 0,
|
|
pointerId: 1,
|
|
});
|
|
|
|
// jsdom doesn't fire click automatically after pointerup with movement < threshold,
|
|
// so we simulate the click event that would fire in a real browser
|
|
fireEvent.click(fab);
|
|
|
|
// Should have toggled panel (treated as click)
|
|
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
|
|
// Should NOT have saved position to localStorage (was a click, not a drag)
|
|
expect(localStorageMock.setItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("FAB position is loaded from localStorage on mount", async () => {
|
|
// Pre-populate localStorage with saved position
|
|
localStorageMock.store["fusion-quick-chat-position-proj-123"] = JSON.stringify({ x: 100, y: 200 });
|
|
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
const fab = screen.getByTestId("quick-chat-fab");
|
|
|
|
// Verify the FAB has the saved position
|
|
expect(fab.style.right).toBe("100px");
|
|
expect(fab.style.bottom).toBe("200px");
|
|
|
|
// Verify localStorage was read
|
|
expect(localStorageMock.getItem).toHaveBeenCalledWith("fusion-quick-chat-position-proj-123");
|
|
});
|
|
|
|
it("FAB position is clamped to viewport boundaries", async () => {
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
const fab = screen.getByTestId("quick-chat-fab");
|
|
|
|
// Simulate drag to extreme position (off viewport)
|
|
fireEvent.pointerDown(fab, {
|
|
clientX: 100,
|
|
clientY: 100,
|
|
button: 0,
|
|
pointerId: 1,
|
|
});
|
|
|
|
// Try to drag way off screen (negative coordinates)
|
|
fireEvent.pointerMove(fab, {
|
|
clientX: -1000,
|
|
clientY: -1000,
|
|
pointerId: 1,
|
|
});
|
|
|
|
fireEvent.pointerUp(fab, {
|
|
clientX: -1000,
|
|
clientY: -1000,
|
|
button: 0,
|
|
pointerId: 1,
|
|
});
|
|
|
|
// Position should be clamped to at least 48px from edges
|
|
const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}");
|
|
expect(savedPosition.x).toBeGreaterThanOrEqual(48);
|
|
expect(savedPosition.y).toBeGreaterThanOrEqual(48);
|
|
});
|
|
|
|
it("touch events work for dragging", async () => {
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
|
|
|
const fab = screen.getByTestId("quick-chat-fab");
|
|
|
|
// Simulate touch drag
|
|
fireEvent.pointerDown(fab, {
|
|
clientX: 100,
|
|
clientY: 100,
|
|
button: 0,
|
|
pointerId: 1,
|
|
pointerType: "touch",
|
|
});
|
|
|
|
// Verify data-dragging attribute is set
|
|
expect(fab.getAttribute("data-dragging")).toBe("true");
|
|
|
|
// Move touch
|
|
fireEvent.pointerMove(fab, {
|
|
clientX: 50,
|
|
clientY: 100,
|
|
pointerId: 1,
|
|
pointerType: "touch",
|
|
});
|
|
|
|
// End touch
|
|
fireEvent.pointerUp(fab, {
|
|
clientX: 50,
|
|
clientY: 100,
|
|
button: 0,
|
|
pointerId: 1,
|
|
pointerType: "touch",
|
|
});
|
|
|
|
// Position should have been saved
|
|
expect(localStorageMock.setItem).toHaveBeenCalled();
|
|
});
|
|
|
|
it("panel position anchors relative to FAB position", async () => {
|
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
|
|
|
|
const panel = screen.getByTestId("quick-chat-panel");
|
|
const fab = screen.getByTestId("quick-chat-fab");
|
|
|
|
// Initial positions (FAB at x=24, y=24+footer, panel at x=24, y=84+footer = FAB.y + 60)
|
|
const fabBottom = parseFloat(fab.style.bottom);
|
|
const panelBottom = parseFloat(panel.style.bottom);
|
|
expect(panelBottom - fabBottom).toBe(60);
|
|
});
|
|
});
|
|
});
|