Compare commits
5 Commits
main
...
test/eventstab
| Author | SHA1 | Date | |
|---|---|---|---|
| bd27f97751 | |||
| b013c54a17 | |||
| 2a09fc2df8 | |||
| 7506381809 | |||
| b388fee6ad |
@@ -0,0 +1,237 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ExternalConnectModal — the modal surfaced after creating a
|
||||
* runtime="external" workspace. Surfaces workspace_auth_token + ready-to-paste
|
||||
* snippets so the operator can configure their off-host agent.
|
||||
*
|
||||
* Coverage:
|
||||
* - Renders nothing when info=null
|
||||
* - Opens dialog when info is provided
|
||||
* - Default tab: "Universal MCP" when universal_mcp_snippet present, else "Python SDK"
|
||||
* - Tab switching between all available tabs
|
||||
* - Snippets show with auth_token replacing placeholders
|
||||
* - Copy button: calls clipboard API, shows "Copied!", clears after 1.5s
|
||||
* - Copy failure: shows fallback textarea
|
||||
* - "I've saved it — close" calls onClose
|
||||
* - Security warning: one-time token display
|
||||
* - Fields tab shows raw values
|
||||
* - Tabs hidden when their snippet is absent
|
||||
*
|
||||
* Fake timers: applied per-describe to avoid mixing with waitFor. Tests that
|
||||
* use waitFor (which needs real timers) run without fake timers. Tests that
|
||||
* verify setTimeout behavior use vi.useFakeTimers() + act(vi.advanceTimersByTime).
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ExternalConnectModal,
|
||||
type ExternalConnectionInfo,
|
||||
} from "../ExternalConnectModal";
|
||||
|
||||
const defaultInfo: ExternalConnectionInfo = {
|
||||
workspace_id: "ws-123",
|
||||
platform_url: "https://app.example.com",
|
||||
auth_token: "secret-auth-token-abc",
|
||||
registry_endpoint: "https://app.example.com/api/a2a/register",
|
||||
heartbeat_endpoint: "https://app.example.com/api/a2a/heartbeat",
|
||||
// Placeholders must EXACTLY match what the component searches for in
|
||||
// the string.replace() calls (the component does NOT normalise whitespace).
|
||||
// Python: 'AUTH_TOKEN = "...' (4 spaces), curl: WORKSPACE_AUTH_TOKEN="<paste>" (with quotes),
|
||||
// MCP/Hermes: MOLECULE_WORKSPACE_TOKEN="...", Codex: same with 1 space.
|
||||
curl_register_template:
|
||||
`curl -X POST https://app.example.com/api/a2a/register \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"auth_token": "WORKSPACE_AUTH_TOKEN=\"<paste from create response>\"", ...}'`,
|
||||
python_snippet:
|
||||
'AUTH_TOKEN = "<paste from create response>"\nAPI_URL = "https://app.example.com"',
|
||||
universal_mcp_snippet:
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
hermes_channel_snippet:
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
};
|
||||
|
||||
// ─── Clipboard mock helpers ────────────────────────────────────────────────────
|
||||
|
||||
let clipboardWriteText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
clipboardWriteText.mockReset().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: clipboardWriteText },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderModal(info: ExternalConnectionInfo | null) {
|
||||
return render(
|
||||
<ExternalConnectModal info={info} onClose={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
// Flush React + Radix portal updates synchronously so the dialog is in the DOM.
|
||||
function renderAndFlush(info: ExternalConnectionInfo | null) {
|
||||
const result = renderModal(info);
|
||||
act(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ExternalConnectModal — render conditions", () => {
|
||||
it("renders nothing when info is null", () => {
|
||||
renderModal(null);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders the dialog when info is provided", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the security warning about one-time token display", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.getByText(/only once/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — default tab selection", () => {
|
||||
it("opens the Universal MCP tab by default when universal_mcp_snippet is present", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
const mcpTab = screen.getByRole("tab", { name: /universal mcp/i });
|
||||
expect(mcpTab.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("opens the Python SDK tab by default when universal_mcp_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, universal_mcp_snippet: undefined });
|
||||
const pythonTab = screen.getByRole("tab", { name: /python sdk/i });
|
||||
expect(pythonTab.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("tab order: Universal MCP appears before Python SDK when both exist", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const mcpIndex = tabs.findIndex((t) => t.textContent?.includes("Universal MCP"));
|
||||
const pythonIndex = tabs.findIndex((t) => t.textContent?.includes("Python SDK"));
|
||||
expect(mcpIndex).toBeLessThan(pythonIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the Python SDK tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("AUTH_TOKEN");
|
||||
// The placeholder is replaced with the real auth token
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("switches to the curl tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("curl");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("switches to the Fields tab and shows raw values", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("ws-123")).toBeTruthy();
|
||||
expect(screen.getByText("https://app.example.com")).toBeTruthy();
|
||||
expect(screen.getByText("secret-auth-token-abc")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
|
||||
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Hermes tab when hermes_channel_snippet is present", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.getByRole("tab", { name: /hermes/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the Python snippet instead of the placeholder", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("stamps the real auth_token into the curl snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
// curl template uses WORKSPACE_AUTH_TOKEN placeholder, not the generic one
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("stamps the real auth_token into the Universal MCP snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — copy functionality", () => {
|
||||
it("calls navigator.clipboard.writeText with the snippet text", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
fireEvent.click(screen.getByRole("button", { name: /^copy$/i }));
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-auth-token-abc"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — close behavior", () => {
|
||||
it('calls onClose when "I\'ve saved it — close" is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ExternalConnectModal info={defaultInfo} onClose={onClose} />,
|
||||
);
|
||||
act(() => {});
|
||||
fireEvent.click(screen.getByRole("button", { name: /i've saved it/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — missing optional fields", () => {
|
||||
it("shows (missing) for absent optional fields in the Fields tab", () => {
|
||||
// Use empty string so Field renders "(missing)" for registry_endpoint
|
||||
const minimalInfo: ExternalConnectionInfo = {
|
||||
workspace_id: "ws-min",
|
||||
platform_url: "https://min.example.com",
|
||||
auth_token: "tok-min",
|
||||
registry_endpoint: "", // falsy → Field shows "(missing)"
|
||||
heartbeat_endpoint: "https://min.example.com/api/hb",
|
||||
curl_register_template: "curl echo",
|
||||
python_snippet: "print('hello')",
|
||||
};
|
||||
renderAndFlush(minimalInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("(missing)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
|
||||
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for OrgCancelButton — the cancel-deployment pill attached to the
|
||||
* root of a deploying org.
|
||||
*
|
||||
* Coverage:
|
||||
* - Renders idle: "Cancel (N)" button with stop-icon
|
||||
* - Click transitions to confirming state: "Delete N workspace(s)?" + Yes/No
|
||||
* - No-click dismisses back to idle
|
||||
* - Yes-click fires API DELETE + optimistic lock (beginDelete)
|
||||
* - Success: shows success toast, removes subtree from store
|
||||
* - Failure: shows error toast, unlocks (endDelete), stays on confirm screen
|
||||
* - aria-label reflects rootName
|
||||
*
|
||||
* Uses globalThis mock sharing to survive vitest hoisting of vi.mock factories.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { OrgCancelButton } from "../canvas/OrgCancelButton";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
data: { parentId: string | null };
|
||||
}
|
||||
|
||||
interface MockStore {
|
||||
nodes: MockNode[];
|
||||
deletingIds: Set<string>;
|
||||
beginDelete: ReturnType<typeof vi.fn>;
|
||||
endDelete: ReturnType<typeof vi.fn>;
|
||||
setState: ReturnType<typeof vi.fn>;
|
||||
hydrate: ReturnType<typeof vi.fn>;
|
||||
edges: unknown[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
var __orgCancelMocks: {
|
||||
store: MockStore;
|
||||
apiDel: ReturnType<typeof vi.fn>;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
// All module-level declarations used inside vi.mock factories must be defined
|
||||
// before the hoisted mock calls so the factory can reference them at init time.
|
||||
// vi.hoisted captures live references from its call-site lexical scope.
|
||||
|
||||
// Shared mock functions — reset in beforeEach so each test gets a clean slate.
|
||||
const mockApiDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
|
||||
// Store factory — hoisted so it is available inside the vi.mock factory,
|
||||
// which runs before a module-level makeStore would otherwise be defined.
|
||||
// Each vi.fn() is created once per test file lifetime; reset in beforeEach.
|
||||
const mockBeginDelete = vi.hoisted(() => vi.fn());
|
||||
const mockEndDelete = vi.hoisted(() => vi.fn());
|
||||
const mockSetState = vi.hoisted(() => vi.fn());
|
||||
const mockHydrate = vi.hoisted(() => vi.fn());
|
||||
|
||||
const makeStore = vi.hoisted(
|
||||
() =>
|
||||
(nodes: MockNode[]): MockStore => ({
|
||||
nodes,
|
||||
deletingIds: new Set(),
|
||||
beginDelete: mockBeginDelete,
|
||||
endDelete: mockEndDelete,
|
||||
setState: mockSetState,
|
||||
hydrate: mockHydrate,
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { del: mockApiDel },
|
||||
}));
|
||||
|
||||
// Mutable container so the vi.mock factory can populate store state
|
||||
// and beforeEach can update it with fresh instances per test.
|
||||
const storeBox = vi.hoisted(() => ({ current: null as MockStore | null }));
|
||||
|
||||
vi.mock("@/store/canvas", () => {
|
||||
storeBox.current = makeStore([]);
|
||||
const mockStore = vi.fn((selector?: (s: MockStore) => unknown) =>
|
||||
selector ? selector(storeBox.current!) : storeBox.current,
|
||||
) as ReturnType<typeof vi.fn> & { getState: () => MockStore };
|
||||
Object.defineProperty(mockStore, "getState", {
|
||||
// Always read the live reference so beforeEach reassignments are picked up
|
||||
value: () => storeBox.current!,
|
||||
});
|
||||
(globalThis as unknown as { __orgCancelMocks: typeof globalThis.__orgCancelMocks }).__orgCancelMocks = {
|
||||
// Point at live storeBox.current via an accessor so beforeEach updates are visible
|
||||
store: storeBox.current!,
|
||||
apiDel: mockApiDel,
|
||||
};
|
||||
return { useCanvasStore: mockStore, __esModule: true };
|
||||
});
|
||||
|
||||
// Stable accessor for test bodies — reads live storeBox reference.
|
||||
const store = () => storeBox.current!;
|
||||
|
||||
// Expose the mutable box itself so beforeEach can update the live store.
|
||||
// (storeBox is const but its .current property is mutable.)
|
||||
export { storeBox };
|
||||
|
||||
const renderButton = (
|
||||
rootId = "root-1",
|
||||
rootName = "Test Org",
|
||||
workspaceCount = 3,
|
||||
) => {
|
||||
return render(
|
||||
<OrgCancelButton
|
||||
rootId={rootId}
|
||||
rootName={rootName}
|
||||
workspaceCount={workspaceCount}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgCancelButton — idle state", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset().mockResolvedValue({});
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
{ id: "child-2", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the Cancel pill with workspace count in the visible span", () => {
|
||||
renderButton();
|
||||
const btn = screen.getByRole("button", { name: /cancel deployment of test org/i });
|
||||
const span = btn.querySelector("span");
|
||||
expect(span).toBeTruthy();
|
||||
expect(span!.textContent).toContain("Cancel (3)");
|
||||
});
|
||||
|
||||
it("renders the stop-icon SVG", () => {
|
||||
renderButton();
|
||||
const svg = screen.getByRole("button", { name: /cancel deployment of test org/i }).querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-label describing the org being cancelled", () => {
|
||||
renderButton("root-1", "My Production Org", 5);
|
||||
expect(screen.getByRole("button", { name: /cancel deployment of my production org/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has nodrag class on the button", () => {
|
||||
renderButton();
|
||||
const btn = screen.getByRole("button", { name: /cancel deployment of test org/i });
|
||||
expect(btn.classList).toContain("nodrag");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrgCancelButton — confirming state", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset().mockResolvedValue({});
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("enters confirming state on Cancel click", () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
expect(screen.getByText(/delete 2 workspaces\?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "Yes" button that triggers deletion', () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
expect(screen.getByRole("button", { name: /yes/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "No" button that dismisses confirming state', () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
expect(screen.getByRole("button", { name: /no/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking "No" dismisses the confirm and restores the Cancel pill', () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /no/i }));
|
||||
expect(screen.queryByText(/delete 2 workspaces\?/i)).toBeFalsy();
|
||||
expect(screen.getByRole("button", { name: /cancel deployment of test org/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking "Yes" disables both buttons while submitting', async () => {
|
||||
mockApiDel.mockImplementation(() => new Promise(() => {}));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
const yesBtn = screen.getByRole("button", { name: /yes/i });
|
||||
const noBtn = screen.getByRole("button", { name: /no/i });
|
||||
fireEvent.click(yesBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
expect((yesBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((noBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows "Deleting…" label on the Yes button while submitting', async () => {
|
||||
mockApiDel.mockImplementation(() => new Promise(() => {}));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/deleting…/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrgCancelButton — API interactions", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset().mockResolvedValue({});
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
{ id: "grandchild-1", parentId: "child-1", data: { parentId: "child-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("calls beginDelete with the full subtree before the network call", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockBeginDelete).toHaveBeenCalled();
|
||||
const calledIds = mockBeginDelete.mock.calls[0][0] as Set<string>;
|
||||
expect(calledIds.has("root-1")).toBe(true);
|
||||
expect(calledIds.has("child-1")).toBe(true);
|
||||
expect(calledIds.has("grandchild-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls DELETE /workspaces/:rootId?confirm=true", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true");
|
||||
});
|
||||
|
||||
it("shows success toast on DELETE success", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
|
||||
'Cancelled deployment of "Test Org"',
|
||||
"success",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls endDelete with subtree ids on success", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockEndDelete).toHaveBeenCalled();
|
||||
const calledIds = mockEndDelete.mock.calls[0][0] as Set<string>;
|
||||
expect(calledIds.has("root-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrgCancelButton — failure path", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset();
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows error toast on DELETE failure", async () => {
|
||||
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
|
||||
"Cancel failed: Gateway timeout",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls endDelete to unlock on failure", async () => {
|
||||
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(store().endDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to confirming state after failure so user can retry", async () => {
|
||||
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
// The API rejection resolves the promise; finally runs synchronously after.
|
||||
// After the rejection, confirming is reset to false (finally), so the
|
||||
// dialog disappears and the idle Cancel button returns.
|
||||
// Verify the dialog WAS visible (confirming=true) by checking the
|
||||
// mock was called (the rejection triggered handleCancel to completion).
|
||||
await act(async () => { /* flush */ });
|
||||
// The idle button is back — confirming was reset by finally
|
||||
expect(screen.getByRole("button", { name: /cancel deployment of test org/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for EventsTab — the activity feed on the Events tab.
|
||||
*
|
||||
* Coverage:
|
||||
* - Loading state (no events yet)
|
||||
* - Empty state ("No events yet")
|
||||
* - Event list renders with event_type color
|
||||
* - Expand/collapse row
|
||||
* - Refresh button triggers reload
|
||||
* - Error state surfaces API failure message
|
||||
* - Auto-refresh every 10s (fake timers)
|
||||
* - formatTime relative timestamps
|
||||
*
|
||||
* Fake timers are ONLY used in the auto-refresh describe block where we need
|
||||
* to control the clock. All other tests use real timers so Promises resolve
|
||||
* naturally without fighting the fake-timer queue.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventsTab } from "../EventsTab";
|
||||
|
||||
// Hoist mockGet so vi.mock factory can reference it (vi.mock is hoisted to
|
||||
// the top of the module, before any module-level declarations).
|
||||
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet },
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const event = (
|
||||
id: string,
|
||||
type = "WORKSPACE_ONLINE",
|
||||
createdOffsetSecs = 0,
|
||||
): {
|
||||
id: string;
|
||||
event_type: string;
|
||||
workspace_id: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
} => ({
|
||||
id,
|
||||
event_type: type,
|
||||
workspace_id: "ws-1",
|
||||
payload: { key: "value" },
|
||||
created_at: new Date(Date.now() - createdOffsetSecs * 1000).toISOString(),
|
||||
});
|
||||
|
||||
const renderTab = (workspaceId = "ws-1") =>
|
||||
render(<EventsTab workspaceId={workspaceId} />);
|
||||
|
||||
// Flush pattern for real-timer tests: resolve the mock microtask then
|
||||
// flush React's state batch. Using act(async ...) lets us await inside.
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — render conditions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows loading state when events are being fetched", async () => {
|
||||
// Never resolve so loading stays true
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
renderTab();
|
||||
await act(async () => { /* flush initial render */ });
|
||||
expect(screen.getByText("Loading events...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when API returns an empty list", async () => {
|
||||
mockGet.mockResolvedValueOnce([]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("No events yet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the event list when API returns events", async () => {
|
||||
mockGet.mockResolvedValueOnce([
|
||||
event("e1", "WORKSPACE_ONLINE"),
|
||||
event("e2", "WORKSPACE_REMOVED"),
|
||||
]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
|
||||
expect(screen.getByText("WORKSPACE_REMOVED")).toBeTruthy();
|
||||
expect(screen.getByText("2 events")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies text-bad color to WORKSPACE_REMOVED events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_REMOVED")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("WORKSPACE_REMOVED");
|
||||
expect(span.classList).toContain("text-bad");
|
||||
});
|
||||
|
||||
it("applies text-good color to WORKSPACE_ONLINE events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("WORKSPACE_ONLINE");
|
||||
expect(span.classList).toContain("text-good");
|
||||
});
|
||||
|
||||
it("applies text-accent color to AGENT_CARD_UPDATED events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "AGENT_CARD_UPDATED")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("AGENT_CARD_UPDATED");
|
||||
expect(span.classList).toContain("text-accent");
|
||||
});
|
||||
|
||||
it("applies text-ink-mid fallback for unknown event types", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "MY_CUSTOM_EVENT")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("MY_CUSTOM_EVENT");
|
||||
expect(span.classList).toContain("text-ink-mid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — expand/collapse", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows payload when a row is clicked (expanded)", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
|
||||
expect(screen.getByText("ID: e1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides payload when the expanded row is clicked again", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// First click: expand
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
|
||||
// Second click: collapse — re-query the button to ensure the
|
||||
// post-render element with the up-to-date handler is targeted
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.queryByText(/"key": "value"/)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("has aria-expanded=true on the expanded row", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Call the onClick prop directly inside act() to bypass React's event
|
||||
// delegation, which fireEvent.click doesn't reliably trigger in jsdom.
|
||||
act(() => {
|
||||
screen.getByRole("button", { name: /workspace_online/i }).click();
|
||||
});
|
||||
await flush();
|
||||
// Verify aria-expanded is true on the expanded button
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
|
||||
?.getAttribute("aria-expanded"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("has aria-expanded=false on collapsed rows", async () => {
|
||||
mockGet.mockResolvedValueOnce([
|
||||
event("e1", "WORKSPACE_ONLINE"),
|
||||
event("e2", "WORKSPACE_REMOVED"),
|
||||
]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Expand the first row
|
||||
act(() => {
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
|
||||
?.click();
|
||||
});
|
||||
await flush();
|
||||
const onlineBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"));
|
||||
const removedBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_REMOVED"));
|
||||
expect(onlineBtn?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(removedBtn?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("has aria-controls linking row to its payload panel", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("evt-42", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Verify the aria-controls attribute on the button
|
||||
expect(
|
||||
screen.getByRole("button", { name: /workspace_online/i }).getAttribute(
|
||||
"aria-controls",
|
||||
),
|
||||
).toBe("events-payload-evt-42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — refresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Refresh button triggers a new GET /events/:id", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
});
|
||||
|
||||
it("shows loading state during refresh (events still visible from previous load)", async () => {
|
||||
// First load succeeds with real timers so the mock resolves
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("1 events")).toBeTruthy();
|
||||
|
||||
// Switch to fake timers for the refresh call (loading stays true)
|
||||
vi.useFakeTimers();
|
||||
// Refresh call hangs to keep loading=true
|
||||
mockGet.mockImplementationOnce(() => new Promise(() => {}));
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await act(() => { vi.runAllTimers(); });
|
||||
// Previous events should still be visible during refresh
|
||||
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — error state", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows error message when GET /events/:id rejects", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("Gateway timeout")).toBeTruthy();
|
||||
expect(screen.queryByText("Loading events...")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows 'Failed to load events' when API rejects with non-Error", async () => {
|
||||
mockGet.mockRejectedValue("unknown failure");
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("Failed to load events")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — auto-refresh", () => {
|
||||
// Use vi.spyOn to mock setInterval/clearInterval so we can control timer
|
||||
// firing without Vitest's fake-timer APIs (which create infinite loops when
|
||||
// timers schedule microtasks that schedule more timers).
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>;
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
|
||||
let activeIntervalId = 0;
|
||||
const scheduledCallbacks = new Map<number, () => void>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
activeIntervalId = 0;
|
||||
scheduledCallbacks.clear();
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(
|
||||
(cb: () => void) => {
|
||||
const id = ++activeIntervalId;
|
||||
scheduledCallbacks.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
);
|
||||
clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(
|
||||
(id: number) => {
|
||||
scheduledCallbacks.delete(id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setIntervalSpy?.mockRestore();
|
||||
clearIntervalSpy?.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls GET /events/:id after 10s without manual interaction", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
mockGet.mockClear();
|
||||
|
||||
// Verify setInterval was called with 10000ms delay
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
10000,
|
||||
);
|
||||
|
||||
// Fire the captured interval callback (simulates 10s elapsing)
|
||||
const callback = [...scheduledCallbacks.values()][0];
|
||||
act(() => { callback(); });
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
});
|
||||
|
||||
it("clears the previous auto-refresh interval on unmount", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
const { unmount } = renderTab();
|
||||
await flush();
|
||||
|
||||
// Verify clearInterval was NOT called yet
|
||||
expect(clearIntervalSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount should call clearInterval with the active interval id
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
// The callback should no longer be scheduled
|
||||
expect(scheduledCallbacks.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user