feat(FN-1465): merge fusion/fn-1465
This commit is contained in:
@@ -41,7 +41,6 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })),
|
||||
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })),
|
||||
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
|
||||
fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -64,10 +63,6 @@ vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: (options?: { projectId?: string; searchQuery?: string }) => mockUseTasks(options),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: () => mockUseTasks(),
|
||||
}));
|
||||
|
||||
// Mock useRemoteNodeData
|
||||
vi.mock("../../hooks/useRemoteNodeData", () => ({
|
||||
useRemoteNodeData: vi.fn(() => ({
|
||||
|
||||
@@ -328,13 +328,13 @@ describe("ExecutorStatusBar", () => {
|
||||
const tasks: any[] = [{ id: "FN-001" }];
|
||||
render(<ExecutorStatusBar tasks={tasks} projectId="proj_abc123" />);
|
||||
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123", undefined);
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123", undefined, undefined);
|
||||
});
|
||||
|
||||
it("passes tasks and undefined to useExecutorStats when projectId not provided", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined);
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("ExecutorStatusBar", () => {
|
||||
render(<ExecutorStatusBar tasks={tasks} />);
|
||||
|
||||
// useExecutorStats receives the tasks array as first argument
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined);
|
||||
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it("renders stuck segment with correct count when stuck tasks detected", () => {
|
||||
|
||||
@@ -1554,6 +1554,27 @@ describe("ListView Quick Entry", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(mockOnQuickCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("QuickEntryBox textarea spans full container width in list view (FN-1579)", () => {
|
||||
mockDesktopViewport();
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
const quickEntryBox = screen.getByTestId("quick-entry-box");
|
||||
const input = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
|
||||
// Get the bounding rectangles for the textarea and its container
|
||||
const inputRect = input.getBoundingClientRect();
|
||||
const containerRect = quickEntryBox.getBoundingClientRect();
|
||||
|
||||
// The textarea should span the full width of its container (within 2px tolerance for rounding)
|
||||
// This ensures the input visually reaches the right edge of the container
|
||||
expect(inputRect.width).toBeGreaterThanOrEqual(containerRect.width - 2);
|
||||
|
||||
// The textarea should be at least 80% of the container width
|
||||
// (accounting for the toggle button on the right)
|
||||
expect(inputRect.width).toBeGreaterThanOrEqual(containerRect.width * 0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ListView Collapsible Sections", () => {
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Message } from "@fusion/core";
|
||||
import type { Agent } from "../../api";
|
||||
import type { Agent, ChatSession } from "../../api";
|
||||
import * as apiModule from "../../api";
|
||||
import { useAgents } from "../../hooks/useAgents";
|
||||
import { QuickChatFAB } from "../QuickChatFAB";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchConversation: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
fetchChatSessions: vi.fn(),
|
||||
createChatSession: vi.fn(),
|
||||
fetchChatMessages: vi.fn(),
|
||||
streamChatResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgents", () => ({
|
||||
useAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||
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[] = [
|
||||
@@ -40,32 +43,13 @@ const mockAgents: Agent[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockConversation: Message[] = [
|
||||
{
|
||||
id: "msg-001",
|
||||
fromId: "agent-001",
|
||||
fromType: "agent",
|
||||
toId: "dashboard",
|
||||
toType: "user",
|
||||
content: "Hello from the agent",
|
||||
type: "agent-to-user",
|
||||
read: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "msg-002",
|
||||
fromId: "dashboard",
|
||||
fromType: "user",
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "Hello back",
|
||||
type: "user-to-agent",
|
||||
read: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
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({
|
||||
@@ -78,25 +62,57 @@ function mockAgentsHook(agents: Agent[], isLoading = false) {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
mockFetchConversation.mockResolvedValue(mockConversation);
|
||||
mockSendMessage.mockResolvedValue({
|
||||
id: "msg-003",
|
||||
fromId: "dashboard",
|
||||
fromType: "user",
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "New message",
|
||||
type: "user-to-agent",
|
||||
read: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
|
||||
mockCreateChatSession.mockResolvedValue({ session: mockSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
createMockStreamResponse();
|
||||
});
|
||||
|
||||
it("renders nothing when no agents exist", () => {
|
||||
@@ -158,53 +174,114 @@ describe("QuickChatFAB", () => {
|
||||
expect(screen.getByRole("option", { name: "Agent Two (reviewer)" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("sending a message calls sendMessage API with expected params", async () => {
|
||||
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(mockSendMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
toId: "agent-001",
|
||||
toType: "agent",
|
||||
content: "Ship it",
|
||||
type: "user-to-agent",
|
||||
},
|
||||
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("switching agents loads the selected conversation", async () => {
|
||||
mockFetchConversation.mockResolvedValue([]);
|
||||
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(mockFetchConversation).toHaveBeenCalledWith("agent-001", "agent", "proj-123");
|
||||
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(mockFetchConversation).toHaveBeenCalledWith("agent-002", "agent", "proj-123");
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith({ agentId: "agent-002" }, "proj-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows placeholder text when conversation is empty", async () => {
|
||||
mockFetchConversation.mockResolvedValue([]);
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
@@ -277,4 +354,35 @@ describe("QuickChatFAB", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3268,7 +3268,7 @@ describe("TaskCard singular/plural file count", () => {
|
||||
expect(screen.queryByText("View files")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to View files for in-progress worktrees without a positive diff count", () => {
|
||||
it("hides file changes link for in-progress worktrees when filesChanged is 0", () => {
|
||||
const task = makeTask({
|
||||
column: "in-progress",
|
||||
worktree: "/repo/.worktrees/fn-099",
|
||||
@@ -3284,7 +3284,27 @@ describe("TaskCard singular/plural file count", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("View files")).toBeInTheDocument();
|
||||
expect(screen.queryByText("View files")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/files? changed/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides file changes link for in-progress worktrees when diffStats is null", () => {
|
||||
const task = makeTask({
|
||||
column: "in-progress",
|
||||
worktree: "/repo/.worktrees/fn-099",
|
||||
status: "executing",
|
||||
});
|
||||
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("View files")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/files? changed/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user