diff --git a/canvas/src/components/__tests__/ApprovalBanner.test.tsx b/canvas/src/components/__tests__/ApprovalBanner.test.tsx index d88cfc1b..5fc0d56f 100644 --- a/canvas/src/components/__tests__/ApprovalBanner.test.tsx +++ b/canvas/src/components/__tests__/ApprovalBanner.test.tsx @@ -16,6 +16,8 @@ vi.mock("@/components/Toaster", () => ({ showToast: vi.fn(), })); +afterEach(cleanup); + // ─── Helpers ────────────────────────────────────────────────────────────────── const pendingApproval = (id = "a1", workspaceId = "ws-1"): { diff --git a/canvas/src/components/__tests__/RevealToggle.test.tsx b/canvas/src/components/__tests__/RevealToggle.test.tsx index 1808b2c7..219c6a74 100644 --- a/canvas/src/components/__tests__/RevealToggle.test.tsx +++ b/canvas/src/components/__tests__/RevealToggle.test.tsx @@ -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, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { RevealToggle } from "../ui/RevealToggle"; describe("RevealToggle — render", () => { + afterEach(cleanup); it("renders a button element", () => { render(); expect(screen.getByRole("button")).toBeTruthy(); diff --git a/canvas/src/components/__tests__/StatusBadge.test.tsx b/canvas/src/components/__tests__/StatusBadge.test.tsx index 4a8ccddf..4f82cd0c 100644 --- a/canvas/src/components/__tests__/StatusBadge.test.tsx +++ b/canvas/src/components/__tests__/StatusBadge.test.tsx @@ -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, screen, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; import { StatusBadge } from "../ui/StatusBadge"; describe("StatusBadge — render", () => { + afterEach(cleanup); it("renders verified status with ✓ icon", () => { render(); const badge = screen.getByRole("status"); diff --git a/canvas/src/components/__tests__/StatusDot.test.tsx b/canvas/src/components/__tests__/StatusDot.test.tsx index ef1445fd..fa06fdc4 100644 --- a/canvas/src/components/__tests__/StatusDot.test.tsx +++ b/canvas/src/components/__tests__/StatusDot.test.tsx @@ -11,16 +11,18 @@ * - 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"; +afterEach(cleanup); + describe("StatusDot — snapshot", () => { it("renders with online status", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-emerald-400"); expect(dot.className).toContain("shadow-emerald-400/50"); expect(dot.getAttribute("aria-hidden")).toBe("true"); @@ -28,7 +30,7 @@ describe("StatusDot — snapshot", () => { it("renders with offline status", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-zinc-500"); // offline has no glow expect(dot.className).not.toContain("shadow-"); @@ -36,34 +38,34 @@ describe("StatusDot — snapshot", () => { it("renders with degraded status", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-amber-400"); expect(dot.className).toContain("shadow-amber-400/50"); }); it("renders with failed status", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-red-400"); expect(dot.className).toContain("shadow-red-400/50"); }); it("renders with paused status", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-indigo-400"); }); it("renders with not_configured status", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-amber-300"); expect(dot.className).toContain("shadow-amber-300/50"); }); it("renders with provisioning status and pulsing animation", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-sky-400"); expect(dot.className).toContain("motion-safe:animate-pulse"); expect(dot.className).toContain("shadow-sky-400/50"); @@ -71,7 +73,7 @@ describe("StatusDot — snapshot", () => { it("falls back to bg-zinc-500 for unknown status", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("bg-zinc-500"); }); }); @@ -79,14 +81,14 @@ describe("StatusDot — snapshot", () => { describe("StatusDot — size prop", () => { it("applies w-2 h-2 (sm, default)", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("w-2"); expect(dot.className).toContain("h-2"); }); it("applies w-2.5 h-2.5 (md)", () => { render(); - const dot = screen.getByRole("img"); + const dot = screen.getByRole("img", { hidden: true }); expect(dot.className).toContain("w-2.5"); expect(dot.className).toContain("h-2.5"); }); @@ -95,6 +97,6 @@ describe("StatusDot — size prop", () => { describe("StatusDot — accessibility", () => { it("is aria-hidden so it doesn't pollute the accessibility tree", () => { render(); - expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true"); + expect(screen.getByRole("img", { hidden: true }).getAttribute("aria-hidden")).toBe("true"); }); }); diff --git a/canvas/src/components/__tests__/Tooltip.test.tsx b/canvas/src/components/__tests__/Tooltip.test.tsx index f2f7de99..433e2f16 100644 --- a/canvas/src/components/__tests__/Tooltip.test.tsx +++ b/canvas/src/components/__tests__/Tooltip.test.tsx @@ -10,9 +10,15 @@ 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(); + vi.useRealTimers(); +}); describe("Tooltip — render", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); it("renders children without showing tooltip on mount", () => { render( @@ -225,11 +231,12 @@ describe("Tooltip — aria-describedby", () => { ); + // The aria-describedby is on the wrapper div, not the button child const btn = screen.getByRole("button"); - const describedBy = btn.getAttribute("aria-describedby"); + const wrapper = btn.parentElement as HTMLElement; + const describedBy = wrapper.getAttribute("aria-describedby"); expect(describedBy).toBeTruthy(); // The describedby id matches the tooltip id - const tooltipId = describedBy!.replace(/.*?:\s*/, ""); - expect(document.getElementById(tooltipId)).toBeTruthy(); + expect(document.getElementById(describedBy!)).toBeTruthy(); }); }); diff --git a/canvas/src/components/__tests__/TopBar.test.tsx b/canvas/src/components/__tests__/TopBar.test.tsx index 260d89e0..39c7e17e 100644 --- a/canvas/src/components/__tests__/TopBar.test.tsx +++ b/canvas/src/components/__tests__/TopBar.test.tsx @@ -6,10 +6,12 @@ * 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, screen, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { TopBar } from "../canvas/TopBar"; +afterEach(cleanup); + // ─── Mock SettingsButton ─────────────────────────────────────────────────────── vi.mock("../settings/SettingsButton", () => ({ diff --git a/canvas/src/components/__tests__/ValidationHint.test.tsx b/canvas/src/components/__tests__/ValidationHint.test.tsx index 1b2fc015..a000b758 100644 --- a/canvas/src/components/__tests__/ValidationHint.test.tsx +++ b/canvas/src/components/__tests__/ValidationHint.test.tsx @@ -6,10 +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, screen, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; import { ValidationHint } from "../ui/ValidationHint"; +afterEach(cleanup); + describe("ValidationHint — error state", () => { it("renders error message when error is a non-null string", () => { render(); @@ -43,7 +45,9 @@ describe("ValidationHint — valid state", () => { it("includes the checkmark icon in valid state", () => { render(); - expect(screen.getByText(/✓ Valid format/)).toBeTruthy(); + // ✓ is in an aria-hidden span; Valid format is a separate text node + expect(screen.getByText(/✓/)).toBeTruthy(); + expect(screen.getByText("Valid format")).toBeTruthy(); }); it("uses the valid class on the paragraph element", () => { diff --git a/canvas/src/components/tabs/config/__tests__/form-inputs.test.tsx b/canvas/src/components/tabs/config/__tests__/form-inputs.test.tsx new file mode 100644 index 00000000..3844141b --- /dev/null +++ b/canvas/src/components/tabs/config/__tests__/form-inputs.test.tsx @@ -0,0 +1,261 @@ +// @vitest-environment jsdom +"use client"; +/** + * Tests for form-inputs.tsx — 35 cases: + * TextInput (7), NumberInput (8), Toggle (5), TagList (9), Section (6). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import React from "react"; + +import { + TextInput, + NumberInput, + Toggle, + TagList, + Section, +} from "../form-inputs"; + +afterEach(cleanup); + +// ─── TextInput ─────────────────────────────────────────────────────────────── + +describe("TextInput", () => { + describe("renders", () => { + it("renders the label", () => { + render(); + expect(screen.getByLabelText("API Key")).toBeTruthy(); + }); + + it("renders the current value", () => { + render(); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("Claude"); + }); + + it("calls onChange when value changes", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Sonnet" } }); + expect(onChange).toHaveBeenCalledWith("Sonnet"); + }); + + it("renders placeholder when provided", () => { + render(); + expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Enter your name"); + }); + + it("applies font-mono class when mono=true", () => { + render(); + const input = screen.getByRole("textbox"); + expect(input.className).toMatch(/font-mono/); + }); + + it("has aria-label matching the label", () => { + render(); + expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("API Key"); + }); + + it("does not apply font-mono class when mono=false", () => { + render(); + expect(screen.getByRole("textbox").className).not.toMatch(/font-mono/); + }); + }); +}); + +// ─── NumberInput ──────────────────────────────────────────────────────────── + +describe("NumberInput", () => { + describe("renders", () => { + it("renders the label", () => { + render(); + expect(screen.getByLabelText("Port")).toBeTruthy(); + }); + + it("renders the numeric value", () => { + render(); + expect((screen.getByRole("spinbutton") as HTMLInputElement).value).toBe("120"); + }); + + it("calls onChange with parsed integer", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "3" } }); + expect(onChange).toHaveBeenCalledWith(3); + }); + + it("calls onChange with 0 for non-numeric input", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "abc" } }); + expect(onChange).toHaveBeenCalledWith(0); + }); + + it("applies min/max attributes", () => { + render(); + const input = screen.getByRole("spinbutton") as HTMLInputElement; + expect(input.min).toBe("1"); + expect(input.max).toBe("10"); + }); + + it("has aria-label matching the label", () => { + render(); + expect(screen.getByRole("spinbutton").getAttribute("aria-label")).toBe("Retries"); + }); + + it("applies font-mono class", () => { + render(); + expect(screen.getByRole("spinbutton").className).toMatch(/font-mono/); + }); + }); +}); + +// ─── Toggle ───────────────────────────────────────────────────────────────── + +describe("Toggle", () => { + describe("renders", () => { + it("renders a checkbox", () => { + render(); + expect(screen.getByRole("checkbox")).toBeTruthy(); + }); + + it("reflects checked=true state", () => { + render(); + expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(true); + }); + + it("reflects checked=false state", () => { + render(); + expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(false); + }); + + it("calls onChange with new boolean value", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("checkbox")); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("renders as type=checkbox", () => { + render(); + expect(screen.getByRole("checkbox").getAttribute("type")).toBe("checkbox"); + }); + }); +}); + +// ─── TagList ─────────────────────────────────────────────────────────────── + +describe("TagList", () => { + describe("renders", () => { + it("renders existing tags", () => { + render(); + expect(screen.getByText("python")).toBeTruthy(); + expect(screen.getByText("go")).toBeTruthy(); + }); + + it("calls onChange with updated array when × clicked", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /remove tag python/i })); + expect(onChange).toHaveBeenCalledWith(["go"]); + }); + + it("× button has correct aria-label per tag", () => { + render(); + expect(screen.getByRole("button", { name: /remove tag python/i })).toBeTruthy(); + }); + + it("adds tag when Enter is pressed with non-empty input", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "rust" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith(["rust"]); + }); + + it("does not add tag when Enter is pressed with whitespace-only input", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("clears input after adding a tag", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "typescript" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect((input as HTMLInputElement).value).toBe(""); + }); + + it("renders the label", () => { + render(); + expect(screen.getByLabelText("Tools")).toBeTruthy(); + }); + + it("renders placeholder text", () => { + render(); + expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Add a skill"); + }); + + it("renders default placeholder when not specified", () => { + render(); + expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Type and press Enter"); + }); + }); +}); + +// ─── Section ──────────────────────────────────────────────────────────────── + +describe("Section", () => { + describe("renders", () => { + it("renders the title", () => { + render(

Content

); + expect(screen.getByText("Runtime Config")).toBeTruthy(); + }); + + it("renders children when defaultOpen=true", () => { + render(

Hello

); + expect(screen.getByTestId("content")).toBeTruthy(); + }); + + it("hides children when defaultOpen=false", () => { + render(

Hello

); + expect(screen.queryByTestId("content")).toBeNull(); + }); + + it("toggles children visibility on click", () => { + render(

Hello

); + expect(screen.getByTestId("content")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /runtime config/i })); + expect(screen.queryByTestId("content")).toBeNull(); + }); + + it("button has aria-expanded reflecting open state", () => { + render(

Content

); + const btn = screen.getByRole("button", { name: /runtime config/i }); + expect(btn.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(btn); + expect(btn.getAttribute("aria-expanded")).toBe("false"); + }); + + it("button has aria-controls linking to content region id", () => { + render(

Content

); + const btn = screen.getByRole("button", { name: /runtime config/i }); + const contentId = btn.getAttribute("aria-controls"); + expect(contentId).not.toBeNull(); + // Content div has the matching id + expect(document.getElementById(String(contentId))).not.toBeNull(); + }); + + it("indicator span has aria-hidden so screen readers skip it", () => { + render(

Content

); + const btn = screen.getByRole("button", { name: /runtime config/i }); + const indicator = btn.querySelector("[aria-hidden='true']"); + expect(indicator).not.toBeNull(); + }); + }); +}); diff --git a/canvas/src/components/tabs/config/form-inputs.tsx b/canvas/src/components/tabs/config/form-inputs.tsx index 4110383e..24cabc17 100644 --- a/canvas/src/components/tabs/config/form-inputs.tsx +++ b/canvas/src/components/tabs/config/form-inputs.tsx @@ -127,13 +127,20 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin export function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) { const [open, setOpen] = useState(defaultOpen); + const contentId = `section-content-${title.toLowerCase().replace(/\s+/g, "-")}`; return (
- - {open &&
{children}
} + {open &&
{children}
}
); } diff --git a/canvas/src/components/ui/KeyValueField.tsx b/canvas/src/components/ui/KeyValueField.tsx index efbef38b..486961a1 100644 --- a/canvas/src/components/ui/KeyValueField.tsx +++ b/canvas/src/components/ui/KeyValueField.tsx @@ -70,6 +70,7 @@ export function KeyValueField({ aria-label={ariaLabel} autoComplete="off" spellCheck={false} + role="textbox" />