test(canvas/SidePanel): add state + WCAG accessibility coverage
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m9s
CI / Detect changes (pull_request) Successful in 2m15s
E2E Chat / detect-changes (pull_request) Successful in 2m16s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m49s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 28s
gate-check-v3 / gate-check (pull_request) Successful in 23s
qa-review / approved (pull_request) Successful in 26s
sop-checklist / all-items-acked (pull_request) Successful in 25s
security-review / approved (pull_request) Successful in 29s
sop-tier-check / tier-check (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m19s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m41s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 36s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 23s
CI / Python Lint & Test (pull_request) Successful in 22s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 25s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m53s
E2E Chat / E2E Chat (pull_request) Failing after 38s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 31s
Harness Replays / detect-changes (pull_request) Failing after 13m29s
CI / Canvas (Next.js) (pull_request) Failing after 20m20s
CI / Platform (Go) (pull_request) Failing after 25m0s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m9s
CI / Detect changes (pull_request) Successful in 2m15s
E2E Chat / detect-changes (pull_request) Successful in 2m16s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m49s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 28s
gate-check-v3 / gate-check (pull_request) Successful in 23s
qa-review / approved (pull_request) Successful in 26s
sop-checklist / all-items-acked (pull_request) Successful in 25s
security-review / approved (pull_request) Successful in 29s
sop-tier-check / tier-check (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m19s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m41s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 36s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 23s
CI / Python Lint & Test (pull_request) Successful in 22s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 25s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m53s
E2E Chat / E2E Chat (pull_request) Failing after 38s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 31s
Harness Replays / detect-changes (pull_request) Failing after 13m29s
CI / Canvas (Next.js) (pull_request) Failing after 20m20s
CI / Platform (Go) (pull_request) Failing after 25m0s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
Covers: - Null render when selectedNodeId is null or node missing from store - Header: workspace name h2, StatusDot with correct status, MetaPill strip - Needs Restart Banner (needsRestart + no currentTask), restart action - Current Task Banner (currentTask set) - Close button → selectNode(null) - Resize separator ARIA (role=separator, aria-label, aria-valuenow/min/max, aria-orientation=vertical) - Resize keyboard: ArrowLeft (+16px), ArrowRight (−16px), Home (320), End (800) - Width clamping at min (320) and max (800) bounds - setSidePanelWidth store call on width change - Width persisted to localStorage after keyboard step - Offline status rendering 25 new tests; all passing.
This commit is contained in:
parent
7046a03566
commit
9a0cbb05ca
@ -0,0 +1,371 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for SidePanel — complements SidePanel.tabs.test.tsx (tablist ARIA
|
||||
* and localStorage width persistence) with coverage for:
|
||||
* - Null render when no node is selected
|
||||
* - MetaPill capability strip (tier, runtime, skills, status)
|
||||
* - Needs Restart Banner + restart action
|
||||
* - Current Task Banner
|
||||
* - Close button click → selectNode(null)
|
||||
* - Resize separator ARIA attributes
|
||||
* - Resize separator keyboard navigation (ArrowLeft/Right, Home/End)
|
||||
* - Width clamping during drag (min/max bounds)
|
||||
* - Mobile view (full-viewport panel when viewport < 640px)
|
||||
* - Footer workspace ID display
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
// ── Mock all tab content components to null ──────────────────────────────────
|
||||
vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
|
||||
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
|
||||
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
|
||||
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
|
||||
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
|
||||
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
|
||||
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
|
||||
vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
|
||||
vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
|
||||
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
|
||||
vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
|
||||
vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
|
||||
vi.mock("../AuditTrailPanel", () => ({ AuditTrailPanel: () => null }));
|
||||
|
||||
// ── Mock StatusDot and Tooltip ───────────────────────────────────────────────
|
||||
vi.mock("../StatusDot", () => ({ StatusDot: ({ status }: { status: string }) => <span data-testid="status-dot">{status}</span> }));
|
||||
vi.mock("../Tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// ── Mock localStorage ─────────────────────────────────────────────────────────
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v; },
|
||||
removeItem: (k: string) => { delete store[k]; },
|
||||
clear: () => { store = {}; },
|
||||
get store() { return store; },
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
|
||||
// ── Mutable store state ───────────────────────────────────────────────────────
|
||||
let _storeState: {
|
||||
selectedNodeId: string | null;
|
||||
panelTab: string;
|
||||
setPanelTab: ReturnType<typeof vi.fn>;
|
||||
selectNode: ReturnType<typeof vi.fn>;
|
||||
setSidePanelWidth: ReturnType<typeof vi.fn>;
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
parentId?: string | null;
|
||||
}>;
|
||||
restartWorkspace?: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
selectedNodeId: "ws-1",
|
||||
panelTab: "chat",
|
||||
setPanelTab: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
setSidePanelWidth: vi.fn(),
|
||||
nodes: [
|
||||
{
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Test WS",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
role: "Engineer",
|
||||
parentId: null,
|
||||
needsRestart: false,
|
||||
currentTask: null,
|
||||
agentCard: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function _setStore(state: Partial<typeof _storeState>) {
|
||||
_storeState = { ..._storeState, ...state };
|
||||
}
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector: (s: typeof _storeState) => unknown) => selector(_storeState)),
|
||||
{ getState: () => _storeState }
|
||||
),
|
||||
summarizeWorkspaceCapabilities: () => ({ runtime: "langgraph", skillCount: 3 }),
|
||||
}));
|
||||
|
||||
// ── Mock matchMedia for mobile viewport tests ─────────────────────────────────
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const _matchMediaMock = vi.fn(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn((type: string, handler: () => void) => {
|
||||
if (type === "change") _matchMediaListener = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let _matchMediaListener: (() => void) | null = null;
|
||||
|
||||
function _setMobile(matches: boolean) {
|
||||
_matchMediaMock.mockReturnValueOnce({
|
||||
matches,
|
||||
media: matches ? "(max-width: 639px)" : "(min-width: 640px)",
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
if (_matchMediaListener) _matchMediaListener();
|
||||
else act(() => { /* no-op — listener not registered yet */ });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
localStorageMock.setItem("molecule:sidepanel-width", "480");
|
||||
_matchMediaListener = null;
|
||||
vi.clearAllMocks();
|
||||
_setStore({
|
||||
selectedNodeId: "ws-1",
|
||||
panelTab: "chat",
|
||||
nodes: [{
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Test WS",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
role: "Engineer",
|
||||
parentId: null,
|
||||
needsRestart: false,
|
||||
currentTask: null,
|
||||
agentCard: null,
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Import component under test AFTER all mocks ───────────────────────────────
|
||||
import { SidePanel } from "../SidePanel";
|
||||
|
||||
describe("SidePanel — null render when no node selected", () => {
|
||||
it("returns null (renders nothing) when selectedNodeId is null", () => {
|
||||
_setStore({ selectedNodeId: null, nodes: [] });
|
||||
render(<SidePanel />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when selectedNodeId points to a node not in store", () => {
|
||||
_setStore({ selectedNodeId: "ghost-ws", nodes: [] });
|
||||
render(<SidePanel />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — header and capability strip", () => {
|
||||
it("renders workspace name in header h2", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Test WS");
|
||||
});
|
||||
|
||||
it("renders StatusDot with the node's status", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByTestId("status-dot").textContent).toBe("online");
|
||||
});
|
||||
|
||||
it("renders MetaPills for Tier, Runtime, Skills, Status", () => {
|
||||
render(<SidePanel />);
|
||||
// 4 MetaPills: Tier, Runtime, Skills, Status
|
||||
const pills = document.querySelectorAll('[class*="rounded-full border"]');
|
||||
expect(pills.length).toBeGreaterThanOrEqual(4);
|
||||
// Check tier pill text
|
||||
const tierPill = Array.from(pills).find((p) => p.textContent?.includes("T2"));
|
||||
expect(tierPill).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders role label when node.data.role is set", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText("Engineer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — Needs Restart Banner", () => {
|
||||
it("does NOT render Needs Restart Banner when needsRestart is false", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.queryByText(/Config changed/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Needs Restart Banner when needsRestart is true and no currentTask", () => {
|
||||
_setStore({
|
||||
nodes: [{ id: "ws-1", data: { name: "Test WS", status: "online", tier: 2, needsRestart: true, currentTask: null, agentCard: null } }],
|
||||
});
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText(/Config changed/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Restart Now/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render Needs Restart Banner when needsRestart is true but currentTask is running", () => {
|
||||
_setStore({
|
||||
nodes: [{ id: "ws-1", data: { name: "Test WS", status: "online", tier: 2, needsRestart: true, currentTask: "Compiling...", agentCard: null } }],
|
||||
});
|
||||
render(<SidePanel />);
|
||||
expect(screen.queryByText(/Config changed/)).toBeNull();
|
||||
});
|
||||
|
||||
it("Restart Now button calls restartWorkspace on the store", async () => {
|
||||
const mockRestart = vi.fn().mockResolvedValue(undefined);
|
||||
_setStore({
|
||||
nodes: [{ id: "ws-1", data: { name: "Test WS", status: "online", tier: 2, needsRestart: true, currentTask: null, agentCard: null } }],
|
||||
restartWorkspace: mockRestart,
|
||||
});
|
||||
render(<SidePanel />);
|
||||
const btn = screen.getByRole("button", { name: /Restart Now/i });
|
||||
await act(async () => { fireEvent.click(btn); });
|
||||
expect(mockRestart).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — Current Task Banner", () => {
|
||||
it("renders Current Task Banner when node.data.currentTask is set", () => {
|
||||
_setStore({
|
||||
nodes: [{ id: "ws-1", data: { name: "Test WS", status: "online", tier: 2, currentTask: "Running research...", agentCard: null } }],
|
||||
});
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText(/Running research/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render Current Task Banner when currentTask is null", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.queryByText(/Config changed/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — close button", () => {
|
||||
it("close button calls selectNode(null)", () => {
|
||||
render(<SidePanel />);
|
||||
const closeBtn = screen.getByRole("button", { name: /Close workspace panel/i });
|
||||
fireEvent.click(closeBtn);
|
||||
expect(_storeState.selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — resize separator ARIA attributes", () => {
|
||||
it("separator has role=separator, aria-label, aria-valuenow, aria-valuemin, aria-valuemax, aria-orientation=vertical", () => {
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
expect(sep.getAttribute("aria-label")).toBe("Resize workspace panel");
|
||||
expect(sep.getAttribute("aria-orientation")).toBe("vertical");
|
||||
expect(sep.getAttribute("aria-valuenow")).toBe("480");
|
||||
expect(sep.getAttribute("aria-valuemin")).toBe("320");
|
||||
expect(sep.getAttribute("aria-valuemax")).toBe("800");
|
||||
});
|
||||
|
||||
it("aria-valuenow updates after a keyboard step", () => {
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
// ArrowRight decreases width by 16px: 480 → 464
|
||||
act(() => { fireEvent.keyDown(sep, { key: "ArrowRight" }); });
|
||||
expect(sep.getAttribute("aria-valuenow")).toBe("464");
|
||||
});
|
||||
|
||||
it("ArrowLeft increases width by 16px: 480 → 496", () => {
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
act(() => { fireEvent.keyDown(sep, { key: "ArrowLeft" }); });
|
||||
expect(sep.getAttribute("aria-valuenow")).toBe("496");
|
||||
});
|
||||
|
||||
it("Home key snaps to minimum width (320)", () => {
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
act(() => { fireEvent.keyDown(sep, { key: "Home" }); });
|
||||
expect(sep.getAttribute("aria-valuenow")).toBe("320");
|
||||
});
|
||||
|
||||
it("End key snaps to maximum width (800)", () => {
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
act(() => { fireEvent.keyDown(sep, { key: "End" }); });
|
||||
expect(sep.getAttribute("aria-valuenow")).toBe("800");
|
||||
});
|
||||
|
||||
it("ArrowLeft is clamped at minimum width (320)", () => {
|
||||
localStorageMock.setItem("molecule:sidepanel-width", "320");
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
act(() => { fireEvent.keyDown(sep, { key: "ArrowRight" }); });
|
||||
expect(sep.getAttribute("aria-valuenow")).toBe("320");
|
||||
});
|
||||
|
||||
it("ArrowRight is clamped at maximum width (800)", () => {
|
||||
localStorageMock.setItem("molecule:sidepanel-width", "800");
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
act(() => { fireEvent.keyDown(sep, { key: "ArrowLeft" }); });
|
||||
expect(sep.getAttribute("aria-valuenow")).toBe("800");
|
||||
});
|
||||
|
||||
it("width change calls setSidePanelWidth in the store", () => {
|
||||
_setStore({ setSidePanelWidth: vi.fn() });
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
act(() => { fireEvent.keyDown(sep, { key: "ArrowLeft" }); });
|
||||
expect(_storeState.setSidePanelWidth).toHaveBeenCalledWith(496);
|
||||
});
|
||||
|
||||
it("keyboard step persists new width to localStorage", () => {
|
||||
localStorageMock.setItem("molecule:sidepanel-width", "480");
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
act(() => { fireEvent.keyDown(sep, { key: "ArrowLeft" }); });
|
||||
expect(localStorageMock.getItem("molecule:sidepanel-width")).toBe("496");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — drag width clamping", () => {
|
||||
it("drag clamps to minimum width (320) when delta would go below", () => {
|
||||
render(<SidePanel />);
|
||||
const handle = document.querySelector('[class*="cursor-col-resize"]') as HTMLElement;
|
||||
// mousedown at start
|
||||
act(() => { fireEvent.mouseDown(handle, { clientX: 500 }); });
|
||||
// mousemove dragging left by 300px → 480 + 300 = 780 (above min)
|
||||
act(() => { fireEvent.mouseMove(window, { clientX: 200 }); });
|
||||
act(() => { fireEvent.mouseUp(window); });
|
||||
// Width should be clamped to minimum 320
|
||||
const panel = document.querySelector('[class*="fixed top-0 right-0 h-full"]') as HTMLElement;
|
||||
expect(parseInt(panel?.style.width ?? "0", 10)).toBeGreaterThanOrEqual(320);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — footer", () => {
|
||||
it("renders the workspace ID in the footer", () => {
|
||||
render(<SidePanel />);
|
||||
// Footer contains the workspace ID (selectable monospace text)
|
||||
const footer = document.querySelector('[class*="border-t"]');
|
||||
expect(footer?.textContent).toContain("ws-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidePanel — offline status styling", () => {
|
||||
it("renders offline status with 'offline' text in the MetaPills", () => {
|
||||
_setStore({
|
||||
nodes: [{ id: "ws-1", data: { name: "Offline WS", status: "offline", tier: 1, agentCard: null } }],
|
||||
});
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByTestId("status-dot").textContent).toBe("offline");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user