Files
fusion/packages/dashboard/app/components/__tests__/utility-mobile.test.tsx
gsxdsm f11800f2c6 refactor(dashboard): split monolithic styles.css into per-component files
Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).

- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
  React.lazy + Suspense; prefetch all chunks on idle so first navigation is
  instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
  635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.

- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
  so CSS regression tests load the full per-component bundle (mirroring Vite
  source order). Migrate 30+ tests off direct readFileSync('../styles.css').

- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
  imports actually inject styles in jsdom (fixes getComputedStyle assertions).

- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
  dashboard test files; points at loadAllAppCss() instead.

- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
  .form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.

- Mobile fixes along the way: scroll containment for view containers
  (min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
  mobile (with safe-area-inset for iOS home bar), AgentsView single-row
  header layout, ActivityLogModal close button on right, model-combobox
  z-index above the mobile quick-chat panel.

- Bug fix: SkillsView toggle was display:none which hid the input from the
  accessibility tree; replaced with the visually-hidden pattern so screen
  readers + getByRole still find the checkbox.

- Bug fix: standalone Delete button in TaskDetailModal for triage-column
  tasks (Actions dropdown is hidden in triage state, so previously no way
  to delete a freshly-created task without status change first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

240 lines
7.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Agent, AiSessionSummary } from "../../api";
import type { Toast } from "../../hooks/useToast";
vi.mock("../../hooks/useExecutorStats", () => ({
useExecutorStats: vi.fn(),
}));
vi.mock("../../hooks/useLiveTranscript", () => ({
useLiveTranscript: vi.fn(() => ({
entries: [],
isConnected: false,
})),
}));
import { useExecutorStats } from "../../hooks/useExecutorStats";
import { BackgroundTasksIndicator } from "../BackgroundTasksIndicator";
import { ExecutorStatusBar } from "../ExecutorStatusBar";
import { ActiveAgentsPanel } from "../ActiveAgentsPanel";
import { ToastContainer } from "../ToastContainer";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function expectMobileRule(css: string, selector: string, declaration: string): void {
const pattern = new RegExp(
`@media\\s*\\(max-width:\\s*768px\\)\\s*\\{[\\s\\S]*?${escapeRegExp(selector)}\\s*\\{[\\s\\S]*?${escapeRegExp(declaration)}`,
);
expect(pattern.test(css)).toBe(true);
}
describe("Utility component mobile adaptations", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useExecutorStats).mockReturnValue({
stats: {
runningTaskCount: 1,
blockedTaskCount: 2,
stuckTaskCount: 0,
queuedTaskCount: 3,
inReviewCount: 4,
executorState: "running",
maxConcurrent: 5,
lastActivityAt: new Date().toISOString(),
},
loading: false,
error: null,
refresh: vi.fn(),
});
});
it("renders BackgroundTasksIndicator pill when sessions exist", () => {
const sessions: AiSessionSummary[] = [
{
id: "sess-1",
type: "planning",
status: "generating",
title: "Refine onboarding flow",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={1}
needsInput={0}
onOpenSession={vi.fn()}
onDismissSession={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /AI 1/i })).toBeTruthy();
});
it("renders BackgroundTasksIndicator popover on pill click", () => {
const sessions: AiSessionSummary[] = [
{
id: "sess-2",
type: "subtask",
status: "awaiting_input",
title: "Break down API tasks",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={1}
onOpenSession={vi.fn()}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
expect(screen.getByText("Background Tasks")).toBeTruthy();
expect(screen.getByText("Break down API tasks")).toBeTruthy();
});
it("returns null for BackgroundTasksIndicator with no sessions", () => {
const { container } = render(
<BackgroundTasksIndicator
sessions={[]}
generating={0}
needsInput={0}
onOpenSession={vi.fn()}
onDismissSession={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
it("calls onOpenSession when clicking on a milestone_interview session item", () => {
const onOpenSession = vi.fn();
const sessions: AiSessionSummary[] = [
{
id: "sess-milestone-1",
type: "milestone_interview",
status: "awaiting_input",
title: "Plan milestone scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={1}
onOpenSession={onOpenSession}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
fireEvent.click(screen.getByText("Plan milestone scope"));
expect(onOpenSession).toHaveBeenCalledWith(sessions[0]);
});
it("calls onOpenSession when clicking on a slice_interview session item", () => {
const onOpenSession = vi.fn();
const sessions: AiSessionSummary[] = [
{
id: "sess-slice-1",
type: "slice_interview",
status: "error",
title: "Plan slice scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
},
];
render(
<BackgroundTasksIndicator
sessions={sessions}
generating={0}
needsInput={0}
onOpenSession={onOpenSession}
onDismissSession={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
fireEvent.click(screen.getByText("Plan slice scope"));
expect(onOpenSession).toHaveBeenCalledWith(sessions[0]);
});
it("renders ExecutorStatusBar segments", () => {
render(<ExecutorStatusBar tasks={[]} />);
const bar = screen.getByRole("status");
expect(bar).toHaveTextContent("Running");
expect(bar).toHaveTextContent("Blocked");
expect(bar).toHaveTextContent("Queued");
expect(bar).toHaveTextContent("In Review");
});
it("renders ActiveAgentsPanel grid and cards when agents are provided", () => {
const agents: Agent[] = [
{
id: "agent-1",
name: "Live Agent",
role: "executor",
state: "active",
taskId: "FN-555",
lastHeartbeatAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
const { container } = render(<ActiveAgentsPanel agents={agents} />);
expect(container.querySelector(".active-agents-grid")).toBeTruthy();
expect(container.querySelectorAll(".live-agent-card").length).toBe(1);
});
it("returns null for ActiveAgentsPanel when no agents are active", () => {
const { container } = render(<ActiveAgentsPanel agents={[]} />);
expect(container.firstChild).toBeNull();
});
it("renders toasts in ToastContainer", () => {
const toasts: Toast[] = [
{ id: 1, message: "Saved", type: "success" },
{ id: 2, message: "Failed", type: "error" },
];
const { container } = render(<ToastContainer toasts={toasts} onRemove={vi.fn()} />);
expect(container.querySelector(".toast-container")).toBeTruthy();
expect(container.querySelector(".toast-success")).toBeTruthy();
expect(container.querySelector(".toast-error")).toBeTruthy();
});
it("contains mobile CSS overrides for adapted utility and layout components", () => {
const css = loadAllAppCss();
expectMobileRule(css, ".settings-layout", "flex-direction: column;");
expectMobileRule(css, ".agent-board", "grid-template-columns: 1fr;");
expectMobileRule(css, ".active-agents-grid", "grid-template-columns: 1fr;");
expectMobileRule(css, ".toast-container", "bottom: calc(var(--mobile-nav-height, 44px) + var(--executor-footer-height, 0px) + var(--standalone-bottom-gap, 0px) + env(safe-area-inset-bottom, 0px) + var(--space-lg) + var(--space-2xl));");
expectMobileRule(css, ".background-tasks-indicator__popover", "position: fixed;");
});
});