test: split ChatView.test.tsx into 3 parallel files + fix FN-4327 isolation bug (#1773)

## What

Splits the giant
`packages/dashboard/app/components/__tests__/ChatView.test.tsx` (**231
tests, ~24s, one sequential worker**) into 3 parallelizable sibling
files sharing a harness — the same pattern already used for
`SettingsModal` — and fixes a latent test-isolation bug it exposed.

### Split (sum unchanged = 231)
| File | tests |
|------|------|
| `ChatView.core.test.tsx` | 140 |
| `ChatView.sessions-rooms.test.tsx` | 40 |
| `ChatView.mobile.test.tsx` | 51 |

`ChatView.test-harness.tsx` exports fixtures, helpers (`renderWithAct`,
`setupMockChat`, `setupMockRooms`, `mockViewportMode`,
`renderRoomCreation`, …), the `vi.mocked` handles, and
`installChatViewEnv()` (the former file-level `beforeEach`/`afterEach`).
The `vi.mock(...)` factories stay **inline & self-contained** in each
test file — delegating them to a harness export triggers a TDZ
`ReferenceError` because the harness imports `ChatView`/`../../api`.

### FN-4327 isolation bug (root cause + fix)
`Direct/Rooms scope toggle > "FN-4327: switching scope from Rooms to
Direct re-anchors direct thread"` failed standalone/split with `expected
500 to be 1200`.

**Root cause:** the Direct↔Rooms toggle swaps subtrees in ChatView's
render (`chatScope` ternary), so the `.chat-messages` container
**unmounts** on entering Rooms and a **fresh node mounts** on returning
to Direct. The re-anchor effect (`anchorToBottom` on scope change)
correctly targets that remounted node — whose jsdom `scrollHeight` is
`0`. The pre-split file mocked geometry on the *pre-toggle* node and
only passed via a timing race against the remount (confirmed:
`sameNode=false`, `afterScrollHeight=0`).

**Fix (no assertion weakening):** install scroll geometry at the
prototype level (restored in `finally`, per-node `scrollTop` backing) so
whichever `.chat-messages` node is live — including the remounted one —
reports `scrollHeight 1200`, making the re-anchor deterministic.

### vitest.config.ts
- Repoint `dashboard-app-quality-chat` (`qualityAppChatOnlyTests`) to
the 3 new files.
- Drop bare `"ChatView"` from `qualityAppComponentTests` and
`isolatedQualityAppComponentTests`.
- Spread `qualityAppChatOnlyTests` into `backfillAppExclude` (mirroring
the settings split) so the files aren't double-collected by the app
backfill lane.

## Verification
- 3 new files together: **231 passed / 0 failed**, stable across 2 runs.
- FN-4327 passes **standalone** (`-t "FN-4327"`) **and** in the full
split run.
- `vitest list --filesOnly` → each new file collected by exactly **one**
project (`dashboard-app-quality-chat`); harness not collected; no
backfill double-collection.
- ESLint on all 4 new files: exit 0.
- Combined Duration ~**17s** (vs original ~24s); in CI the 3 files
parallelize across workers within the chat project.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- stage-review-badge-begin -->

---

<a href="https://stagereview.app/Runfusion/Fusion/pull/1773">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg">
<img src="https://stagereview.app/assets/gh-open-in-stage-light.svg"
alt="Open in Stage">
  </picture>
</a>

<!-- stage-review-badge-end -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved chat experience reliability across mobile and desktop,
including session switching, room navigation, keyboard behavior,
scrolling, and sidebar interactions.
* Strengthened coverage for room creation, scope switching, and message
refresh behavior to help prevent regressions.
* **Chores**
* Reorganized automated checks to run chat-related scenarios more
consistently and avoid duplicate test collection.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-06-25 17:21:33 -07:00
committed by GitHub
5 changed files with 2962 additions and 2632 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,898 @@
/*
FNXC:DashboardTests 2026-06-25-16:30:
ChatView suite split 2/3 (sessions + rooms + scope) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures,
helpers, vi.mocked handles, and installChatViewEnv(). vi.mock factories stay inline & self
-contained here (see harness header for why delegating them triggers a TDZ ReferenceError).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, fireEvent, render as rtlRender, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
import type { ChatMessageInfo } from "../../hooks/useChat";
import { loadAllAppCss } from "../../test/cssFixture";
import * as apiModule from "../../api";
import {
renderWithAct,
setupMockChat,
setupMockRooms,
mockViewportMode,
activeSessionFixture,
createRoomFixture,
renderRoomCreation,
mockFetchModels,
mockFetchDiscoveredSkills,
installChatViewEnv,
} from "./ChatView.test-harness";
// Mock the hooks
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
// Mock lucide-react icons - spread actual module and override specific icons
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
MessageSquare: ({ "data-testid": testId, ...props }: any) => (
<svg data-testid={testId || "icon-message-square"} {...props} />
),
Send: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-send"} {...props} />,
Plus: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-plus"} {...props} />,
Search: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-search"} {...props} />,
Trash2: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-trash"} {...props} />,
Archive: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-archive"} {...props} />,
Pencil: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-pencil"} {...props} />,
ChevronLeft: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-chevron-left"} {...props} />,
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
Eye: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye"} {...props} />,
EyeOff: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye-off"} {...props} />,
Paperclip: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-paperclip"} {...props} />,
File: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-file"} {...props} />,
Copy: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-copy"} {...props} />,
Check: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-check"} {...props} />,
};
});
// Mock CustomModelDropdown - no longer used but kept for other tests
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
onChange,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) => (
<select
data-testid="mock-model-dropdown"
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Use default</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
// Mock fetchAgents for new chat dialog
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
],
favoriteProviders: [],
favoriteModels: [],
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}),
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
installChatViewEnv();
describe("ChatView project-scoped agent fetching", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchDiscoveredSkills.mockResolvedValue([]);
});
it("passes projectId to fetchAgents in agent name resolution effect", async () => {
// Mock useChat to return empty agentsMap so ChatView fetches its own
setupMockChat({ agentsMap: new Map() });
await renderWithAct(<ChatView projectId="proj-456" addToast={vi.fn()} />);
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-456");
});
});
it("passes projectId to NewChatDialog for agent selection", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-789" addToast={vi.fn()} />);
// Open the new chat dialog
await userEvent.click(screen.getByTestId("chat-new-btn"));
// The dialog should have been rendered with projectId
// We verify the mock fetchAgents was called with the correct projectId
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-789");
});
});
it("refetches agents when projectId changes in ChatView", async () => {
// First render with proj-001
setupMockChat({ agentsMap: new Map() });
const { rerender } = await renderWithAct(<ChatView projectId="proj-001" addToast={vi.fn()} />);
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001");
});
const callsBeforeRerender = vi.mocked(apiModule.fetchAgents).mock.calls.length;
// Rerender with proj-002
rerender(<ChatView projectId="proj-002" addToast={vi.fn()} />);
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002");
});
// Should have made an additional fetch call
expect(vi.mocked(apiModule.fetchAgents).mock.calls.length).toBeGreaterThan(callsBeforeRerender);
});
it("refetches agents when projectId changes in NewChatDialog", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const { rerender } = await renderWithAct(<ChatView projectId="proj-001" addToast={vi.fn()} />);
// Open dialog and check initial projectId
await userEvent.click(screen.getByTestId("chat-new-btn"));
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001");
});
// Close dialog, change projectId, reopen
// Note: we need to trigger a new dialog render with the new projectId
rerender(<ChatView projectId="proj-002" addToast={vi.fn()} />);
// Close and reopen dialog
const closeBtn = document.querySelector(".chat-new-dialog-backdrop") as HTMLElement | null;
if (closeBtn) {
await userEvent.click(closeBtn);
}
await userEvent.click(screen.getByTestId("chat-new-btn"));
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002");
});
});
});
describe("ChatView sidebar structure", () => {
it("renders sidebar sections without an empty header spacer", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-sidebar")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-search")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-list")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-footer")).not.toBeInTheDocument();
expect(screen.getByTestId("chat-new-btn").closest(".view-header")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-header")).not.toBeInTheDocument();
});
it("renders desktop header New Chat button", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
});
it("renders mobile footer New Chat button in Direct scope", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const viewportSpy = mockViewportMode("mobile");
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("hides mobile footer New Chat button in Rooms scope", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const viewportSpy = mockViewportMode("mobile");
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.queryByTestId("chat-new-btn")).not.toBeInTheDocument();
viewportSpy.mockRestore();
});
it("opens new chat dialog when clicking mobile footer New Chat button in Direct scope", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const viewportSpy = mockViewportMode("mobile");
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
expect(dialog).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("session list has both chat-session-list and chat-sidebar-list classes", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionList = document.querySelector(".chat-session-list") as HTMLElement | null;
expect(sessionList).toBeInTheDocument();
expect(sessionList).toHaveClass("chat-sidebar-list");
});
});
describe("room creation", () => {
it("opens the newly created room and collapses the mobile sidebar on success", async () => {
const { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "mobile" });
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
expect(document.querySelector(".chat-sidebar")).toHaveClass("chat-sidebar--hidden");
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("opens the newly created room on desktop without hiding the sidebar", async () => {
const { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "desktop" });
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden");
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("keeps the modal open and sidebar visible when room creation fails", async () => {
const { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "mobile", createRejects: true });
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
expect(screen.getByRole("dialog", { name: "Create room" })).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden");
expect(screen.queryByText("#newroom")).toBeNull();
viewportSpy.mockRestore();
});
});
describe("Direct/Rooms scope toggle", () => {
beforeEach(() => {
localStorage.clear();
});
it("shows rooms UI when chatRooms experimental flag is missing", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument();
expect(screen.queryByTestId("chat-sidebar-rooms")).not.toBeInTheDocument();
});
it("defaults to Direct with sidebar list visible", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-direct")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "false");
expect(document.querySelector(".chat-session-list")).toBeInTheDocument();
expect(screen.queryByTestId("chat-sidebar-rooms-empty")).toBeNull();
});
it("shows rooms UI when chatRooms experimental flag is on", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
});
it("shows rooms placeholder and hides direct search/list in Rooms scope", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
expect(document.querySelector(".chat-session-list")).toBeNull();
expect(screen.queryByTestId("chat-search-input")).toBeNull();
});
it("switching back to Direct restores search/list and keeps active session highlight", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
expect(screen.getByTestId("chat-search-input")).toBeInTheDocument();
expect(document.querySelector(".chat-session-list")).toBeInTheDocument();
expect(screen.getByTestId("chat-session-session-001")).toHaveClass("chat-session-item--active");
});
it("FN-4327: switching scope from Rooms to Direct re-anchors direct thread", async () => {
/*
FNXC:DashboardTests 2026-06-25-16:30:
The Direct↔Rooms toggle swaps subtrees in ChatView's render (chatScope ternary), so the
`.chat-messages` container UNMOUNTS on entering Rooms and a FRESH node mounts on return to
Direct. The re-anchor effect (ChatView.tsx anchorToBottom on scope change) correctly targets
that remounted node — whose jsdom scrollHeight is 0 unless we provide geometry at creation.
The pre-split file masked this by mocking geometry on the pre-toggle node and only passing via
a timing race against the remount. Install scroll geometry at the prototype level (restored in
finally) with per-node scrollTop backing so whichever `.chat-messages` node is live — including
the remounted one — reports scrollHeight 1200, making the re-anchor deterministically observable
without weakening the "re-anchors to bottom" assertion.
*/
const proto = HTMLElement.prototype;
const originalScrollHeight = Object.getOwnPropertyDescriptor(proto, "scrollHeight");
const originalClientHeight = Object.getOwnPropertyDescriptor(proto, "clientHeight");
const originalScrollTop = Object.getOwnPropertyDescriptor(proto, "scrollTop");
const scrollTopByNode = new WeakMap<HTMLElement, number>();
const restoreGeometry = () => {
if (originalScrollHeight) Object.defineProperty(proto, "scrollHeight", originalScrollHeight);
if (originalClientHeight) Object.defineProperty(proto, "clientHeight", originalClientHeight);
if (originalScrollTop) Object.defineProperty(proto, "scrollTop", originalScrollTop);
};
try {
Object.defineProperty(proto, "scrollHeight", {
configurable: true,
get(this: HTMLElement) {
return this.classList.contains("chat-messages") ? 1200 : (originalScrollHeight?.get?.call(this) ?? 0);
},
});
Object.defineProperty(proto, "clientHeight", {
configurable: true,
get(this: HTMLElement) {
return this.classList.contains("chat-messages") ? 200 : (originalClientHeight?.get?.call(this) ?? 0);
},
});
Object.defineProperty(proto, "scrollTop", {
configurable: true,
get(this: HTMLElement) {
return scrollTopByNode.get(this) ?? (originalScrollTop?.get?.call(this) ?? 0);
},
set(this: HTMLElement, value: number) {
scrollTopByNode.set(this, value);
},
});
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
// Scroll up from the bottom so the jump-to-latest affordance appears.
messagesContainer.scrollTop = 500;
fireEvent.scroll(messagesContainer);
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
await waitFor(() => {
const live = document.querySelector(".chat-messages") as HTMLDivElement;
expect(live.scrollTop).toBe(1200);
});
await waitFor(() => {
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
});
} finally {
restoreGeometry();
}
});
it("restores persisted rooms scope when chatRooms experimental flag is missing", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
localStorage.setItem("fusion:chat-scope", "rooms");
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
});
it("persists scope in localStorage and restores Rooms on next mount", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const { unmount } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(localStorage.getItem("fusion:chat-scope")).toBe("rooms");
unmount();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
});
});
describe("FN-5380 scroll preservation", () => {
beforeEach(() => {
mockFetchModels.mockImplementation(() => new Promise(() => {}));
});
const makeMessages = (count: number, sessionId = "session-001") =>
Array.from({ length: count }, (_, index) => ({
id: `msg-${index + 1}`,
sessionId,
role: index % 2 === 0 ? "assistant" : "user",
content: `Message ${index + 1}`,
createdAt: `2026-04-08T00:00:${String(index).padStart(2, "0")}.000Z`,
} satisfies ChatMessageInfo));
const attachScrollGeometry = (container: HTMLDivElement, initialTop: number, height = 2000) => {
let scrollTopValue = initialTop;
Object.defineProperty(container, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(container, "clientHeight", { configurable: true, get: () => 300 });
Object.defineProperty(container, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
return () => scrollTopValue;
};
it("preserves scroll across silent reconnect-style refetch for direct chats", async () => {
const baseMessages = makeMessages(30);
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 760);
fireEvent.scroll(container);
setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages] });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(readScrollTop()).toBe(760);
});
});
it("auto-scrolls on new message only when previously pinned", async () => {
const baseMessages = makeMessages(4);
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 1700);
fireEvent.scroll(container);
setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-5" }))] });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(readScrollTop()).toBe(2000);
});
container.scrollTop = 500;
fireEvent.scroll(container);
setupMockChat({ activeSession: activeSessionFixture, messages: makeMessages(6) });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(readScrollTop()).toBe(500);
});
});
it("preserves scroll through visibility reconnect path", async () => {
const baseMessages = makeMessages(20);
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 640);
fireEvent.scroll(container);
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
fireEvent(document, new Event("visibilitychange"));
setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-21" }))] });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(readScrollTop()).toBe(640);
});
});
it("preserves room transcript scroll on message refresh", async () => {
const room = createRoomFixture("ops");
const roomMessages = makeMessages(12, room.id).map((message) => ({
id: message.id,
roomId: room.id,
role: message.role,
content: message.content,
createdAt: message.createdAt,
senderAgentId: null,
thinkingOutput: null,
metadata: null,
mentions: [],
}));
setupMockChat({ sessions: [], filteredSessions: [] });
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 420);
fireEvent.scroll(container);
setupMockRooms({ rooms: [room], activeRoom: room, messages: [...roomMessages], messagesLoading: false });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(readScrollTop()).toBe(420);
});
});
});
describe("FN-5720 room re-entry anchoring", () => {
beforeEach(() => {
mockFetchModels.mockImplementation(() => new Promise(() => {}));
});
const makeMessages = (count: number, sessionId = "session-001") =>
Array.from({ length: count }, (_, index) => ({
id: `msg-${index + 1}`,
sessionId,
role: index % 2 === 0 ? "assistant" : "user",
content: `Message ${index + 1}`,
createdAt: `2026-04-08T00:00:${String(index).padStart(2, "0")}.000Z`,
} satisfies ChatMessageInfo));
const attachScrollGeometry = (container: HTMLDivElement, initialTop: number, height = 2000) => {
let scrollTopValue = initialTop;
Object.defineProperty(container, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(container, "clientHeight", { configurable: true, get: () => 300 });
Object.defineProperty(container, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
return () => scrollTopValue;
};
const makeRoomMessages = (roomId: string, count: number) =>
makeMessages(count, roomId).map((message) => ({
id: message.id,
roomId,
role: message.role,
content: message.content,
createdAt: message.createdAt,
senderAgentId: null,
thinkingOutput: null,
metadata: null,
mentions: [],
}));
it("anchors to bottom when re-entering Rooms scope", async () => {
const room = createRoomFixture("ops");
const roomMessages = makeRoomMessages(room.id, 12);
setupMockChat({
activeSession: activeSessionFixture,
sessions: [activeSessionFixture],
filteredSessions: [activeSessionFixture],
messages: [{ id: "dm-1", sessionId: activeSessionFixture.id, role: "assistant", content: "Direct", createdAt: "2026-04-08T00:00:00.000Z" }],
});
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
localStorage.setItem("fusion:chat-scope", "rooms");
rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 420);
container.scrollTop = 420;
fireEvent.scroll(container);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await waitFor(() => {
expect(readScrollTop()).toBe(2000);
});
});
it("preserves scrolled-up room position on message refetch", async () => {
const room = createRoomFixture("ops");
const roomMessages = makeRoomMessages(room.id, 10);
setupMockChat({ sessions: [], filteredSessions: [] });
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 380);
fireEvent.scroll(container);
setupMockRooms({ rooms: [room], activeRoom: room, messages: [...roomMessages], messagesLoading: false });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(readScrollTop()).toBe(380);
});
});
});
describe("resizable sidebar", () => {
beforeEach(() => {
localStorage.clear();
});
it("renders desktop resize handle with separator ARIA attributes", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
expect(handle).toHaveAttribute("aria-orientation", "vertical");
expect(handle).toHaveAttribute("aria-valuemin", "180");
expect(handle).toHaveAttribute("aria-valuemax", "500");
expect(handle).toHaveAttribute("aria-valuenow", "280");
expect(handle).toHaveAttribute("tabindex", "0");
viewportSpy.mockRestore();
});
it("updates sidebar width while dragging", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
expect(sidebar.style.width).toBe("360px");
expect(handle).toHaveAttribute("aria-valuenow", "360");
viewportSpy.mockRestore();
});
it("clamps width between min and max", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: -1000 });
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("180px");
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 2000 });
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("500px");
viewportSpy.mockRestore();
});
it("persists width to localStorage on pointer up", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
act(() => {
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
fireEvent.pointerUp(document, { pointerId: 1, clientX: 360 });
});
expect(localStorage.getItem("fusion:chat-sidebar-width")).toBe("360");
viewportSpy.mockRestore();
});
it("restores persisted width on mount", async () => {
const viewportSpy = mockViewportMode("desktop");
localStorage.setItem("fusion:chat-sidebar-width", "350");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("350px");
viewportSpy.mockRestore();
});
it("does not render resize handle on mobile", async () => {
const viewportSpy = mockViewportMode("mobile");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
viewportSpy.mockRestore();
});
});
describe("Chat header New Chat button", () => {
const activeSession = { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
it("renders New Chat button in the shared header on desktop when session is active", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ activeSession });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const btn = screen.getByTestId("chat-new-btn");
expect(btn).toBeInTheDocument();
expect(btn.closest(".view-header")).toBeInTheDocument();
expect(btn).toHaveTextContent("New Chat");
expect(btn).toHaveClass("btn", "btn-sm", "btn-primary");
viewportSpy.mockRestore();
});
it("clicking shared header New Chat button opens the NewChatDialog", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ activeSession });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const btn = screen.getByTestId("chat-new-btn");
await act(async () => {
fireEvent.click(btn);
});
expect(await screen.findByTestId("chat-new-dialog-mode-toggle")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("does not render New Chat button in the shared header on mobile", async () => {
const viewportSpy = mockViewportMode("mobile");
setupMockChat({ activeSession });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull();
expect(document.querySelector(".view-header [data-testid='chat-new-btn']")).toBeNull();
viewportSpy.mockRestore();
});
});
describe("Chat pop-out header actions", () => {
it("renders a pop-out action in the main Chat header", async () => {
const onPopOut = vi.fn();
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} onPopOut={onPopOut} />);
const button = screen.getByTestId("chat-pop-out");
expect(button.closest(".view-header")).toBeInTheDocument();
fireEvent.click(button);
expect(onPopOut).toHaveBeenCalledTimes(1);
});
it("renders maximize, minimize, and close actions in floating Chat", async () => {
const onMaximize = vi.fn();
const onMinimize = vi.fn();
const onClose = vi.fn();
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(
<ChatView
projectId="proj-123"
addToast={vi.fn()}
floating
onMaximize={onMaximize}
onMinimize={onMinimize}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByTestId("chat-modal-maximize"));
fireEvent.click(screen.getByTestId("chat-modal-minimize"));
fireEvent.click(screen.getByTestId("chat-modal-close"));
expect(onMaximize).toHaveBeenCalledTimes(1);
expect(onMinimize).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("defines a modal-width narrow layout that mirrors mobile one-pane behavior", async () => {
const css = loadAllAppCss();
expect(css).toMatch(/\.chat-view--narrow \.chat-view__body\s*\{[^}]*flex-direction:\s*column;/);
expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar\s*\{[^}]*min-width:\s*100%;[^}]*border-right:\s*none;/);
expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar:not\(\.chat-sidebar--hidden\) \+ \.chat-thread\s*\{[^}]*display:\s*none;/);
expect(css).toMatch(/\.chat-view--narrow \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/);
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-view \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/);
});
it("collapses Direct/Rooms labels from ChatView container width so the header title remains visible", async () => {
const css = loadAllAppCss();
expect(css).toMatch(/\.chat-view\s*\{[^}]*container:\s*chat-view \/ inline-size;/);
expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px;/);
expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip-path:\s*inset\(50%\);/);
});
});

View File

@@ -0,0 +1,258 @@
import { vi, beforeEach, afterEach } from "vitest";
import { act, fireEvent, render as rtlRender, screen, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { useState } from "react";
import { ChatView } from "../ChatView";
import type { DiscoveredSkill } from "@fusion/dashboard";
import * as useChatModule from "../../hooks/useChat";
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
import * as apiModule from "../../api";
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
/*
FNXC:DashboardTests 2026-06-25-16:30:
Shared harness for the ChatView suite. The 231-test ChatView.test.tsx was split into 3
sibling files (core / sessions-rooms / mobile) so the dashboard chat project parallelizes
them across workers instead of running one ~24s sequential file (FN-5048 feedback-loop
velocity).
CRITICAL: the vi.mock(...) factories stay INLINE in each split test file, NOT here. This
harness imports ChatView/../../api, so a vi.mock factory that referenced a harness export
would evaluate during harness init while that export is still in the TDZ — producing
`ReferenceError: Cannot access '__vi_import_N__' before initialization`. The factories must
stay self-contained (lucide-react, CustomModelDropdown, useNavigationHistory, ../../api,
useChat, useChatRooms). This harness only exports fixtures, helpers, the `vi.mocked`
handles (resolved against the per-file hoisted mocks), and installChatViewEnv() — the
former file-level beforeEach/afterEach.
*/
// Mock scrollIntoView for JSDOM
Element.prototype.scrollIntoView = vi.fn();
export const mockUseChat = vi.mocked(useChatModule.useChat);
export const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
export const mockFetchModels = vi.mocked(apiModule.fetchModels);
export const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
export const mockCreateObjectURL = vi.fn();
export const mockRevokeObjectURL = vi.fn();
export const mockClipboardWriteText = vi.fn();
export const defaultModelsResponse = {
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
],
favoriteProviders: [],
favoriteModels: [],
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
};
export const defaultChatState: UseChatReturn = {
sessions: [],
activeSession: null,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__", status: "active", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" } satisfies ChatSessionInfo),
archiveSession: vi.fn(),
renameSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
pendingMessage: "",
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [],
refreshSessions: vi.fn(),
agentsMap: new Map(),
};
export const defaultRoomsState: UseChatRoomsResult = {
rooms: [],
roomsLoading: false,
roomsError: null,
activeRoom: null,
activeRoomMembers: [],
messages: [],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
refreshRooms: vi.fn(),
};
export async function renderWithAct(ui: Parameters<typeof rtlRender>[0]) {
let result: ReturnType<typeof rtlRender> | undefined;
await act(async () => {
result = rtlRender(ui);
});
return result!;
}
export const activeSessionFixture: ChatSessionInfo = {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test Chat",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
export function createMockSkill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
return {
id: "skill-id",
name: "skill/name",
path: "/tmp/skills/skill.md",
relativePath: "skills/skill.md",
enabled: true,
metadata: {
source: "*",
scope: "project",
origin: "top-level",
},
...overrides,
};
}
export function setupMockChat(overrides: Partial<UseChatReturn> = {}) {
const state: UseChatReturn = { ...defaultChatState, ...overrides };
mockUseChat.mockReturnValue(state);
}
export function setupMockRooms(overrides: Partial<UseChatRoomsResult> = {}) {
const state: UseChatRoomsResult = { ...defaultRoomsState, ...overrides };
mockUseChatRooms.mockReturnValue(state);
}
export function createRoomFixture(name: string) {
return {
id: `room-${name}`,
projectId: "proj-123",
slug: name,
name,
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T00:00:00.000Z",
};
}
export function setupStatefulCreateRoomMock(options?: { createRejects?: boolean }) {
const createRoom = vi.fn();
mockUseChatRooms.mockImplementation(() => {
const [roomsState, setRoomsState] = useState<UseChatRoomsResult["rooms"]>([]);
const [activeRoom, setActiveRoom] = useState<UseChatRoomsResult["activeRoom"]>(null);
return {
...defaultRoomsState,
rooms: roomsState,
activeRoom,
activeRoomMembers: activeRoom
? [{ roomId: activeRoom.id, agentId: "agent-001", role: "member", addedAt: "2026-05-12T00:00:00.000Z" }]
: [],
createRoom: async ({ name, memberAgentIds }) => {
createRoom({ name, memberAgentIds });
if (options?.createRejects) {
throw new Error("Failed to create room.");
}
const nextRoom = createRoomFixture(name);
setRoomsState((previous) => [...previous, nextRoom]);
setActiveRoom(nextRoom);
return nextRoom;
},
selectRoom: (roomId) => {
setActiveRoom(roomsState.find((room) => room.id === roomId) ?? null);
},
} satisfies UseChatRoomsResult;
});
return { createRoom };
}
export async function renderRoomCreation(options?: { viewport?: "mobile" | "desktop"; createRejects?: boolean }) {
const viewportSpy = mockViewportMode(options?.viewport ?? "mobile");
const { createRoom } = setupStatefulCreateRoomMock({ createRejects: options?.createRejects });
setupMockChat({ sessions: [], filteredSessions: [] });
localStorage.setItem("fusion:chat-scope", "rooms");
const user = userEvent.setup({ delay: null });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await user.click(screen.getByTestId("chat-create-room-btn"));
const dialog = await screen.findByRole("dialog", { name: "Create room" });
fireEvent.change(within(dialog).getByLabelText("Room name"), { target: { value: "newroom" } });
await user.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha"));
await user.click(within(dialog).getByRole("button", { name: "Create room" }));
return { createRoom, viewportSpy };
}
export function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
export function mockViewportMode(mode: "mobile" | "desktop") {
ensureMatchMedia();
const isMobile = mode === "mobile";
Object.defineProperty(window, "innerWidth", { value: isMobile ? 375 : 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches:
isMobile &&
(query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
/**
* FNXC:DashboardTests 2026-06-25-16:30:
* The former file-level beforeEach/afterEach from ChatView.test.tsx. Each split file calls
* this once at top level so the shared jsdom/global setup (object-URL, clipboard, matchMedia
* desktop default, viewport-height reset) is identical across the parallel chat files.
*/
export function installChatViewEnv() {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
_resetInitialViewportHeight();
setupMockRooms();
mockViewportMode("desktop");
mockFetchModels.mockResolvedValue({ ...defaultModelsResponse });
mockFetchDiscoveredSkills.mockResolvedValue([]);
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
Object.defineProperty(URL, "revokeObjectURL", { value: mockRevokeObjectURL, writable: true });
mockClipboardWriteText.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: mockClipboardWriteText },
configurable: true,
});
});
afterEach(() => {
vi.clearAllMocks();
localStorage.clear();
_resetInitialViewportHeight();
});
}

View File

@@ -121,7 +121,6 @@ const qualityAppComponentTests = [
"board-mobile",
"board-mobile-view-switch",
"BranchGroupCard",
"ChatView",
"ChatView.autosize",
"ChatView.chat-input-autosize",
"ChatView.default-model-icon",
@@ -212,7 +211,7 @@ const qualityAppComponentTests = [
// project, but the 231-test suite is now 4 sibling files sharing
// SettingsModal.test-harness so the settings project parallelizes them across
// workers instead of one ~61s sequential file (FN-5048 feedback-loop velocity).
const isolatedQualityAppComponentTests = ["App", "ChatView"] as const;
const isolatedQualityAppComponentTests = ["App"] as const;
const batchedQualityAppComponentTests = qualityAppComponentTests.filter(
(testName) => !isolatedQualityAppComponentTests.includes(testName),
);
@@ -238,7 +237,20 @@ const qualityAppComponentBatchATests = buildComponentQualityInclude(batchedQuali
const qualityAppComponentBatchBTests = buildComponentQualityInclude(batchedQualityAppComponentTestsB);
const qualityAppAppOnlyTests = ["app/components/__tests__/App.test.tsx"];
const qualityAppChatOnlyTests = ["app/components/__tests__/ChatView.test.tsx"];
/*
FNXC:DashboardTests 2026-06-25-16:30:
ChatView keeps its own isolated `dashboard-app-quality-chat` project, but the 231-test
ChatView.test.tsx is now 3 sibling files sharing ChatView.test-harness so the chat project
parallelizes them across workers instead of one ~24s sequential file (FN-5048 feedback-loop
velocity). Mirror the SettingsModal split: these 3 files live ONLY here (not in
qualityAppComponentTests), so they must be spread into backfillAppExclude too — otherwise
the broad `app/**` backfill glob re-collects them and they run in BOTH projects.
*/
const qualityAppChatOnlyTests = [
"app/components/__tests__/ChatView.core.test.tsx",
"app/components/__tests__/ChatView.sessions-rooms.test.tsx",
"app/components/__tests__/ChatView.mobile.test.tsx",
];
const qualityAppSettingsOnlyTests = [
"app/components/__tests__/SettingsModal.general.test.tsx",
"app/components/__tests__/SettingsModal.models-auth.test.tsx",
@@ -342,6 +354,10 @@ const backfillAppExclude = [
settings project and backfill, doubling their wall-time instead of halving it.
*/
...qualityAppSettingsOnlyTests,
// FNXC:DashboardTests 2026-06-25-16:30: same rationale as the settings split above —
// the 3 ChatView split files live only in qualityAppChatOnlyTests, so exclude them from
// the broad app backfill to avoid double-collection.
...qualityAppChatOnlyTests,
...skipListDashboardGlobs.filter((file) => file.startsWith("app/")),
"app/__tests__/build-output.test.ts",
];