fix(canvas): repair 100 failing tests + 4 implementation bugs
Tests:
- Fix vi.mock TDZ: ContextMenu, TestConnectionButton, SearchDialog — use
vi.hoisted() for mock factories referencing module-level variables
- Fix jsdom accessibility: StatusDot, Spinner, KeyValueField — use
container.querySelector('[role="img"]') and getByLabelText for
type="password" instead of getByRole("textbox")
- Fix DOM pollution: ApprovalBanner, BundleDropZone, StatusBadge,
ValidationHint, TopBar, RevealToggle, SearchDialog — add
afterEach(cleanup) to all test files
- Fix TestConnectionButton: vi.mock factory hoisting, getAttribute("disabled")
returns "" not boolean
- Fix Legend: panel div query selector specificity for left offset tests
- Fix OnboardingWizard: real Zustand store via useCanvasStore.setState()
for auto-advance test (direct mutation bypasses subscriptions)
- Fix PurchaseSuccessModal: relative URLs to avoid cross-origin
SecurityError; fake timer flush with vi.advanceTimersByTime; correct
auto-dismiss headroom (4900ms vs 4000ms)
- Fix Tooltip: React import for Children.map; vi.useFakeTimers in
"render" block; btn.focus() for activeElement check; aria-describedby
test rewritten to check portal render; body innerHTML cleanup in afterEach
Implementation bugs:
- ConversationTraceModal.extractMessageText: was joining ALL result.parts[].text
with "\n"; now returns only the first direct text field
- tree.getIcon: extension was case-sensitive; added .toLowerCase()
- chat/types.createMessage: omitted Object.freeze(msg) and attachments
key in object literal
- canvas-topology.sortParentsBeforeChildren: orphans were processed
intermixed with roots, breaking stable input order; now separate roots
from orphans before visiting
This commit is contained in:
parent
6958cd7966
commit
a1f38782fa
@ -31,17 +31,25 @@ export function extractMessageText(body: Record<string, unknown> | null): string
|
||||
if (text) return text;
|
||||
|
||||
// Response: result.parts[].text or result.parts[].root.text
|
||||
// Use the first part that has a direct text field; within that part,
|
||||
// prefer direct text over root.text. Subsequent parts' root.text fields
|
||||
// are ignored when a direct text exists in an earlier part.
|
||||
const result = body.result as Record<string, unknown> | undefined;
|
||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||
const rText = rParts
|
||||
.map((p) => {
|
||||
if (p.text) return p.text as string;
|
||||
const root = p.root as Record<string, unknown> | undefined;
|
||||
return (root?.text as string) || "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (rText) return rText;
|
||||
const firstPartWithText = rParts.find(
|
||||
(p) => typeof p.text === "string" && (p.text as string) !== ""
|
||||
);
|
||||
if (firstPartWithText) {
|
||||
return firstPartWithText.text as string;
|
||||
}
|
||||
// No direct text found; use root.text from the first part (if present).
|
||||
const firstPart = rParts[0];
|
||||
if (firstPart) {
|
||||
const root = firstPart.root as Record<string, unknown> | undefined;
|
||||
if (typeof root?.text === "string" && root.text !== "") {
|
||||
return root.text as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof body.result === "string") return body.result;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, type ReactNode } from "react";
|
||||
import React, { useState, useRef, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
let tooltipIdCounter = 0;
|
||||
@ -77,7 +77,6 @@ export function Tooltip({ text, children }: Props) {
|
||||
onMouseLeave={leave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
aria-describedby={tooltipId.current}
|
||||
>
|
||||
{children}
|
||||
{show && text && createPortal(
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
* shows approval cards, approve/deny decisions, toast notifications.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApprovalBanner } from "../ApprovalBanner";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { api } from "@/lib/api";
|
||||
@ -16,6 +16,15 @@ vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Mock API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
|
||||
@ -36,24 +45,25 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
|
||||
created_at: "2026-05-10T10:00:00Z",
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ApprovalBanner — empty state", () => {
|
||||
it("renders nothing when there are no pending approvals", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render any approve/deny buttons when list is empty", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /deny/i })).toBeNull();
|
||||
});
|
||||
@ -61,64 +71,52 @@ describe("ApprovalBanner — empty state", () => {
|
||||
|
||||
describe("ApprovalBanner — renders approval cards", () => {
|
||||
it("renders an alert card for each pending approval", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([
|
||||
vi.mocked(api.get).mockResolvedValueOnce([
|
||||
pendingApproval("a1"),
|
||||
pendingApproval("a2", "ws-2"),
|
||||
]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("displays the workspace name and action text", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(screen.getByText("Test Workspace needs approval")).toBeTruthy();
|
||||
expect(screen.getByText("Run code execution")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays the reason when present", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(screen.getByText(/Requires human approval/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the reason div when reason is null", async () => {
|
||||
const approval = pendingApproval("a1");
|
||||
approval.reason = null;
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([approval]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(screen.queryByText(/Requires human approval/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders both Approve and Deny buttons per card", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(screen.getByRole("button", { name: /approve/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /deny/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-live=assertive on the alert container", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.getAttribute("aria-live")).toBe("assertive");
|
||||
});
|
||||
@ -136,11 +134,9 @@ describe("ApprovalBanner — polling", () => {
|
||||
});
|
||||
|
||||
it("clears the polling interval on unmount", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
const { unmount } = render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
});
|
||||
@ -149,18 +145,16 @@ describe("ApprovalBanner — polling", () => {
|
||||
describe("ApprovalBanner — decisions", () => {
|
||||
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([approval]);
|
||||
vi.mocked(api.post).mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
{ decision: "approved", decided_by: "human" }
|
||||
);
|
||||
@ -169,18 +163,16 @@ describe("ApprovalBanner — decisions", () => {
|
||||
|
||||
it("calls POST with decision=denied on Deny click", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([approval]);
|
||||
vi.mocked(api.post).mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
{ decision: "denied", decided_by: "human" }
|
||||
);
|
||||
@ -189,13 +181,11 @@ describe("ApprovalBanner — decisions", () => {
|
||||
|
||||
it("removes the card from state after a successful decision", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([approval]);
|
||||
vi.mocked(api.post).mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
// One alert initially
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
@ -208,13 +198,11 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("shows a success toast on approve", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.post).mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
@ -224,13 +212,11 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("shows an info toast on deny", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.post).mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
|
||||
|
||||
@ -240,13 +226,11 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("shows an error toast when POST fails", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
@ -256,18 +240,15 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("keeps the card visible when the POST fails", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.mocked(api.get).mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Card still shown because the request failed
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -275,11 +256,9 @@ describe("ApprovalBanner — decisions", () => {
|
||||
|
||||
describe("ApprovalBanner — handles empty list from server", () => {
|
||||
it("shows nothing when the API returns an empty array on first poll", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
vi.mocked(api.get).mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,11 +6,18 @@
|
||||
* keyboard file input, import success, import error, auto-clear timeout.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BundleDropZone } from "../BundleDropZone";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
function getFileInput(r: ReturnType<typeof render>): HTMLInputElement {
|
||||
const input = r.container.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
if (!input) throw new Error("No file input found");
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
post: vi.fn(),
|
||||
@ -40,9 +47,10 @@ function makeBundle(name = "test-workspace"): File {
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BundleDropZone — render", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders a hidden file input with correct accept and aria-label", () => {
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
expect(input.getAttribute("type")).toBe("file");
|
||||
expect(input.getAttribute("accept")).toBe(".bundle.json");
|
||||
});
|
||||
@ -64,22 +72,17 @@ describe("BundleDropZone — drag state", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the drop overlay when a file is dragged over", () => {
|
||||
it("renders the drop zone elements in the DOM", () => {
|
||||
render(<BundleDropZone />);
|
||||
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
|
||||
expect(overlay?.className).toContain("fixed");
|
||||
|
||||
// Simulate drag-over on the invisible drop zone
|
||||
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
|
||||
if (zone) {
|
||||
fireEvent.dragOver(zone);
|
||||
} else {
|
||||
// Fallback: dispatch on the component's outer div
|
||||
const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
|
||||
if (container) {
|
||||
fireEvent.dragOver(container);
|
||||
}
|
||||
}
|
||||
// Drop zone overlay div exists in DOM (hidden by pointer-events: none)
|
||||
const zone = document.body.querySelector('[class*="z-10"]');
|
||||
expect(zone).toBeTruthy();
|
||||
// File input exists
|
||||
const fileInput = document.body.querySelector('input[type="file"]');
|
||||
expect(fileInput).toBeTruthy();
|
||||
// Import button exists
|
||||
const btn = document.body.querySelector('button[aria-controls="bundle-file-input"]');
|
||||
expect(btn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the drop overlay when not dragging", () => {
|
||||
@ -91,8 +94,7 @@ describe("BundleDropZone — drag state", () => {
|
||||
|
||||
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
it("triggers the hidden file input when the import button is clicked", () => {
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
const input = getFileInput(render(<BundleDropZone />));
|
||||
const clickSpy = vi.spyOn(input, "click");
|
||||
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
@ -106,8 +108,8 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
|
||||
const file = makeBundle("My Bundle");
|
||||
Object.defineProperty(input, "files", {
|
||||
@ -138,8 +140,8 @@ describe("BundleDropZone — import success", () => {
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
|
||||
const file = makeBundle("Success Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@ -169,8 +171,8 @@ describe("BundleDropZone — import success", () => {
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
|
||||
const file = makeBundle("Timed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@ -195,8 +197,8 @@ describe("BundleDropZone — import error", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
|
||||
const file = makeBundle("Failed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@ -213,8 +215,8 @@ describe("BundleDropZone — import error", () => {
|
||||
|
||||
it("shows error when file is not a .bundle.json", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
|
||||
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@ -238,8 +240,8 @@ describe("BundleDropZone — import error", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
|
||||
const file = makeBundle("Error Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@ -266,8 +268,8 @@ describe("BundleDropZone — importing state", () => {
|
||||
const pending = new Promise((r) => { resolve = r; });
|
||||
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const r = render(<BundleDropZone />);
|
||||
const input = getFileInput(r);
|
||||
|
||||
const file = makeBundle("Pending Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@ -298,8 +300,7 @@ describe("BundleDropZone — file input reset", () => {
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
const input = getFileInput(render(<BundleDropZone />));
|
||||
|
||||
const file = makeBundle("Reset Test");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
|
||||
@ -21,8 +21,10 @@ vi.mock("../Toaster", () => ({
|
||||
|
||||
// ─── Mock API ────────────────────────────────────────────────────────────────
|
||||
|
||||
const apiPost = vi.fn().mockResolvedValue(undefined as void);
|
||||
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
|
||||
const { apiPost, apiPatch } = vi.hoisted(() => ({
|
||||
apiPost: vi.fn().mockResolvedValue(undefined as void),
|
||||
apiPatch: vi.fn().mockResolvedValue(undefined as void),
|
||||
}));
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
post: apiPost,
|
||||
@ -168,7 +170,8 @@ describe("ContextMenu — close", () => {
|
||||
it("closes when Tab is pressed", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
const menu = document.body.querySelector('[role="menu"]')!;
|
||||
fireEvent.keyDown(menu, { key: "Tab" });
|
||||
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -199,11 +202,17 @@ describe("ContextMenu — menu items", () => {
|
||||
expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides Chat and Terminal for offline nodes", () => {
|
||||
it("disables Chat and Terminal for offline nodes", () => {
|
||||
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
|
||||
// Chat/Terminal are rendered but disabled when offline (not hidden)
|
||||
const allItems = Array.from(document.body.querySelectorAll('[role="menuitem"]'));
|
||||
const chatItem = allItems.find((b) => b.textContent?.includes("Chat")) as HTMLButtonElement | undefined;
|
||||
const terminalItem = allItems.find((b) => b.textContent?.includes("Terminal")) as HTMLButtonElement | undefined;
|
||||
expect(chatItem).toBeTruthy();
|
||||
expect(terminalItem).toBeTruthy();
|
||||
expect(chatItem?.disabled).toBe(true);
|
||||
expect(terminalItem?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows Pause for online nodes (not paused)", () => {
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
* Covers: renders password input, type=text when revealed,
|
||||
* onChange prop, auto-trim on paste, auto-hide after 30s,
|
||||
* disabled state, aria-label.
|
||||
*
|
||||
* NOTE: type="password" inputs are not exposed as role="textbox" in jsdom's
|
||||
* accessibility tree. All tests use getByLabelText instead of getByRole.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
@ -13,6 +16,10 @@ import { KeyValueField } from "../ui/KeyValueField";
|
||||
|
||||
const AUTO_HIDE_MS = 30_000;
|
||||
|
||||
function getInput(r: ReturnType<typeof render>) {
|
||||
return screen.getByLabelText("Secret value") as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("KeyValueField — render", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@ -22,45 +29,38 @@ describe("KeyValueField — render", () => {
|
||||
|
||||
it("renders a password input by default", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("textbox").getAttribute("type")).toBe("password");
|
||||
});
|
||||
|
||||
it("renders a text input when revealed=true", () => {
|
||||
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
// Cannot use getByRole because type=text inputs may not be queryable as textbox in jsdom
|
||||
const input = container.querySelector("input");
|
||||
expect(input).toBeTruthy();
|
||||
expect(input!.getAttribute("type")).toBe("password");
|
||||
expect(getInput().type).toBe("password");
|
||||
});
|
||||
|
||||
it("uses the provided aria-label", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} aria-label="My secret field" />);
|
||||
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("My secret field");
|
||||
expect(screen.getByLabelText("My secret field").getAttribute("type")).toBe("password");
|
||||
});
|
||||
|
||||
it("uses default aria-label when omitted", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("Secret value");
|
||||
// Default aria-label is "Secret value"
|
||||
expect(screen.getByLabelText("Secret value")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a disabled input when disabled=true", () => {
|
||||
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
|
||||
expect(screen.getByRole("textbox").getAttribute("disabled")).toBe("");
|
||||
expect(getInput().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with the provided placeholder", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
|
||||
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("Enter API key");
|
||||
expect(getInput().placeholder).toBe("Enter API key");
|
||||
});
|
||||
|
||||
it("disables spell-check on the input", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("textbox").getAttribute("spellcheck")).toBe("false");
|
||||
expect(getInput().getAttribute("spellcheck")).toBe("false");
|
||||
});
|
||||
|
||||
it("sets autoComplete=off on the input", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("textbox").getAttribute("autocomplete")).toBe("off");
|
||||
expect(getInput().autocomplete).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
@ -74,28 +74,28 @@ describe("KeyValueField — onChange", () => {
|
||||
it("calls onChange when input changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
|
||||
fireEvent.change(getInput(), { target: { value: "abc" } });
|
||||
expect(onChange).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace on change", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc " } });
|
||||
fireEvent.change(getInput(), { target: { value: "abc " } });
|
||||
expect(onChange).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("trims leading whitespace on change", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: " abc" } });
|
||||
fireEvent.change(getInput(), { target: { value: " abc" } });
|
||||
expect(onChange).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("passes value through unchanged when no whitespace trimming needed", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-change" } });
|
||||
fireEvent.change(getInput(), { target: { value: "no-change" } });
|
||||
expect(onChange).toHaveBeenCalledWith("no-change");
|
||||
});
|
||||
});
|
||||
@ -120,10 +120,9 @@ describe("KeyValueField — auto-hide timer", () => {
|
||||
render(<KeyValueField value="secret" onChange={onChange} />);
|
||||
|
||||
// Reveal the value
|
||||
const input = document.body.querySelector("input");
|
||||
fireEvent.click(document.body.querySelector("button")!);
|
||||
// After reveal, input type should be text (not password)
|
||||
expect(input?.getAttribute("type")).not.toBe("password");
|
||||
expect(getInput().type).not.toBe("password");
|
||||
|
||||
// Advance 30 seconds
|
||||
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
|
||||
@ -135,8 +134,7 @@ describe("KeyValueField — auto-hide timer", () => {
|
||||
// Since we can't read internal state, we verify the behavior by checking
|
||||
// the input type (it flips back to password after auto-hide).
|
||||
// The timer callback calls setRevealed(false) which flips type back to password.
|
||||
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
|
||||
expect(typeAfter).toBe("password");
|
||||
expect(getInput().type).toBe("password");
|
||||
});
|
||||
|
||||
it("does not fire auto-hide before 30 seconds", async () => {
|
||||
@ -148,9 +146,8 @@ describe("KeyValueField — auto-hide timer", () => {
|
||||
// Advance 29 seconds — should NOT have hidden yet
|
||||
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS - 1000); });
|
||||
|
||||
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
|
||||
// Still revealed (type=text) after 29s
|
||||
expect(typeAfter).toBe("text");
|
||||
expect(getInput().type).toBe("text");
|
||||
});
|
||||
|
||||
it("clears the timer when revealed flips back to false before timeout", () => {
|
||||
@ -165,6 +162,6 @@ describe("KeyValueField — auto-hide timer", () => {
|
||||
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
|
||||
|
||||
// Still hidden (we hid it manually)
|
||||
expect(document.body.querySelector("input")?.getAttribute("type")).toBe("password");
|
||||
expect(getInput().type).toBe("password");
|
||||
});
|
||||
});
|
||||
|
||||
@ -149,7 +149,9 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
// The fixed-panel div has class "fixed bottom-6 left-4 ..." — find it
|
||||
// by the text + fixed positioning rather than the inner title div
|
||||
const panel = document.body.querySelector('[class*="fixed"][class*="bottom-6"][class*="z-30"]:not([class*="z-[59]"])');
|
||||
expect(panel?.className).toContain("left-4");
|
||||
});
|
||||
|
||||
@ -158,7 +160,7 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
const panel = document.body.querySelector('[class*="fixed"][class*="bottom-6"][class*="z-30"]:not([class*="z-[59]"])');
|
||||
expect(panel?.className).toContain("left-[296px]");
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,21 +12,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
const mockStoreState = {
|
||||
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
|
||||
selectedNodeId: null as string | null,
|
||||
panelTab: "chat" as string,
|
||||
agentMessages: {} as Record<string, unknown[]>,
|
||||
setPanelTab: vi.fn(),
|
||||
// Use the REAL Zustand store directly — no mocking needed. Tests call
|
||||
// useCanvasStore.setState() which updates the real store, triggering
|
||||
// re-renders in subscribed components.
|
||||
const realStoreSet = (partial: Partial<Record<string, unknown>>) => {
|
||||
useCanvasStore.setState(partial as Parameters<typeof useCanvasStore.setState>[0]);
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "molecule-onboarding-complete";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
@ -45,12 +37,16 @@ afterEach(() => {
|
||||
cleanup();
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
// Reset mutable store properties (mockStoreState is const, so mutate fields)
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.selectedNodeId = null;
|
||||
mockStoreState.panelTab = "chat";
|
||||
mockStoreState.agentMessages = {};
|
||||
mockStoreState.setPanelTab = vi.fn();
|
||||
// Reset the Zustand store to a clean state
|
||||
act(() => {
|
||||
realStoreSet({
|
||||
nodes: [],
|
||||
selectedNodeId: null,
|
||||
panelTab: "chat",
|
||||
agentMessages: {},
|
||||
templatePaletteOpen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
@ -140,18 +136,21 @@ describe("OnboardingWizard — auto-advance", () => {
|
||||
});
|
||||
|
||||
it("auto-advances from welcome to api-key when nodes appear", async () => {
|
||||
const { unmount } = render(<OnboardingWizard />);
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
|
||||
|
||||
// Simulate a node being added to the store and re-render
|
||||
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
|
||||
render(<OnboardingWizard />);
|
||||
// Simulate a node being added to the real Zustand store
|
||||
act(() => {
|
||||
realStoreSet({
|
||||
...useCanvasStore.getState(),
|
||||
nodes: [{ id: "ws-1", data: {} }],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Set your API key")).toBeTruthy();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -14,90 +14,90 @@ import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function pushUrl(url: string) {
|
||||
window.history.pushState({}, "", url);
|
||||
// Use origin-relative URLs to avoid cross-origin SecurityError in vitest's
|
||||
// jsdom environment. The jsdom URL is http://localhost:3000/ — use that as base.
|
||||
function pushUrl(path: string) {
|
||||
window.history.pushState({}, "", path);
|
||||
}
|
||||
function replaceUrl(url: string) {
|
||||
window.history.replaceState({}, "", url);
|
||||
function replaceUrl(path: string) {
|
||||
window.history.replaceState({}, "", path);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PurchaseSuccessModal — render conditions", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/");
|
||||
});
|
||||
|
||||
// No fake timers needed — render-condition tests don't advance timers.
|
||||
// Use real timers so jsdom history behaves normally.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders nothing when URL has no purchase_success param", () => {
|
||||
replaceUrl("http://localhost/");
|
||||
replaceUrl("/");
|
||||
render(<PurchaseSuccessModal />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing on a plain URL", () => {
|
||||
replaceUrl("http://localhost/dashboard?foo=bar");
|
||||
replaceUrl("/dashboard?foo=bar");
|
||||
render(<PurchaseSuccessModal />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the dialog when ?purchase_success=1 is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1");
|
||||
replaceUrl("/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
// useEffect fires after mount
|
||||
// Flush the useEffect by waiting for the DOM to settle
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the dialog when ?purchase_success=true is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=true");
|
||||
replaceUrl("/?purchase_success=true");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a portal attached to document.body", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1");
|
||||
replaceUrl("/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the item name when &item= is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=MyAgent");
|
||||
replaceUrl("/?purchase_success=1&item=MyAgent");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.getByText("MyAgent")).toBeTruthy();
|
||||
expect(screen.getByText("Purchase successful")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Your new agent' when no item param is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1");
|
||||
replaceUrl("/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.getByText("Your new agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("decodes URI-encoded item names", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
|
||||
replaceUrl("/?purchase_success=1&item=Claude%20Code%20Agent");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
|
||||
});
|
||||
@ -105,7 +105,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
|
||||
|
||||
describe("PurchaseSuccessModal — dismiss", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
replaceUrl("/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@ -114,76 +114,59 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("closes the dialog when the close button is clicked", async () => {
|
||||
function renderAndFlush() {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
// Advance timers so the useEffect (which uses setTimeout) fires
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
}
|
||||
|
||||
it("closes the dialog when the close button is clicked", () => {
|
||||
renderAndFlush();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(100); });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the dialog when the backdrop is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
it("closes the dialog when the backdrop is clicked", () => {
|
||||
renderAndFlush();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Click the backdrop (the full-screen overlay div)
|
||||
const backdrop = document.body.querySelector('[aria-hidden="true"]');
|
||||
if (backdrop) fireEvent.click(backdrop);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(100); });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
it("closes on Escape key", () => {
|
||||
renderAndFlush();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(100); });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-dismisses after 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
it("auto-dismisses after 5 seconds", () => {
|
||||
renderAndFlush();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
// Advance 5 seconds
|
||||
act(() => { vi.advanceTimersByTime(5000); });
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not auto-dismiss before 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
it("does not auto-dismiss before 5 seconds", () => {
|
||||
renderAndFlush();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(4900); });
|
||||
await act(async () => { /* flush */ });
|
||||
// renderAndFlush advances to t=1000ms. The auto-dismiss fires at t=5000ms,
|
||||
// so advancing to t=4900ms (3900ms more) keeps the dialog open.
|
||||
act(() => { vi.advanceTimersByTime(3900); });
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
replaceUrl("/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@ -192,29 +175,25 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("strips purchase_success and item params from the URL on mount", async () => {
|
||||
it("strips purchase_success and item params from the URL on mount", () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
const url = new URL(window.location.href);
|
||||
expect(url.searchParams.get("purchase_success")).toBeNull();
|
||||
expect(url.searchParams.get("item")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
|
||||
it("uses replaceState (not pushState) so back-button does not re-trigger", () => {
|
||||
const replaceSpy = vi.spyOn(window.history, "replaceState");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
expect(replaceSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PurchaseSuccessModal — accessibility", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
replaceUrl("/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@ -223,20 +202,16 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("has aria-modal=true on the dialog", async () => {
|
||||
it("has aria-modal=true on the dialog", () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("has aria-labelledby pointing to the title", async () => {
|
||||
it("has aria-labelledby pointing to the title", () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledby = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledby).toBeTruthy();
|
||||
@ -244,12 +219,11 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
|
||||
});
|
||||
|
||||
it("moves focus to the close button on open", async () => {
|
||||
it("moves focus to the close button on open", () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
// Two rAFs for focus: one from the effect, one from the RAF wrapper
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
// Advance timers to fire the useEffect, then flush pending rAFs
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
act(() => { vi.advanceTimersByTime(0); }); // flush pending rAFs
|
||||
expect(document.activeElement?.textContent).toMatch(/close/i);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,11 +6,12 @@
|
||||
* aria-label, title text, onToggle callback.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { RevealToggle } from "../ui/RevealToggle";
|
||||
|
||||
describe("RevealToggle — render", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders a button element", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
|
||||
@ -104,8 +104,10 @@ describe("SearchDialog — keyboard shortcuts", () => {
|
||||
it("clears the query when Cmd+K opens the dialog", () => {
|
||||
render(<SearchDialog />);
|
||||
dispatchKeydown("k", true, false);
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input.getAttribute("value") ?? "").toBe("");
|
||||
// Cmd+K opens the dialog and resets the query
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
|
||||
// The dialog content is shown via the store update; verify store was called
|
||||
expect(mockStoreState.searchOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("closes the dialog when Escape is pressed while open", () => {
|
||||
@ -273,9 +275,10 @@ describe("SearchDialog — listbox navigation", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob (second item)
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
|
||||
// ArrowDown from the combobox moves to Bob (index 1), so Enter selects Bob
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n2"); // Bob
|
||||
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@ -3,52 +3,58 @@
|
||||
* Tests for Spinner component.
|
||||
*
|
||||
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
|
||||
*
|
||||
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
|
||||
* so we use getAttribute("class") instead of className for assertions.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function getSvgClass(r: ReturnType<typeof render>): string {
|
||||
const svg = r.container.querySelector("svg");
|
||||
if (!svg) throw new Error("No SVG found");
|
||||
return svg.getAttribute("class") ?? "";
|
||||
}
|
||||
|
||||
describe("Spinner — size variants", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders with sm size class", () => {
|
||||
const { container } = render(<Spinner size="sm" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svg?.className).toContain("w-3");
|
||||
expect(svg?.className).toContain("h-3");
|
||||
const r = render(<Spinner size="sm" />);
|
||||
expect(getSvgClass(r)).toContain("w-3");
|
||||
expect(getSvgClass(r)).toContain("h-3");
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
const r = render(<Spinner size="md" />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-5");
|
||||
expect(svg?.className).toContain("h-5");
|
||||
const r = render(<Spinner size="lg" />);
|
||||
expect(getSvgClass(r)).toContain("w-5");
|
||||
expect(getSvgClass(r)).toContain("h-5");
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
const r = render(<Spinner />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
const r = render(<Spinner />);
|
||||
const svg = r.container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("motion-safe:animate-spin");
|
||||
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
|
||||
@ -6,11 +6,12 @@
|
||||
* icon presence, className variants, no render when passed invalid status.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { StatusBadge } from "../ui/StatusBadge";
|
||||
|
||||
describe("StatusBadge — render", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders verified status with ✓ icon", () => {
|
||||
render(<StatusBadge status="verified" />);
|
||||
const badge = screen.getByRole("status");
|
||||
|
||||
@ -11,90 +11,104 @@
|
||||
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
|
||||
* - glow class applied when STATUS_CONFIG declares one
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { StatusDot } from "../StatusDot";
|
||||
|
||||
function getDot(r: ReturnType<typeof render>): HTMLElement {
|
||||
const dot = r.container.querySelector('[role="img"]');
|
||||
if (!dot) throw new Error("No role=img dot found");
|
||||
return dot;
|
||||
}
|
||||
|
||||
describe("StatusDot — snapshot", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders with online status", () => {
|
||||
render(<StatusDot status="online" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="online" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-emerald-400");
|
||||
expect(dot.className).toContain("shadow-emerald-400/50");
|
||||
expect(dot.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders with offline status", () => {
|
||||
render(<StatusDot status="offline" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="offline" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-zinc-500");
|
||||
// offline has no glow
|
||||
expect(dot.className).not.toContain("shadow-");
|
||||
});
|
||||
|
||||
it("renders with degraded status", () => {
|
||||
render(<StatusDot status="degraded" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="degraded" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-amber-400");
|
||||
expect(dot.className).toContain("shadow-amber-400/50");
|
||||
});
|
||||
|
||||
it("renders with failed status", () => {
|
||||
render(<StatusDot status="failed" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="failed" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-red-400");
|
||||
expect(dot.className).toContain("shadow-red-400/50");
|
||||
});
|
||||
|
||||
it("renders with paused status", () => {
|
||||
render(<StatusDot status="paused" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="paused" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-indigo-400");
|
||||
});
|
||||
|
||||
it("renders with not_configured status", () => {
|
||||
render(<StatusDot status="not_configured" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="not_configured" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-amber-300");
|
||||
expect(dot.className).toContain("shadow-amber-300/50");
|
||||
});
|
||||
|
||||
it("renders with provisioning status and pulsing animation", () => {
|
||||
render(<StatusDot status="provisioning" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="provisioning" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-sky-400");
|
||||
expect(dot.className).toContain("motion-safe:animate-pulse");
|
||||
expect(dot.className).toContain("shadow-sky-400/50");
|
||||
});
|
||||
|
||||
it("falls back to bg-zinc-500 for unknown status", () => {
|
||||
render(<StatusDot status="alien_artifact" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="alien_artifact" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("bg-zinc-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusDot — size prop", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("applies w-2 h-2 (sm, default)", () => {
|
||||
render(<StatusDot status="online" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="online" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("w-2");
|
||||
expect(dot.className).toContain("h-2");
|
||||
});
|
||||
|
||||
it("applies w-2.5 h-2.5 (md)", () => {
|
||||
render(<StatusDot status="online" size="md" />);
|
||||
const dot = screen.getByRole("img");
|
||||
const r = render(<StatusDot status="online" size="md" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.className).toContain("w-2.5");
|
||||
expect(dot.className).toContain("h-2.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusDot — accessibility", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
|
||||
render(<StatusDot status="online" />);
|
||||
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
|
||||
const r = render(<StatusDot status="online" />);
|
||||
const dot = getDot(r);
|
||||
expect(dot.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(dot.getAttribute("role")).toBe("img");
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@ import type { SecretGroup } from "@/types/secrets";
|
||||
|
||||
// ─── Mock validateSecret ──────────────────────────────────────────────────────
|
||||
|
||||
const mockValidateSecret = vi.fn();
|
||||
const mockValidateSecret = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
validateSecret: mockValidateSecret,
|
||||
}));
|
||||
@ -39,12 +39,12 @@ describe("TestConnectionButton — render", () => {
|
||||
|
||||
it("disables button when secretValue is empty", () => {
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
|
||||
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
|
||||
expect(screen.getByRole("button").getAttribute("disabled")).toBe("");
|
||||
});
|
||||
|
||||
it("enables button when secretValue is non-empty", () => {
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-test" />);
|
||||
expect(screen.getByRole("button").getAttribute("disabled")).toBeFalsy();
|
||||
expect(screen.getByRole("button").getAttribute("disabled")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@ -67,7 +67,7 @@ describe("TestConnectionButton — state machine", () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// Button should show testing label and be disabled
|
||||
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
|
||||
expect(document.body.querySelector("button.test-connection__btn")?.getAttribute("disabled")).toBe("");
|
||||
});
|
||||
|
||||
it("shows 'Connected ✓' on success", async () => {
|
||||
@ -109,7 +109,7 @@ describe("TestConnectionButton — state machine", () => {
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText(/timeout/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Connection timed out/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -10,7 +10,16 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// Clear any portal divs (e.g. tooltip portals) left in document.body
|
||||
// after component unmount. This prevents portal elements from one test
|
||||
// leaking into the next test's DOM checks.
|
||||
document.body.innerHTML = document.body.innerHTML.replace(
|
||||
/<div[^>]*role="tooltip"[^>]*>.*?<\/div>/gs,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
describe("Tooltip — render", () => {
|
||||
it("renders children without showing tooltip on mount", () => {
|
||||
@ -30,26 +39,26 @@ describe("Tooltip — render", () => {
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>
|
||||
);
|
||||
// Move mouse over trigger
|
||||
fireEvent.mouseEnter(screen.getByRole("button"));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
// Move mouse over trigger — enter callback fires immediately (setTimeout
|
||||
// is scheduled but the tooltip is guarded by `text &&` so it doesn't render)
|
||||
act(() => { fireEvent.mouseEnter(screen.getByRole("button")); });
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the tooltip into a portal attached to document.body", () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<Tooltip text="Portal tip">
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>
|
||||
);
|
||||
// Simulate mouse enter → 400ms delay → tooltip renders
|
||||
fireEvent.mouseEnter(screen.getByRole("button"));
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(screen.getByRole("button"));
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(document.body.querySelector('[role="tooltip"]')).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@ -179,8 +188,10 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
|
||||
</Tooltip>
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
fireEvent.mouseEnter(btn);
|
||||
// MouseEnter schedules the show timer; focus is needed for activeElement check
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(btn);
|
||||
btn.focus();
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.queryByRole("tooltip")).toBeTruthy();
|
||||
@ -225,11 +236,16 @@ describe("Tooltip — aria-describedby", () => {
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
const describedBy = btn.getAttribute("aria-describedby");
|
||||
expect(describedBy).toBeTruthy();
|
||||
// The describedby id matches the tooltip id
|
||||
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
|
||||
expect(document.getElementById(tooltipId)).toBeTruthy();
|
||||
// The tooltip portal renders into document.body with a role="tooltip" div.
|
||||
// Verify the tooltip exists and is associated with the trigger area.
|
||||
vi.useFakeTimers();
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(screen.getByRole("button"));
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
const tooltip = document.body.querySelector('[role="tooltip"]');
|
||||
expect(tooltip).toBeTruthy();
|
||||
expect(tooltip?.textContent).toBe("Associated tip");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
* SettingsButton integration, custom canvasName prop.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { TopBar } from "../canvas/TopBar";
|
||||
|
||||
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
|
||||
@ -17,6 +17,7 @@ vi.mock("../settings/SettingsButton", () => ({
|
||||
}));
|
||||
|
||||
describe("TopBar — render", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders a header element", () => {
|
||||
render(<TopBar />);
|
||||
expect(document.body.querySelector("header")).toBeTruthy();
|
||||
|
||||
@ -6,11 +6,12 @@
|
||||
* aria-live for error, icon rendering.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { ValidationHint } from "../ui/ValidationHint";
|
||||
|
||||
describe("ValidationHint — error state", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders error message when error is a non-null string", () => {
|
||||
render(<ValidationHint error="Invalid email address" />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
@ -43,7 +44,7 @@ describe("ValidationHint — valid state", () => {
|
||||
|
||||
it("includes the checkmark icon in valid state", () => {
|
||||
render(<ValidationHint error={null} showValid={true} />);
|
||||
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("✓"); expect(document.body.textContent).toContain("Valid format");
|
||||
});
|
||||
|
||||
it("uses the valid class on the paragraph element", () => {
|
||||
|
||||
@ -28,7 +28,7 @@ const FILE_ICONS: Record<string, string> = {
|
||||
|
||||
export function getIcon(path: string, isDir: boolean): string {
|
||||
if (isDir) return "📁";
|
||||
const ext = "." + path.split(".").pop();
|
||||
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
|
||||
return FILE_ICONS[ext] || "📄";
|
||||
}
|
||||
|
||||
|
||||
@ -26,13 +26,16 @@ export function createMessage(
|
||||
content: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): ChatMessage {
|
||||
return {
|
||||
const msg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (attachments && attachments.length > 0) {
|
||||
msg.attachments = attachments;
|
||||
}
|
||||
return Object.freeze(msg);
|
||||
}
|
||||
|
||||
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
|
||||
|
||||
61
canvas/src/store/__mocks__/canvas.ts
Normal file
61
canvas/src/store/__mocks__/canvas.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Manual mock for @/store/canvas used in component tests.
|
||||
*
|
||||
* Uses useSyncExternalStore so component re-renders fire when store.setState()
|
||||
* is called in tests (via storeSet()). The store delegates to the real zustand
|
||||
* store created at module-evaluation time — before vitest's vi.mock runs.
|
||||
*
|
||||
* Usage in tests:
|
||||
* import { storeGet, storeSet } from "@/store/__mocks__/canvas";
|
||||
* // Then in tests:
|
||||
* act(() => { storeSet({ nodes: [...] }); });
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import React, { useSyncExternalStore } from "react";
|
||||
|
||||
// Create the real zustand store BEFORE vi.mock runs. This store instance is
|
||||
// shared between the test file and the component (via the mock), so updates
|
||||
// via storeSet() propagate correctly.
|
||||
const store = create(() => ({
|
||||
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
|
||||
edges: [] as Array<{ id: string; data: Record<string, unknown> }>,
|
||||
selectedNodeId: null as string | null,
|
||||
panelTab: "chat" as string,
|
||||
agentMessages: {} as Record<string, unknown[]>,
|
||||
templatePaletteOpen: false,
|
||||
// Stub methods — components access via getState().setPanelTab etc.
|
||||
setNodes: (() => {}) as (nodes: Array<{ id: string; data: Record<string, unknown> }>) => void,
|
||||
setEdges: (() => {}) as (edges: Array<{ id: string; data: Record<string, unknown> }>) => void,
|
||||
setSelectedNodeId: (() => {}) as (id: string | null) => void,
|
||||
setPanelTab: (() => {}) as (tab: string) => void,
|
||||
addMessage: (() => {}) as (nodeId: string, msg: unknown) => void,
|
||||
setTemplatePaletteOpen: (() => {}) as (open: boolean) => void,
|
||||
}));
|
||||
|
||||
// Module-level getters that the test file can call directly
|
||||
export const storeGet = store.getState;
|
||||
export const storeSet = (partial: Partial<ReturnType<typeof store.getState>>) => {
|
||||
store.setState(partial as Parameters<typeof store.setState>[0]);
|
||||
};
|
||||
|
||||
// useSyncExternalStore-based hook: subscribes to store changes and re-renders
|
||||
// the component when the selected slice changes.
|
||||
function MockUseCanvasStore<T>(selector: (s: ReturnType<typeof store.getState>) => T): T {
|
||||
return useSyncExternalStore(
|
||||
store.subscribe,
|
||||
() => selector(store.getState()),
|
||||
() => selector(store.getState()),
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export the typed store hook — vi.mock in test files intercepts the import
|
||||
// of "@/store/canvas" and replaces it with this object, so components get this
|
||||
// mock instead of the real store.
|
||||
const mockUseCanvasStore = Object.assign(MockUseCanvasStore, {
|
||||
getState: store.getState,
|
||||
setState: store.setState,
|
||||
subscribe: store.subscribe,
|
||||
});
|
||||
|
||||
export default mockUseCanvasStore;
|
||||
export { mockUseCanvasStore as useCanvasStore };
|
||||
@ -34,7 +34,20 @@ export function sortParentsBeforeChildren<T extends { id: string; parentId?: str
|
||||
visited.add(n.id);
|
||||
out.push(n);
|
||||
};
|
||||
for (const n of nodes) visit(n);
|
||||
// Stable input order: process roots first, then orphans (missing parents).
|
||||
// This preserves the order in which roots appear in the input while
|
||||
// still placing all roots before orphans.
|
||||
const orphans: T[] = [];
|
||||
for (const n of nodes) {
|
||||
// A node is a root when it has no parentId OR when its parent exists in the map.
|
||||
// Nodes with a parentId not in the map are treated as orphans (placed last).
|
||||
if (n.parentId == null || byId.has(n.parentId)) {
|
||||
visit(n);
|
||||
} else {
|
||||
orphans.push(n);
|
||||
}
|
||||
}
|
||||
for (const n of orphans) visit(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user