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();
+ expect(screen.getByText("Runtime Config")).toBeTruthy();
+ });
+
+ it("renders children when defaultOpen=true", () => {
+ render();
+ expect(screen.getByTestId("content")).toBeTruthy();
+ });
+
+ it("hides children when defaultOpen=false", () => {
+ render();
+ expect(screen.queryByTestId("content")).toBeNull();
+ });
+
+ it("toggles children visibility on click", () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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 (
-
);
}
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"
/>