From f6275dd6c0732c81614c2b0f893e589936cfe2f2 Mon Sep 17 00:00:00 2001 From: Molecule AI App-FE Date: Tue, 12 May 2026 00:33:24 +0000 Subject: [PATCH 1/2] test(ui): add KeyValueField, RevealToggle, ValidationHint coverage (29 cases) - ValidationHint (6 cases): null/valid/error render, role=alert a11y - RevealToggle (9 cases): eye-icon toggle, aria-label, onToggle callback, SVG icons - KeyValueField (14 cases): password type, aria-label forwarding, onChange with whitespace trim, disabled state, auto-hide timer setup + cleanup Co-Authored-By: Claude Opus 4.7 --- .../ui/__tests__/KeyValueField.test.tsx | 142 ++++++++++++++++++ .../ui/__tests__/RevealToggle.test.tsx | 68 +++++++++ .../ui/__tests__/ValidationHint.test.tsx | 49 ++++++ 3 files changed, 259 insertions(+) create mode 100644 canvas/src/components/ui/__tests__/KeyValueField.test.tsx create mode 100644 canvas/src/components/ui/__tests__/RevealToggle.test.tsx create mode 100644 canvas/src/components/ui/__tests__/ValidationHint.test.tsx diff --git a/canvas/src/components/ui/__tests__/KeyValueField.test.tsx b/canvas/src/components/ui/__tests__/KeyValueField.test.tsx new file mode 100644 index 00000000..1603faa6 --- /dev/null +++ b/canvas/src/components/ui/__tests__/KeyValueField.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +/** + * Tests for KeyValueField component. + * + * Covers: initial password type, onChange callback (including whitespace trim + * on type), aria-label forwarding, disabled state, and auto-hide timer setup. + */ +import React from "react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { KeyValueField } from "../KeyValueField"; + +describe("KeyValueField — rendering", () => { + afterEach(cleanup); + + it("renders input with type=password by default (secret hidden)", () => { + render(); + const input = screen.getByLabelText("Secret value"); + expect(input.getAttribute("type")).toBe("password"); + }); + + it("passes custom aria-label to the input element", () => { + render(); + expect(screen.getByLabelText("API secret key")).toBeTruthy(); + }); + + it("disables the input when disabled=true", () => { + render(); + expect(screen.getByLabelText("Secret value").disabled).toBe(true); + }); + + it("renders with the current value", () => { + render(); + expect(screen.getByLabelText("Secret value").value).toBe("sk-test-key-123"); + }); + + it("renders with the placeholder text", () => { + render(); + expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key"); + }); + + it("renders the RevealToggle child button", () => { + render(); + // KeyValueField renders exactly one button (the RevealToggle) + expect(screen.getByRole("button")).toBeTruthy(); + }); +}); + +describe("KeyValueField — onChange", () => { + afterEach(cleanup); + + it("calls onChange with the new value when user types", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "new-value" } }); + expect(onChange).toHaveBeenCalledWith("new-value"); + }); + + it("trims leading whitespace when user types with leading space", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " trimmed" } }); + expect(onChange).toHaveBeenCalledWith("trimmed"); + }); + + it("trims trailing whitespace when user types with trailing space", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "trimmed " } }); + expect(onChange).toHaveBeenCalledWith("trimmed"); + }); + + it("trims both sides when user types whitespace-surrounded value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " both sides " } }); + expect(onChange).toHaveBeenCalledWith("both sides"); + }); + + it("does not modify value with no whitespace", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "clean-value" } }); + expect(onChange).toHaveBeenCalledWith("clean-value"); + }); +}); + +describe("KeyValueField — auto-hide timer setup", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("sets up a 30s setTimeout when the component mounts with a non-empty value", () => { + const setTimeoutSpy = vi.spyOn(global, "setTimeout"); + render(); + // No timer should be set initially (revealed=false by default) + const callsBeforeInteraction = setTimeoutSpy.mock.calls.length; + + // Simulate reveal (click the only button) + act(() => { fireEvent.click(screen.getByRole("button")); }); + + // After reveal, a 30s timer should be set + const timerCalls = setTimeoutSpy.mock.calls.filter( + ([, delay]) => delay === 30_000, + ); + expect(timerCalls.length).toBeGreaterThanOrEqual(1); + }); + + it("clears existing timer when a new toggle happens before auto-hide fires", () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + const timerObj = {}; // fake timer ID + vi.spyOn(global, "setTimeout").mockImplementation((fn: () => void, delay: number) => { + return timerObj; + }); + render(); + + // First toggle — reveal + act(() => { fireEvent.click(screen.getByRole("button")); }); + + // Second toggle — hide (should clear the timer from first toggle) + act(() => { fireEvent.click(screen.getByRole("button")); }); + + // clearTimeout was called with the timer object + expect(clearTimeoutSpy).toHaveBeenCalledWith(timerObj); + }); + + it("clears timer on unmount", () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + const { unmount } = render(); + + // Toggle reveal to start the timer + act(() => { fireEvent.click(screen.getByRole("button")); }); + + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); +}); diff --git a/canvas/src/components/ui/__tests__/RevealToggle.test.tsx b/canvas/src/components/ui/__tests__/RevealToggle.test.tsx new file mode 100644 index 00000000..0a68d454 --- /dev/null +++ b/canvas/src/components/ui/__tests__/RevealToggle.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +/** + * Tests for RevealToggle component. + * + * Covers: eye-icon (hidden) vs eye-off-icon (revealed), onToggle callback, + * aria-label (default + custom), title attribute. + */ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { RevealToggle } from "../RevealToggle"; + +afterEach(cleanup); + +describe("RevealToggle", () => { + it("renders as a button", () => { + render(); + expect(screen.getByRole("button")).toBeTruthy(); + }); + + it("uses default aria-label when not provided", () => { + render(); + expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle reveal secret"); + }); + + it("uses custom aria-label when provided", () => { + render(); + expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password"); + }); + + it('title is "Hide value" when revealed', () => { + render(); + expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value"); + }); + + it('title is "Show value" when hidden', () => { + render(); + expect(screen.getByRole("button").getAttribute("title")).toBe("Show value"); + }); + + it("calls onToggle when clicked (revealed=true → should hide)", () => { + const onToggle = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle when clicked (revealed=false → should show)", () => { + const onToggle = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("renders the eye-open SVG (hide icon) when revealed=false", () => { + render(); + const btn = screen.getByRole("button"); + // The eye SVG contains a circle element; eye-off has a strikethrough line + expect(btn.querySelector("circle")).toBeTruthy(); + expect(btn.querySelectorAll("line")).toHaveLength(0); + }); + + it("renders the eye-off SVG (show icon) when revealed=true", () => { + render(); + const btn = screen.getByRole("button"); + // EyeOffIcon has a line (strikethrough) through the eye + expect(btn.querySelectorAll("line")).toHaveLength(1); + }); +}); diff --git a/canvas/src/components/ui/__tests__/ValidationHint.test.tsx b/canvas/src/components/ui/__tests__/ValidationHint.test.tsx new file mode 100644 index 00000000..a0a2144c --- /dev/null +++ b/canvas/src/components/ui/__tests__/ValidationHint.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +/** + * Tests for ValidationHint component. + * + * Covers: null/neutral render, error state (red ⚠ + message), valid state + * (green ✓ + "Valid format"), ARIA role="alert" on error. + */ +import { afterEach, describe, it, expect } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { ValidationHint } from "../ValidationHint"; + +afterEach(cleanup); + +describe("ValidationHint", () => { + it("renders nothing when error is null and showValid is false", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders nothing when error is null and showValid is undefined", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders error state with ⚠ icon and message", () => { + render(); + const el = screen.getByRole("alert"); + expect(el.textContent).toContain("⚠"); + expect(el.textContent).toContain("Key name must be UPPER_SNAKE_CASE"); + }); + + it("renders valid state with ✓ and 'Valid format'", () => { + render(); + const el = screen.getByText("Valid format"); + expect(el.textContent).toContain("✓"); + }); + + it("prefers error over valid when both are set (error is not null)", () => { + // ValidationHint checks error first; showValid is only rendered when error is falsy. + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.queryByText("Valid format")).toBeNull(); + }); + + it("error alert has role='alert' for screen readers", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + }); +}); -- 2.45.2 From 8724776e242cce9aa38802be780bd383add46285 Mon Sep 17 00:00:00 2001 From: Molecule AI App-FE Date: Tue, 12 May 2026 01:22:47 +0000 Subject: [PATCH 2/2] chore: retimestamp to retrigger CI -- 2.45.2