Merge branch 'main' into docs/gitea-operational-quirks-runbook
All checks were successful
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
sop-tier-check / tier-check (pull_request) Successful in 17s
CI / Detect changes (pull_request) Successful in 38s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 39s
E2E API Smoke Test / detect-changes (pull_request) Successful in 40s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 37s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 37s
CI / Platform (Go) (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 19s
All checks were successful
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
sop-tier-check / tier-check (pull_request) Successful in 17s
CI / Detect changes (pull_request) Successful in 38s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 39s
E2E API Smoke Test / detect-changes (pull_request) Successful in 40s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 37s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 37s
CI / Platform (Go) (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 19s
This commit is contained in:
commit
db647de1cd
291
canvas/src/components/__tests__/Toolbar.test.tsx
Normal file
291
canvas/src/components/__tests__/Toolbar.test.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Toolbar tests.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders with 0 workspaces
|
||||
* - Shows online/offline/failed/provisioning status pills when nodes exist
|
||||
* - WebSocket status pill: connected → "Live"
|
||||
* - WebSocket status pill: connecting → "Reconnecting"
|
||||
* - WebSocket status pill: disconnected → "Offline"
|
||||
* - Stop All button visible when activeTasks > 0
|
||||
* - Restart Pending button visible when needsRestart nodes exist
|
||||
* - Help button opens the help popover
|
||||
* - Help popover closes on Escape or pointer-outside
|
||||
* - KeyboardShortcutsDialog opens via ? shortcut (when not in input)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Reset store state between tests so mutations don't leak.
|
||||
beforeEach(() => {
|
||||
defaultStore.nodes = [];
|
||||
defaultStore.wsStatus = "connected";
|
||||
defaultStore.showA2AEdges = false;
|
||||
defaultStore.selectedNodeId = null;
|
||||
mockSetShowA2AEdges.mockClear();
|
||||
mockSetPanelTab.mockClear();
|
||||
mockSetSearchOpen.mockClear();
|
||||
mockUpdateNodeData.mockClear();
|
||||
});
|
||||
|
||||
// ── Mock targets ───────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/SettingsButton", () => ({
|
||||
SettingsButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/SettingsPanel", () => ({
|
||||
settingsGearRef: { current: null },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ThemeToggle", () => ({
|
||||
ThemeToggle: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/KeyboardShortcutsDialog", () => ({
|
||||
KeyboardShortcutsDialog: ({ open }: { open: boolean; onClose: () => void }) =>
|
||||
open ? <div role="dialog" data-testid="shortcuts-dialog">Shortcuts</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/design-tokens", () => ({
|
||||
statusDotClass: (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
online: "bg-emerald-400",
|
||||
offline: "bg-zinc-500",
|
||||
paused: "bg-indigo-400",
|
||||
degraded: "bg-amber-400",
|
||||
failed: "bg-red-400",
|
||||
provisioning: "bg-sky-400",
|
||||
};
|
||||
return map[status] ?? "bg-zinc-500";
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockSetShowA2AEdges = vi.fn();
|
||||
const mockSetPanelTab = vi.fn();
|
||||
const mockSetSearchOpen = vi.fn();
|
||||
const mockUpdateNodeData = vi.fn();
|
||||
|
||||
const makeNodes = (
|
||||
statuses: Array<"online" | "offline" | "failed" | "provisioning">,
|
||||
activeTasks: number[] = [],
|
||||
needsRestart: boolean[] = [],
|
||||
parentIds: (string | null)[] = []
|
||||
) => {
|
||||
return statuses.map((status, i) => ({
|
||||
id: `ws-${i}`,
|
||||
data: {
|
||||
name: `Workspace ${i}`,
|
||||
role: "agent",
|
||||
tier: 1,
|
||||
status,
|
||||
parentId: parentIds[i] ?? null,
|
||||
activeTasks: activeTasks[i] ?? 0,
|
||||
needsRestart: needsRestart[i] ?? false,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Nodes must be React Flow nodes (id + data), but Toolbar only reads data fields.
|
||||
// makeNodes returns { id, data: { activeTasks, needsRestart, ... } }.
|
||||
const toStoreNodes = (nodes: ReturnType<typeof makeNodes>) =>
|
||||
nodes.map((n) => ({ id: n.id, data: n.data }));
|
||||
|
||||
const defaultStore = {
|
||||
nodes: [] as ReturnType<typeof makeNodes>,
|
||||
wsStatus: "connected" as "connected" | "connecting" | "disconnected",
|
||||
showA2AEdges: false,
|
||||
selectedNodeId: null as string | null,
|
||||
sidePanelWidth: 480,
|
||||
setShowA2AEdges: mockSetShowA2AEdges,
|
||||
setPanelTab: mockSetPanelTab,
|
||||
setSearchOpen: mockSetSearchOpen,
|
||||
updateNodeData: mockUpdateNodeData,
|
||||
selectedNodeIds: new Set<string>(),
|
||||
clearSelection: vi.fn(),
|
||||
batchRestart: vi.fn(() => Promise.resolve()),
|
||||
batchPause: vi.fn(() => Promise.resolve()),
|
||||
batchDelete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: typeof defaultStore) => unknown) =>
|
||||
selector(defaultStore)
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Component under test ───────────────────────────────────────────────────────
|
||||
import { Toolbar } from "../Toolbar";
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Toolbar — workspace count display", () => {
|
||||
it("shows '0 workspaces' when the canvas is empty", () => {
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByText(/0 workspaces?/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'N workspaces' when nodes exist", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"]));
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByText(/2 workspaces?/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — status pills", () => {
|
||||
it("shows the online pill when nodes are online", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["online"]));
|
||||
render(<Toolbar />);
|
||||
// StatusPill uses aria-label
|
||||
expect(screen.getByLabelText(/1 online/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the offline pill only when offline nodes exist", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["offline"]));
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByLabelText(/1 offline/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the failed pill when failed nodes exist", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["failed"]));
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByLabelText(/1 failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the provisioning pill when provisioning nodes exist", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["provisioning"]));
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByLabelText(/1 starting/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses offline pill when no offline nodes", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"]));
|
||||
render(<Toolbar />);
|
||||
expect(screen.queryByLabelText(/offline/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — WebSocket status pill", () => {
|
||||
it('shows "Live" when connected', () => {
|
||||
defaultStore.wsStatus = "connected";
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByText("Live")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "Reconnecting" when connecting', () => {
|
||||
defaultStore.wsStatus = "connecting";
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByText("Reconnecting")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "Offline" when disconnected', () => {
|
||||
defaultStore.wsStatus = "disconnected";
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByText("Offline")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — Stop All", () => {
|
||||
it("is hidden when no active tasks", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["online"], [0]));
|
||||
render(<Toolbar />);
|
||||
expect(screen.queryByRole("button", { name: /Stop All/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("is visible when active tasks > 0", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"], [2, 2]));
|
||||
render(<Toolbar />);
|
||||
// aria-label: "Stop all running tasks (2)"
|
||||
expect(screen.getByRole("button", { name: /stop all running tasks/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — Restart Pending", () => {
|
||||
it("is hidden when no nodes need restart", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["online"], [], [false]));
|
||||
render(<Toolbar />);
|
||||
expect(screen.queryByRole("button", { name: /Restart Pending/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("is visible when nodes need restart", () => {
|
||||
defaultStore.nodes = toStoreNodes(makeNodes(["online"], [], [true]));
|
||||
render(<Toolbar />);
|
||||
// aria-label: "Restart 1 workspaces pending config or secret changes"
|
||||
expect(screen.getByRole("button", { name: /restart 1 workspace/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — Help popover", () => {
|
||||
it("opens when help button is clicked", () => {
|
||||
render(<Toolbar />);
|
||||
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes when close button is clicked", () => {
|
||||
render(<Toolbar />);
|
||||
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
const closeBtn = screen.getByRole("button", { name: /close help dialog/i });
|
||||
fireEvent.click(closeBtn);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — A2A edges toggle", () => {
|
||||
it("calls setShowA2AEdges on click", () => {
|
||||
defaultStore.showA2AEdges = false;
|
||||
render(<Toolbar />);
|
||||
const toggle = screen.getByRole("button", { name: /show a2a edges/i });
|
||||
fireEvent.click(toggle);
|
||||
expect(mockSetShowA2AEdges).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — ? shortcut opens shortcuts dialog", () => {
|
||||
it("opens KeyboardShortcutsDialog when ? is pressed outside an input", () => {
|
||||
render(<Toolbar />);
|
||||
expect(screen.queryByTestId("shortcuts-dialog")).toBeNull();
|
||||
fireEvent.keyDown(window, { key: "?" });
|
||||
expect(screen.getByTestId("shortcuts-dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not fire ? shortcut when focus is in an input", () => {
|
||||
render(
|
||||
<div>
|
||||
<input data-testid="test-input" type="text" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
);
|
||||
const input = screen.getByTestId("test-input");
|
||||
fireEvent.focus(input);
|
||||
// Fire on the input element so e.target.tagName === "INPUT" is true
|
||||
fireEvent.keyDown(input, { key: "?" });
|
||||
expect(screen.queryByTestId("shortcuts-dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user