Files
fusion/packages/dashboard/app/components/__tests__/Header.test.tsx
Fusion 6fa487ee7f feat(FN-3264): add droid plugin, improve agent detail view, and fix researc
This merge adds Agent Detail view improvements including model favoriting, wires in the droid bundled plugin, updates mobile split layout with proper safe-area padding, and adds research task-view persistence with associated test coverage across Header, MobileNavBar, App, and view state hooks.

Fusion-Task-Id: FN-3264
2026-05-04 09:20:07 -07:00

1442 lines
61 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Header } from "../Header";
// Mock fetchScripts for overflow submenu
const mockFetchScripts = vi.fn();
vi.mock("../../api", () => ({
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
const noop = () => {};
// Helper to mock mobile/tablet/desktop viewport
type ViewportTier = "mobile" | "tablet" | "desktop";
function mockMatchMedia(tier: ViewportTier) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => {
let matches = false;
if (tier === "mobile" && query.includes("max-width: 768px")) {
matches = true;
} else if (tier === "tablet" && query.includes("769px") && query.includes("1024px")) {
matches = true;
}
// desktop: neither mobile nor tablet query matches
return {
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
function renderHeader(props = {}, tier: ViewportTier = "desktop") {
mockMatchMedia(tier);
return render(
<Header
onOpenSettings={noop}
onOpenGitHubImport={noop}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
{...props}
/>
);
}
describe("Header", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchScripts.mockResolvedValue({});
});
it("renders the logo and brand", () => {
renderHeader();
expect(screen.getByText("Fusion")).toBeDefined();
});
it("renders action buttons", () => {
renderHeader();
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
expect(screen.getByTitle("Settings")).toBeDefined();
});
it("renders system stats button on desktop when handler is provided", () => {
renderHeader({ onOpenSystemStats: vi.fn() }, "desktop");
expect(screen.getByTitle("System Stats")).toBeDefined();
});
it("calls onOpenSystemStats when system stats button is clicked", () => {
const onOpenSystemStats = vi.fn();
renderHeader({ onOpenSystemStats }, "desktop");
fireEvent.click(screen.getByTitle("System Stats"));
expect(onOpenSystemStats).toHaveBeenCalled();
});
it("calls onOpenSettings when settings button is clicked", () => {
const onOpenSettings = vi.fn();
renderHeader({ onOpenSettings });
fireEvent.click(screen.getByTitle("Settings"));
expect(onOpenSettings).toHaveBeenCalled();
});
it("calls onOpenGitHubImport when import button is clicked", () => {
const onOpenGitHubImport = vi.fn();
renderHeader({ onOpenGitHubImport });
fireEvent.click(screen.getByTitle("Import from GitHub"));
expect(onOpenGitHubImport).toHaveBeenCalled();
});
describe("view toggle", () => {
it("does not render view toggle when onChangeView is not provided", () => {
renderHeader();
expect(screen.queryByTitle("Board view")).toBeNull();
expect(screen.queryByTitle("List view")).toBeNull();
});
it("renders view toggle when onChangeView is provided", () => {
renderHeader({ onChangeView: noop });
expect(screen.getByTitle("Board view")).toBeDefined();
expect(screen.getByTitle("List view")).toBeDefined();
});
it("shows board view as active by default", () => {
renderHeader({ onChangeView: noop });
const boardBtn = screen.getByTitle("Board view");
const listBtn = screen.getByTitle("List view");
expect(boardBtn.className).toContain("active");
expect(listBtn.className).not.toContain("active");
});
it("shows list view as active when view is 'list'", () => {
renderHeader({ onChangeView: noop, view: "list" });
const boardBtn = screen.getByTitle("Board view");
const listBtn = screen.getByTitle("List view");
expect(boardBtn.className).not.toContain("active");
expect(listBtn.className).toContain("active");
});
it("calls onChangeView with 'board' when clicking board view button", () => {
const onChangeView = vi.fn();
renderHeader({ onChangeView, view: "list" });
fireEvent.click(screen.getByTitle("Board view"));
expect(onChangeView).toHaveBeenCalledWith("board");
});
it("calls onChangeView with 'list' when clicking list view button", () => {
const onChangeView = vi.fn();
renderHeader({ onChangeView, view: "board" });
fireEvent.click(screen.getByTitle("List view"));
expect(onChangeView).toHaveBeenCalledWith("list");
});
it("has correct aria attributes for accessibility", () => {
renderHeader({ onChangeView: noop, view: "board" });
const boardBtn = screen.getByTitle("Board view");
const listBtn = screen.getByTitle("List view");
expect(boardBtn.getAttribute("aria-pressed")).toBe("true");
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
});
it("renders view overflow trigger when todos are enabled", () => {
renderHeader({ onChangeView: noop, todosEnabled: true });
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("shows the Todos entry in view overflow when todos are enabled", () => {
renderHeader({ onChangeView: noop, onOpenTodos: vi.fn(), todosEnabled: true });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
});
it("renders plugin dashboard views by placement and uses manifest icon metadata", () => {
const onChangeView = vi.fn();
renderHeader({
onChangeView,
pluginDashboardViews: [
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary" },
},
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "queue", label: "Queue", componentPath: "./QueueView", icon: "Workflow" },
},
],
});
const graphPrimary = screen.getByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph");
expect(graphPrimary.querySelector(".lucide-map")).toBeTruthy();
fireEvent.click(graphPrimary);
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
const queueItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-queue");
expect(queueItem.querySelector(".lucide-workflow")).toBeTruthy();
fireEvent.click(queueItem);
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
});
it("hides legacy roadmaps overflow item when roadmap plugin view is present", () => {
renderHeader({
onChangeView: noop,
experimentalFeatures: { roadmap: true },
pluginDashboardViews: [
{
pluginId: "fusion-plugin-roadmap",
view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./RoadmapsView", icon: "Map", placement: "primary" },
},
],
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-roadmaps")).toBeNull();
});
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {
renderHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("renders view overflow trigger when skills tab is enabled", () => {
renderHeader({ onChangeView: noop, showSkillsTab: true });
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("does not render research in overflow when researchView is disabled", () => {
renderHeader({
onChangeView: noop,
showSkillsTab: false,
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, researchView: false },
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-research")).toBeNull();
});
it("routes to research from the desktop view overflow when enabled", () => {
const onChangeView = vi.fn();
renderHeader({
onChangeView,
experimentalFeatures: { researchView: true },
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(screen.getByTestId("view-overflow-research"));
expect(onChangeView).toHaveBeenCalledWith("research");
expect(screen.queryByTestId("view-overflow-research")).toBeNull();
});
});
describe("terminal split button", () => {
it("renders terminal main button and scripts chevron on desktop", () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
expect(screen.getByTitle("Open Terminal")).toBeDefined();
expect(screen.getByTestId("scripts-btn")).toBeDefined();
});
it("does not render terminal button inline on mobile", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
expect(screen.queryByTitle("Open Terminal")).toBeNull();
});
it("clicking main button calls onToggleTerminal without opening scripts dropdown", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("terminal-toggle-btn"));
expect(onToggleTerminal).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
it("clicking scripts chevron opens dropdown without calling onToggleTerminal", async () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(onToggleTerminal).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
});
it("fetches scripts and runs selected script from dropdown", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript, projectId: "proj-1" }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
it("shows loading state while scripts are fetching", () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
});
it("shows empty state when no scripts are configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
it("shows manage scripts footer when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({ test: "pnpm test" });
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
});
it("supports keyboard navigation in scripts dropdown", async () => {
mockFetchScripts.mockResolvedValue({ alpha: "echo alpha", beta: "echo beta" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(onRunScript).toHaveBeenCalledWith("alpha", "echo alpha");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(screen.getByTestId("quick-scripts-dropdown"), { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("is always enabled regardless of task state", () => {
renderHeader({ onToggleTerminal: noop }, "desktop");
const btn = screen.getByTitle("Open Terminal");
expect(btn.hasAttribute("disabled")).toBe(false);
});
});
describe("files button", () => {
it("renders files button on desktop when handler is provided", () => {
renderHeader({ onOpenFiles: vi.fn() }, "desktop");
expect(screen.getByTitle("Browse files")).toBeDefined();
});
it("does not render files button on desktop when handler is omitted", () => {
renderHeader({}, "desktop");
expect(screen.queryByTitle("Browse files")).toBeNull();
});
it("calls onOpenFiles when desktop files button is clicked", () => {
const onOpenFiles = vi.fn();
renderHeader({ onOpenFiles }, "desktop");
fireEvent.click(screen.getByTitle("Browse files"));
expect(onOpenFiles).toHaveBeenCalled();
});
it("applies active class when files modal is open", () => {
renderHeader({ onOpenFiles: vi.fn(), filesOpen: true }, "desktop");
expect(screen.getByTitle("Browse files").className).toContain("btn-icon--active");
});
it("shows files action in mobile overflow menu", () => {
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-files-btn")).toBeDefined();
});
it("calls onOpenFiles from mobile overflow menu", () => {
const onOpenFiles = vi.fn();
renderHeader({ onOpenFiles }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-files-btn"));
expect(onOpenFiles).toHaveBeenCalled();
});
});
describe("todos button", () => {
it("renders todos button on desktop when enabled", () => {
renderHeader({ onOpenTodos: vi.fn(), todosEnabled: true }, "desktop");
expect(screen.getByTitle("Open todos")).toBeDefined();
});
it("does not render todos button when disabled", () => {
renderHeader({ onOpenTodos: vi.fn(), todosEnabled: false }, "desktop");
expect(screen.queryByTitle("Open todos")).toBeNull();
});
it("calls onOpenTodos when clicked", () => {
const onOpenTodos = vi.fn();
renderHeader({ onOpenTodos, todosEnabled: true }, "desktop");
fireEvent.click(screen.getByTitle("Open todos"));
expect(onOpenTodos).toHaveBeenCalled();
});
});
describe("pause controls", () => {
it("renders engine control split-button", () => {
renderHeader();
expect(screen.getByTestId("engine-control-main-btn")).toBeDefined();
expect(screen.getByTestId("engine-control-chevron-btn")).toBeDefined();
});
it("renders pause triage option in dropdown", () => {
renderHeader();
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
expect(screen.getByTestId("engine-control-pause-triage-btn")).toBeDefined();
});
it("calls onToggleEnginePause when pause triage item is clicked", () => {
const onToggleEnginePause = vi.fn();
renderHeader({ onToggleEnginePause });
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn"));
expect(onToggleEnginePause).toHaveBeenCalled();
});
it("calls onToggleGlobalPause when main button is clicked", () => {
const onToggleGlobalPause = vi.fn();
renderHeader({ onToggleGlobalPause });
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
expect(onToggleGlobalPause).toHaveBeenCalled();
});
it("shows resume text in dropdown when engine is paused", () => {
renderHeader({ enginePaused: true });
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
expect(screen.getByTitle("Resume scheduling")).toBeDefined();
});
it("shows start AI engine title on main button when global is paused", () => {
renderHeader({ globalPaused: true });
expect(screen.getByTitle("Start AI engine")).toBeDefined();
});
});
describe("usage button", () => {
it("does not render usage button when onOpenUsage is not provided", () => {
renderHeader({}, "desktop");
expect(screen.queryByTitle("View usage")).toBeNull();
});
it("does not render usage button when onOpenUsage is not provided on mobile", () => {
renderHeader({}, "mobile");
expect(screen.queryByTitle("View usage")).toBeNull();
});
it("renders usage button with correct title when onOpenUsage is provided on desktop", () => {
renderHeader({ onOpenUsage: vi.fn() }, "desktop");
expect(screen.getByTitle("View usage")).toBeDefined();
expect(screen.getByTestId("desktop-header-usage-btn")).toBeDefined();
});
it("does not render usage button inline on mobile when onOpenUsage is provided", () => {
renderHeader({ onOpenUsage: vi.fn() }, "mobile");
// Button should NOT be inline on mobile (it's in overflow menu)
expect(screen.queryByTitle("View usage")).toBeNull();
expect(screen.queryByTestId("desktop-header-usage-btn")).toBeNull();
});
it("shows usage in overflow menu on mobile", () => {
renderHeader({ onOpenUsage: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
});
it("calls onOpenUsage with button bounds when usage button is clicked on desktop", () => {
const onOpenUsage = vi.fn();
renderHeader({ onOpenUsage }, "desktop");
const usageButton = screen.getByTestId("desktop-header-usage-btn") as HTMLButtonElement;
const mockRect = {
top: 12,
bottom: 52,
left: 820,
right: 860,
width: 40,
height: 40,
x: 820,
y: 12,
toJSON: () => ({}),
} as DOMRect;
usageButton.getBoundingClientRect = vi.fn(() => mockRect);
fireEvent.click(usageButton);
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
});
it("calls onOpenUsage with button bounds when usage button in overflow menu is clicked", () => {
const onOpenUsage = vi.fn();
renderHeader({ onOpenUsage }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
const usageButton = screen.getByTestId("overflow-usage-btn") as HTMLButtonElement;
const mockRect = {
top: 100,
bottom: 132,
left: 20,
right: 180,
width: 160,
height: 32,
x: 20,
y: 100,
toJSON: () => ({}),
} as DOMRect;
usageButton.getBoundingClientRect = vi.fn(() => mockRect);
fireEvent.click(usageButton);
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
});
});
describe("activity log button", () => {
it("does not render activity log button when onOpenActivityLog is not provided", () => {
renderHeader({}, "desktop");
expect(screen.queryByTitle("View Activity Log")).toBeNull();
});
it("does not render activity log button when onOpenActivityLog is not provided on mobile", () => {
renderHeader({}, "mobile");
expect(screen.queryByTitle("View Activity Log")).toBeNull();
});
it("renders activity log button with correct title when onOpenActivityLog is provided on desktop", () => {
renderHeader({ onOpenActivityLog: vi.fn() }, "desktop");
expect(screen.getByTitle("View Activity Log")).toBeDefined();
});
it("does not render activity log button inline on mobile when onOpenActivityLog is provided", () => {
renderHeader({ onOpenActivityLog: vi.fn() }, "mobile");
// Button should NOT be inline on mobile (it's in overflow menu)
expect(screen.queryByTitle("View Activity Log")).toBeNull();
});
it("shows activity log in overflow menu on mobile", () => {
renderHeader({ onOpenActivityLog: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-activity-log-btn")).toBeDefined();
});
it("calls onOpenActivityLog when activity log button is clicked on desktop", () => {
const onOpenActivityLog = vi.fn();
renderHeader({ onOpenActivityLog }, "desktop");
fireEvent.click(screen.getByTitle("View Activity Log"));
expect(onOpenActivityLog).toHaveBeenCalled();
});
it("calls onOpenActivityLog when activity log button in overflow menu is clicked", () => {
const onOpenActivityLog = vi.fn();
renderHeader({ onOpenActivityLog }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-activity-log-btn"));
expect(onOpenActivityLog).toHaveBeenCalled();
});
});
describe("planning button", () => {
it("renders planning button with correct title on desktop", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
});
it("does not render planning button inline on mobile", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "mobile");
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
});
it("calls onOpenPlanning when planning button is clicked", () => {
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning }, "desktop");
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("has correct data-testid for testing on desktop", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.getByTestId("planning-btn")).toBeDefined();
});
describe("active session badge", () => {
it("does not render badge when activePlanningSessionCount is 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 0 }, "desktop");
expect(screen.queryByTestId("planning-badge")).toBeNull();
});
it("does not render badge when activePlanningSessionCount is undefined", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.queryByTestId("planning-badge")).toBeNull();
});
it("renders badge when activePlanningSessionCount > 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTestId("planning-badge")).toBeDefined();
});
it("badge shows correct count", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 3 }, "desktop");
expect(screen.getByTestId("planning-badge").textContent).toBe("3");
});
it("updates title to 'Resume planning session' when count > 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTitle("Resume planning session")).toBeDefined();
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
});
it("keeps original title when count is 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 0 }, "desktop");
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
});
it("calls onResumePlanning when clicked with active sessions", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 2 }, "desktop");
fireEvent.click(screen.getByTitle("Resume planning session"));
expect(onResumePlanning).toHaveBeenCalled();
expect(onOpenPlanning).not.toHaveBeenCalled();
});
it("calls onOpenPlanning when clicked with no active sessions", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 0 }, "desktop");
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
expect(onResumePlanning).not.toHaveBeenCalled();
});
it("calls onOpenPlanning when clicked with active sessions but no onResumePlanning", () => {
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, activePlanningSessionCount: 1 }, "desktop");
// Without onResumePlanning, falls back to onOpenPlanning even with active sessions
fireEvent.click(screen.getByTitle("Resume planning session"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("badge has correct aria-label", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 2 }, "desktop");
expect(screen.getByTestId("planning-badge").getAttribute("aria-label")).toBe("2 active planning sessions");
});
it("badge aria-label uses singular for count of 1", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTestId("planning-badge").getAttribute("aria-label")).toBe("1 active planning session");
});
});
});
describe("mobile overflow menu", () => {
it("renders overflow trigger on mobile", () => {
renderHeader({}, "mobile");
expect(screen.getByTitle("More header actions")).toBeDefined();
});
it("does not render overflow trigger on desktop", () => {
renderHeader({}, "desktop");
expect(screen.queryByTitle("More header actions")).toBeNull();
});
it("shows terminal group in overflow menu on mobile", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-submenu-toggle")).toBeDefined();
});
it("shows terminal submenu items when terminal group is expanded on mobile", async () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("shows scripts manage in terminal submenu on mobile when onOpenScripts is provided", async () => {
renderHeader({ onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("does not show scripts manage in terminal submenu when onOpenScripts is undefined", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
it("calls onToggleTerminal from primary terminal button on mobile", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("calls onOpenScripts from terminal submenu manage on mobile", async () => {
const onOpenScripts = vi.fn();
renderHeader({ onOpenScripts }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-scripts-manage"));
expect(onOpenScripts).toHaveBeenCalled();
});
it("primary terminal button opens terminal directly without expanding submenu", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Click primary button — should open terminal and NOT expand submenu
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
// Overflow menu should close after action
expect(screen.queryByRole("menu")).toBeNull();
});
it("chevron toggle expands submenu without opening terminal", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Click chevron — should expand submenu but NOT call onToggleTerminal
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(onToggleTerminal).not.toHaveBeenCalled();
// Overflow menu should still be open (check by primary button still being visible)
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
});
it("renders one script item per fetched script in submenu", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build", test: "pnpm test" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
expect(screen.getByTestId("overflow-script-item-test")).toBeDefined();
});
});
it("clicking a script entry calls onRunScript and closes overflow", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
// Overflow menu should close after running script
expect(screen.queryByRole("menu")).toBeNull();
});
it("does not render old overflow-scripts-btn item", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onRunScript: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
// The old generic scripts button should not exist
expect(screen.queryByTestId("overflow-scripts-btn")).toBeNull();
// The old terminal submenu "Open Terminal" button should not exist
expect(screen.queryByTestId("overflow-terminal-btn")).toBeNull();
});
it("shows loading state while fetching scripts", () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(screen.getByTestId("overflow-scripts-loading")).toBeDefined();
});
it("shows manage scripts link when no scripts are configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("does not show manage scripts link when onOpenScripts is undefined", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
});
it("handles missing onRunScript gracefully", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
// Clicking script without onRunScript should not throw
expect(() => {
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
}).not.toThrow();
// Overflow menu should still close
expect(screen.queryByRole("menu")).toBeNull();
});
it("shows Manage Scripts after script entries when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onRunScript: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("shows GitHub import in overflow menu on mobile", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Import from GitHub")).toBeDefined();
});
it("shows planning in overflow menu on mobile", () => {
renderHeader({ onOpenPlanning: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
});
it("shows planning badge in overflow menu when activePlanningSessionCount > 0", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 1 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-badge")).toBeDefined();
expect(screen.getByTestId("overflow-planning-badge").textContent).toBe("1");
});
it("does not show planning badge in overflow menu when count is 0", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 0 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.queryByTestId("overflow-planning-badge")).toBeNull();
});
it("calls onResumePlanning from overflow menu when active sessions exist", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 2 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-planning-btn"));
expect(onResumePlanning).toHaveBeenCalled();
expect(onOpenPlanning).not.toHaveBeenCalled();
});
it("shows resume text in overflow menu when active sessions exist", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 1 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Resume planning session (1)")).toBeDefined();
});
it("shows settings in overflow menu on mobile", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Settings")).toBeDefined();
});
});
describe("nodes button", () => {
it("renders Nodes button in desktop overflow when handler is provided", () => {
renderHeader({ onOpenNodes: vi.fn() }, "desktop");
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-nodes-btn")).toBeDefined();
});
it("calls onOpenNodes when Nodes button is clicked from desktop overflow", () => {
const onOpenNodes = vi.fn();
renderHeader({ onOpenNodes }, "desktop");
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
fireEvent.click(screen.getByTestId("desktop-overflow-nodes-btn"));
expect(onOpenNodes).toHaveBeenCalled();
});
it("shows Nodes action in mobile overflow menu", () => {
const onOpenNodes = vi.fn();
renderHeader({ onOpenNodes }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-nodes-btn"));
expect(onOpenNodes).toHaveBeenCalled();
});
});
describe("non-mobile search toggle", () => {
it("does not render search toggle when onSearchChange is not provided", () => {
renderHeader({ view: "board" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("renders search toggle button when onSearchChange and view='board' are provided", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
});
it("renders search toggle button when onSearchChange and view='list' are provided", () => {
renderHeader({ onSearchChange: vi.fn(), view: "list" });
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
});
it("does not render search toggle when view is 'agents'", () => {
renderHeader({ onSearchChange: vi.fn(), view: "agents" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("does not render search toggle when view is 'missions'", () => {
renderHeader({ onSearchChange: vi.fn(), view: "missions" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("does not render search input by default when toggle is visible", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("opens search input when toggle button is clicked", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("closes search when close button is clicked", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
fireEvent.click(screen.getByLabelText("Close search"));
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("clears search query when close button is clicked", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.click(screen.getByLabelText("Close search"));
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("keeps search open when searchQuery is non-empty", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board", searchQuery: "test" });
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("shows search input with active query and hides toggle", () => {
renderHeader({ onSearchChange: vi.fn(), view: "list", searchQuery: "test" });
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.getByDisplayValue("test")).toBeDefined();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("calls onSearchChange when typing in search input", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
const input = screen.getByPlaceholderText("Search tasks...");
fireEvent.change(input, { target: { value: "test query" } });
expect(onSearchChange).toHaveBeenCalledWith("test query");
});
it("search input has correct placeholder text", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
const input = screen.getByPlaceholderText("Search tasks...");
expect(input).toBeDefined();
});
it("renders search input inside header-floating-search on desktop board view", () => {
const { container } = renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(container.querySelector(".header-floating-search .header-search")).not.toBeNull();
});
it("does not render search input inside header-actions", () => {
const { container } = renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(container.querySelector(".header-actions .header-search")).toBeNull();
});
it("renders header-wrapper containing both header and floating search", () => {
const { container } = renderHeader({ onSearchChange: vi.fn(), view: "board" });
const wrapper = container.querySelector(".header-wrapper");
expect(wrapper).not.toBeNull();
expect(wrapper!.querySelector("header.header")).not.toBeNull();
});
it("toggling search twice reopens the search (use close button to dismiss)", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Second toggle click reopens search since first close was via toggle
// (toggle always opens, use close button to dismiss)
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
// Search stays open because toggle only opens
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Use close button to dismiss
fireEvent.click(screen.getByLabelText("Close search"));
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("supports search toggle flow on list view", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "list" });
// Toggle visible on list view
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
// Click toggle
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
// Search opens
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Close and clear
fireEvent.click(screen.getByLabelText("Close search"));
expect(onSearchChange).toHaveBeenCalledWith("");
});
});
describe("automation button", () => {
it("renders automation button in desktop overflow", () => {
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-schedules-btn")).toBeDefined();
});
it("does not render automation button inline on mobile", () => {
renderHeader({ onOpenSchedules: vi.fn() }, "mobile");
expect(screen.queryByTitle("Automation")).toBeNull();
});
it("calls onOpenSchedules when automation button is clicked from desktop overflow", () => {
const onOpenSchedules = vi.fn();
renderHeader({ onOpenSchedules }, "desktop");
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
fireEvent.click(screen.getByTestId("desktop-overflow-schedules-btn"));
expect(onOpenSchedules).toHaveBeenCalled();
});
it("has correct data-testid for testing on desktop", () => {
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-schedules-btn")).toBeDefined();
});
it("includes automation in overflow menu on mobile", () => {
renderHeader({ onOpenSchedules: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Automation")).toBeDefined();
});
it("calls onOpenSchedules from mobile overflow menu", () => {
const onOpenSchedules = vi.fn();
renderHeader({ onOpenSchedules }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-schedules-btn"));
expect(onOpenSchedules).toHaveBeenCalled();
});
});
describe("mobile header layout", () => {
it("applies header-project-selector class when multiple projects exist on mobile", () => {
const { container } = renderHeader({
projects: [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
],
currentProject: { id: "1", name: "Project One", path: "/path/one", status: "active" as const },
}, "mobile");
expect(container.querySelector(".header-project-selector")).toBeDefined();
});
it("does not show project selector on mobile with single project", () => {
const { container } = renderHeader({
projects: [{ id: "1", name: "Project One", path: "/path/one", status: "active" as const }],
}, "mobile");
expect(container.querySelector(".header-project-selector")).toBeNull();
});
it("renders header-back-button when currentProject is set on mobile", () => {
const { container } = renderHeader({
currentProject: { id: "1", name: "Project One", path: "/path/one", status: "active" as const },
onViewAllProjects: vi.fn(),
}, "mobile");
expect(container.querySelector(".header-back-button")).toBeDefined();
});
it("does not render header-back-button on mobile when no currentProject", () => {
const { container } = renderHeader({}, "mobile");
expect(container.querySelector(".header-back-button")).toBeNull();
});
it("mobile overflow menu closes when clicking outside", () => {
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
// Click outside the menu
fireEvent.mouseDown(document.body);
expect(screen.queryByRole("menu")).toBeNull();
});
it("mobile overflow menu closes on Escape key", () => {
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
it("mobile overflow trigger has correct accessibility attributes", () => {
renderHeader({}, "mobile");
const trigger = screen.getByTitle("More header actions");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
});
it("hides logo-sub on mobile via CSS", () => {
renderHeader({}, "mobile");
// The "tasks" element no longer exists - it was removed
});
});
describe("mobile search with mobileNavEnabled", () => {
it("renders mobile search input when searchQuery is active with mobileNavEnabled", () => {
renderHeader({ view: "board", searchQuery: "test query", onSearchChange: vi.fn(), onChangeView: noop }, "mobile");
// Search should be visible even with mobileNavEnabled when query is active
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.getByDisplayValue("test query")).toBeDefined();
});
it("can open mobile search when mobileNavEnabled is true", () => {
renderHeader({ view: "board", searchQuery: "", onSearchChange: vi.fn(), onChangeView: noop }, "mobile");
// Should show the trigger button
expect(screen.getByTestId("mobile-header-search-btn")).toBeDefined();
// Expanded search should not be visible initially
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("closes mobile search and clears query when close button clicked with mobileNavEnabled", () => {
const onSearchChange = vi.fn();
renderHeader({ view: "board", searchQuery: "test query", onSearchChange, onChangeView: noop }, "mobile");
const closeBtn = screen.getByLabelText("Close search");
fireEvent.click(closeBtn);
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("does not render mobile project switch trigger on desktop", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "desktop");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("does not render mobile project switch trigger on tablet", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "tablet");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("renders mobile project switch trigger on mobile with 2+ projects", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
});
it("renders mobile project switch trigger on mobile with single project", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
});
it("closes compact project switch dropdown on Escape in mobile mode", async () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "paused" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
});
it("closes compact project switch dropdown on outside click in mobile mode", async () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "paused" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
});
it("closes compact project switch dropdown after selecting a project in mobile mode", async () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "paused" as const },
];
const onSelectProject = vi.fn();
renderHeader({
projects,
currentProject: projects[0],
onSelectProject,
}, "mobile");
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
fireEvent.click(screen.getByTestId("mobile-project-switch-item-2"));
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
await waitFor(() => {
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
});
});
describe("Manage Projects action", () => {
const singleProject = [
{ id: "1", name: "Test Project", path: "/path/to/project", status: "active" as const },
];
it("renders project selector trigger on desktop with a single project", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects: noop,
}, "desktop");
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
});
it("shows current project name in the desktop project selector trigger", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects: noop,
}, "desktop");
const trigger = screen.getByTestId("project-selector-trigger");
expect(trigger).toHaveTextContent("Test Project");
});
it("includes the full active project name in the trigger title for truncated labels", () => {
const longName = "This is a very long project name that should be truncated in the header trigger";
const projects = [
{ id: "1", name: longName, path: "/path/to/project", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onViewAllProjects: noop,
}, "desktop");
const trigger = screen.getByTestId("project-selector-trigger");
expect(trigger).toHaveTextContent(longName);
expect(trigger).toHaveAttribute("title", `Switch project (current: ${longName})`);
});
it("falls back to 'Projects' label when current project is missing", () => {
renderHeader({
projects: singleProject,
currentProject: null,
onViewAllProjects: noop,
}, "desktop");
const trigger = screen.getByTestId("project-selector-trigger");
expect(trigger).toHaveTextContent("Projects");
});
it("shows Manage Projects action in dropdown and calls onViewAllProjects", () => {
const onViewAllProjects = vi.fn();
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects,
}, "desktop");
fireEvent.click(screen.getByTestId("project-selector-trigger"));
fireEvent.click(screen.getByTestId("manage-projects-action"));
expect(onViewAllProjects).toHaveBeenCalled();
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
});
it("does not render separate back button on desktop", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
onViewAllProjects: noop,
}, "desktop");
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("does not render project selector when onViewAllProjects is not provided", () => {
renderHeader({
projects: singleProject,
currentProject: singleProject[0],
}, "desktop");
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
});
});
describe("action ordering", () => {
it("Settings is the last inline action on desktop (after stop button)", () => {
const { container } = renderHeader({
onOpenUsage: noop,
onOpenActivityLog: noop,
onOpenWorkflowSteps: noop,
onOpenFiles: noop,
onOpenGitManager: noop,
onOpenScripts: noop,
onRunScript: noop,
}, "desktop");
// Get direct children of header-actions: top-level btn-icon buttons AND the split-button container
const headerActions = container.querySelector(".header-actions")!;
const inlineItems = Array.from(
headerActions.querySelectorAll<HTMLElement>(
":scope > button.btn-icon, :scope > .engine-control-split-btn"
)
);
const settingsIdx = inlineItems.findIndex(
(el) => el instanceof HTMLButtonElement && el.title === "Settings"
);
const splitBtnIdx = inlineItems.findIndex((el) =>
el.classList.contains("engine-control-split-btn")
);
expect(settingsIdx).toBeGreaterThanOrEqual(0);
expect(splitBtnIdx).toBeGreaterThanOrEqual(0);
expect(settingsIdx).toBeGreaterThan(splitBtnIdx);
const itemsAfterSettings = inlineItems.slice(settingsIdx + 1);
expect(itemsAfterSettings).toHaveLength(0);
});
it("Settings is the last item in the mobile overflow menu", () => {
const { container } = renderHeader({
onOpenUsage: noop,
onOpenActivityLog: noop,
onOpenWorkflowSteps: noop,
onOpenFiles: noop,
onOpenGitManager: noop,
}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Get all menu items inside the overflow menu
const menu = container.querySelector(".mobile-overflow-menu")!;
const menuItems = Array.from(menu.querySelectorAll<HTMLButtonElement>("button.mobile-overflow-item"));
// The last menu item should be Settings
const lastItem = menuItems[menuItems.length - 1];
expect(lastItem.textContent).toBe("Settings");
});
it("Settings is the last item in the mobile overflow menu even when optional items are absent", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Get the overflow menu items
const menu = screen.getByRole("menu");
const menuItems = Array.from(menu.querySelectorAll<HTMLButtonElement>("button[role='menuitem']"));
const lastItem = menuItems[menuItems.length - 1];
expect(lastItem.textContent).toBe("Settings");
});
});
});