Files
prowler/ui/components/side-panel/global-side-panel.test.tsx

428 lines
14 KiB
TypeScript

import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SIDE_PANEL_DEFAULT_WIDTH } from "@/lib/ui-layout";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
import { GlobalSidePanel } from "./global-side-panel";
const { isCloudMock } = vi.hoisted(() => ({ isCloudMock: vi.fn(() => true) }));
const navigationMocks = vi.hoisted(() => ({ pathname: "/findings" }));
// jsdom has no matchMedia: emulate the push (>= sm) viewport per test.
const mediaMocks = vi.hoisted(() => ({ isPushViewport: false }));
// Toggles a render failure to exercise the panel's local error boundary.
const chatMocks = vi.hoisted(() => ({ shouldThrow: false }));
// Adds a second registry tab on demand: the real registry only holds one
// today, but tab-switch isolation must already hold for when cloud adds more.
const registryMocks = vi.hoisted(() => ({ extraTab: false }));
vi.mock("@/lib/shared/env", () => ({ isCloud: isCloudMock }));
vi.mock("./side-panel-tabs", async (importOriginal) => {
const original = await importOriginal<typeof import("./side-panel-tabs")>();
return {
...original,
getVisibleSidePanelTabs: () => {
const tabs = original.getVisibleSidePanelTabs();
if (!registryMocks.extraTab) return tabs;
return [
...tabs,
{
id: "second-tab",
label: "Second tab",
Icon: () => null,
loadContent: () =>
Promise.resolve({
default: () => (
<div data-testid="second-tab-content">second content</div>
),
}),
Fallback: () => null,
isAvailable: () => true,
},
];
},
};
});
vi.mock("next/navigation", () => ({
usePathname: () => navigationMocks.pathname,
}));
vi.mock("@/hooks/use-media-query", () => ({
useMediaQuery: () => mediaMocks.isPushViewport,
}));
// The AI tab's real content pulls in the whole chat (server actions,
// streamdown); the shell test only cares that the registry's content mounts.
vi.mock(
"@/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat",
() => ({
LighthousePanelChat: () => {
if (chatMocks.shouldThrow) throw new Error("chunk load failed");
return <div data-testid="panel-chat-content">chat content</div>;
},
}),
);
describe("GlobalSidePanel", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
isCloudMock.mockReturnValue(true);
navigationMocks.pathname = "/findings";
mediaMocks.isPushViewport = false;
chatMocks.shouldThrow = false;
registryMocks.extraTab = false;
localStorage.clear();
useSidePanelStore.setState({
isOpen: false,
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
hasBeenOpened: false,
width: SIDE_PANEL_DEFAULT_WIDTH,
isResizing: false,
contextTab: null,
contextOwnerToken: 0,
contextOutlet: null,
});
});
it("renders closed and empty by default (lazy-mount)", () => {
// Given / When
render(<GlobalSidePanel />);
// Then: panel exists but is off-screen, inert, and mounts no content
const panel = screen.getByTestId("global-side-panel");
expect(panel).toHaveClass("translate-x-full");
expect(screen.queryByTestId("panel-chat-content")).not.toBeInTheDocument();
});
it("slides in and mounts the AI tab content when opened via the store", async () => {
// Given
render(<GlobalSidePanel />);
// When
act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT));
// Then
expect(screen.getByTestId("global-side-panel")).toHaveClass(
"translate-x-0",
);
expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
});
it("keeps the content mounted (hidden) after closing", async () => {
// Given
render(<GlobalSidePanel />);
act(() => useSidePanelStore.getState().openPanel());
await screen.findByTestId("panel-chat-content");
// When
act(() => useSidePanelStore.getState().closePanel());
// Then: off-screen but the chat DOM survives (scroll/draft preservation)
expect(screen.getByTestId("global-side-panel")).toHaveClass(
"translate-x-full",
);
expect(screen.getByTestId("panel-chat-content")).toBeInTheDocument();
});
it("closes on Escape", async () => {
// Given
const user = userEvent.setup();
render(<GlobalSidePanel />);
act(() => useSidePanelStore.getState().openPanel());
// When
await user.keyboard("{Escape}");
// Then
await waitFor(() =>
expect(useSidePanelStore.getState().isOpen).toBe(false),
);
});
it("toggles with Cmd/Ctrl+.", async () => {
// Given
const user = userEvent.setup();
render(<GlobalSidePanel />);
// When / Then
await user.keyboard("{Meta>}.{/Meta}");
expect(useSidePanelStore.getState().isOpen).toBe(true);
await user.keyboard("{Meta>}.{/Meta}");
expect(useSidePanelStore.getState().isOpen).toBe(false);
});
it("closes via the close button", async () => {
// Given
const user = userEvent.setup();
render(<GlobalSidePanel />);
act(() => useSidePanelStore.getState().openPanel());
// When
await user.click(screen.getByRole("button", { name: "Close side panel" }));
// Then
expect(useSidePanelStore.getState().isOpen).toBe(false);
});
it("groups the chat actions beside close in the existing header", async () => {
// Given
render(<GlobalSidePanel />);
// When
act(() => useSidePanelStore.getState().openPanel());
// Then: no extra toolbar; both actions are adjacent in the panel header
const newChat = await screen.findByRole("button", { name: "New chat" });
const fullPage = screen.getByRole("link", {
name: "Open Lighthouse AI full page",
});
const close = screen.getByRole("button", { name: "Close side panel" });
const actionGroup = close.parentElement;
expect(actionGroup).toContainElement(newChat);
expect(actionGroup).toContainElement(fullPage);
expect(actionGroup).toHaveClass("ml-auto");
expect(newChat).not.toHaveClass("ml-auto");
expect(close).not.toHaveClass("ml-auto");
});
it("renders nothing in OSS while no detail view is registered", () => {
// Given
isCloudMock.mockReturnValue(false);
// When
const { container } = render(<GlobalSidePanel />);
// Then
expect(container).toBeEmptyDOMElement();
});
it("hosts a registered detail view in OSS, without the AI tab", () => {
// Given
isCloudMock.mockReturnValue(false);
render(<GlobalSidePanel />);
// When: a detail view registers its context tab
act(() =>
useSidePanelStore.getState().registerContextTab({
label: "Details",
onRequestClose: vi.fn(),
}),
);
// Then: the panel appears with only the Details surface (no tab strip)
expect(screen.getByTestId("global-side-panel")).toHaveClass(
"translate-x-0",
);
expect(screen.getByText("Details")).toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.getByTestId("side-panel-context-outlet")).toBeInTheDocument();
});
it("shows Details and Lighthouse AI as switchable tabs in cloud", async () => {
// Given
const user = userEvent.setup();
render(<GlobalSidePanel />);
act(() =>
useSidePanelStore.getState().registerContextTab({
label: "Details",
onRequestClose: vi.fn(),
}),
);
// Then: the context tab opens selected, beside the AI tab
const detailsTab = screen.getByRole("tab", { name: "Details" });
expect(detailsTab).toHaveAttribute("aria-selected", "true");
expect(detailsTab).toHaveClass("aria-selected:after:scale-x-100");
expect(detailsTab).not.toHaveClass("rounded-[8px]");
expect(screen.getByTestId("side-panel-context-outlet")).toBeVisible();
const lighthouseTab = screen.getByRole("tab", { name: "Lighthouse AI" });
expect(lighthouseTab.querySelector("svg")?.parentElement).toHaveClass(
"flex",
);
// When: switching to the AI tab
await user.click(lighthouseTab);
// Then: the chat mounts and the detail outlet stays mounted, hidden
expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
expect(screen.getByTestId("side-panel-context-outlet")).not.toBeVisible();
// When: switching back
await user.click(screen.getByRole("tab", { name: "Details" }));
// Then
expect(screen.getByTestId("side-panel-context-outlet")).toBeVisible();
});
it("contains a lazy tab failure inside the panel and recovers via Retry", async () => {
// Given: the AI tab's content throws on render (e.g. chunk-load failure)
const user = userEvent.setup();
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
chatMocks.shouldThrow = true;
render(<GlobalSidePanel />);
// When
act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT));
// Then: the failure stays inside the panel body; the shell survives
expect(
await screen.findByText("This panel failed to load."),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Close side panel" }),
).toBeInTheDocument();
// When: the failure clears and the user retries
chatMocks.shouldThrow = false;
await user.click(screen.getByRole("button", { name: "Retry" }));
// Then: the content mounts normally
expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
consoleError.mockRestore();
});
it("renders the next tab fresh after another tab failed", async () => {
// Given: two registry tabs, with the first one failing to render
registryMocks.extraTab = true;
chatMocks.shouldThrow = true;
const user = userEvent.setup();
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
render(<GlobalSidePanel />);
act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT));
await screen.findByText("This panel failed to load.");
// When: the user switches to the healthy second tab
await user.click(screen.getByRole("tab", { name: "Second tab" }));
// Then: its content mounts instead of the previous tab's error state
expect(await screen.findByTestId("second-tab-content")).toBeInTheDocument();
expect(
screen.queryByText("This panel failed to load."),
).not.toBeInTheDocument();
consoleError.mockRestore();
});
it("keeps the context outlet attached across store-driven re-renders", () => {
// Given: a registered detail view with its portal outlet attached
render(<GlobalSidePanel />);
act(() =>
useSidePanelStore.getState().registerContextTab({
label: "Details",
onRequestClose: vi.fn(),
}),
);
const outlet = useSidePanelStore.getState().contextOutlet;
expect(outlet).not.toBeNull();
// When: an unrelated update re-renders the panel (a resize tick)
const seen: (HTMLElement | null)[] = [];
const unsubscribe = useSidePanelStore.subscribe((state) =>
seen.push(state.contextOutlet),
);
act(() => useSidePanelStore.getState().setWidth(500));
unsubscribe();
// Then: the outlet never detached (a null flicker remounts the portal)
expect(seen).not.toContain(null);
expect(useSidePanelStore.getState().contextOutlet).toBe(outlet);
});
it("re-clamps a persisted oversized width to the current viewport", () => {
// Given: a huge width rehydrated raw from a larger monitor
mediaMocks.isPushViewport = true;
useSidePanelStore.setState({ width: 5000 });
render(<GlobalSidePanel />);
// When
act(() => useSidePanelStore.getState().openPanel());
// Then: applied width is capped at 85% of this viewport
expect(screen.getByTestId("global-side-panel")).toHaveStyle({
width: `${Math.floor(window.innerWidth * 0.85)}px`,
});
});
it("resizes with arrow keys on the handle (left widens, right narrows)", async () => {
// Given
mediaMocks.isPushViewport = true;
const user = userEvent.setup();
render(<GlobalSidePanel />);
act(() => useSidePanelStore.getState().openPanel());
const handle = screen.getByRole("separator", { name: "Resize panel" });
expect(handle).toHaveAttribute(
"aria-valuenow",
String(SIDE_PANEL_DEFAULT_WIDTH),
);
// When / Then
act(() => handle.focus());
await user.keyboard("{ArrowLeft}");
expect(useSidePanelStore.getState().width).toBe(
SIDE_PANEL_DEFAULT_WIDTH + 24,
);
await user.keyboard("{ArrowRight}");
expect(useSidePanelStore.getState().width).toBe(SIDE_PANEL_DEFAULT_WIDTH);
});
it("exposes half the viewport as the resize maximum on ultra-wide screens", () => {
// Given
vi.stubGlobal("innerWidth", 2560);
mediaMocks.isPushViewport = true;
useSidePanelStore.setState({ width: 5000 });
// When
render(<GlobalSidePanel />);
// Then
expect(screen.getByTestId("global-side-panel")).toHaveStyle({
width: "1280px",
});
expect(
screen.getByRole("separator", { name: "Resize panel" }),
).toHaveAttribute("aria-valuemax", "1280");
});
it("does not exist on the full-page chat route (one place or the other)", () => {
// Given: the user is on the agentic chat page with the panel open
navigationMocks.pathname = "/lighthouse";
useSidePanelStore.setState({ isOpen: true, hasBeenOpened: true });
// When
const { container } = render(<GlobalSidePanel />);
// Then: no panel DOM at all — the chat lives in the page there
expect(container).toBeEmptyDOMElement();
});
it("remains available on Lighthouse settings", () => {
// Given: settings must coexist with the panel, unlike the chat page
navigationMocks.pathname = "/lighthouse/settings";
useSidePanelStore.setState({ isOpen: true, hasBeenOpened: true });
// When
render(<GlobalSidePanel />);
// Then
expect(screen.getByTestId("global-side-panel")).toHaveClass(
"translate-x-0",
);
});
});