From 0151cbffa293fcff96754fa34255d5d14dd318bd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 27 Sep 2026 12:08:17 -0700 Subject: [PATCH] test: drop class-string tests covered by browser specs Twelve unit test files mostly asserted class names and markup. The e2e specs and the axe sweep cover their behavior; motion.spec.ts adds the reduced-motion, forced-colors and paused checks. --- e2e/fixtures.tsx | 5 ++ e2e/motion.spec.ts | 51 ++++++++++++++++ src/blocks/login/auth-layout.test.tsx | 48 ---------------- src/icon-tokens.test.ts | 37 ------------ src/ui/block-card.test.tsx | 83 --------------------------- src/ui/button.test.tsx | 64 --------------------- src/ui/dictation-status-line.test.tsx | 37 ------------ src/ui/filter-chip.test.tsx | 46 --------------- src/ui/rich-empty-state.test.tsx | 56 ------------------ src/ui/select.test.tsx | 80 -------------------------- src/ui/shimmer-text.test.tsx | 20 ------- src/ui/sidebar-item-row.test.tsx | 56 ------------------ src/ui/thinking-indicator.test.tsx | 43 -------------- src/ui/voice-waveform.test.tsx | 14 ----- 14 files changed, 56 insertions(+), 584 deletions(-) create mode 100644 e2e/motion.spec.ts delete mode 100644 src/blocks/login/auth-layout.test.tsx delete mode 100644 src/icon-tokens.test.ts delete mode 100644 src/ui/block-card.test.tsx delete mode 100644 src/ui/button.test.tsx delete mode 100644 src/ui/dictation-status-line.test.tsx delete mode 100644 src/ui/filter-chip.test.tsx delete mode 100644 src/ui/rich-empty-state.test.tsx delete mode 100644 src/ui/select.test.tsx delete mode 100644 src/ui/shimmer-text.test.tsx delete mode 100644 src/ui/sidebar-item-row.test.tsx delete mode 100644 src/ui/thinking-indicator.test.tsx delete mode 100644 src/ui/voice-waveform.test.tsx diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 6f8ae994..c343ae2f 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -9,8 +9,10 @@ import { DictationStatusLine, FilterChip, Select, + ShimmerText, ThemeProvider, ThemeToggle, + ThinkingMark, type ApprovalRequest, type ChatMessage, } from "@corbits/react-ui"; @@ -189,6 +191,9 @@ export const scenarios: Record ReactNode> = { Form ), + shimmer: () => Thinking, + "thinking-mark": () => , + "thinking-mark-paused": () => , theme: () => , "theme-toggle": () => ( diff --git a/e2e/motion.spec.ts b/e2e/motion.spec.ts new file mode 100644 index 00000000..3160437d --- /dev/null +++ b/e2e/motion.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +const color = (page: import("@playwright/test").Page) => + page.getByText("Thinking").evaluate((node) => getComputedStyle(node).color); + +test("ShimmerText clips a gradient by default", async ({ page }) => { + await page.goto("/shimmer"); + expect(await color(page)).toBe("rgba(0, 0, 0, 0)"); +}); + +test("ShimmerText falls back to solid text under reduced motion", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/shimmer"); + expect(await color(page)).not.toBe("rgba(0, 0, 0, 0)"); +}); + +test("ShimmerText falls back to solid text under forced colors", async ({ + page, +}) => { + await page.emulateMedia({ forcedColors: "active" }); + await page.goto("/shimmer"); + expect(await color(page)).not.toBe("rgba(0, 0, 0, 0)"); +}); + +test("ThinkingMark echo drops its second wave under reduced motion", async ({ + page, +}) => { + await page.goto("/thinking-mark"); + const displays = () => + page + .locator(".corbits-thinking-echo") + .evaluateAll((nodes) => + nodes.map((node) => getComputedStyle(node).display), + ); + expect(await displays()).toEqual(["inline", "inline"]); + await page.emulateMedia({ reducedMotion: "reduce" }); + expect(await displays()).toEqual(["inline", "none"]); +}); + +test("paused ThinkingMark freezes every animation", async ({ page }) => { + await page.goto("/thinking-mark-paused"); + const states = await page + .locator("svg *") + .evaluateAll((nodes) => + nodes.map((node) => getComputedStyle(node).animationPlayState), + ); + expect(states.length).toBeGreaterThan(0); + expect(new Set(states)).toEqual(new Set(["paused"])); +}); diff --git a/src/blocks/login/auth-layout.test.tsx b/src/blocks/login/auth-layout.test.tsx deleted file mode 100644 index 09aa16b1..00000000 --- a/src/blocks/login/auth-layout.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, test } from "bun:test"; - -import { AuthLayout } from "./auth-layout.js"; - -type Mounted = { - container: HTMLElement; - unmount: () => void; -}; - -function render(node: React.ReactElement): Mounted { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render(node); - }); - return { - container, - unmount: () => { - act(() => root.unmount()); - container.remove(); - }, - }; -} - -describe("AuthLayout panel slot", () => { - test("renders nothing decorative when panel is omitted", () => { - const mounted = render(Form); - const panel = mounted.container.querySelector('[aria-hidden="true"]'); - expect(panel?.textContent).toBe(""); - mounted.unmount(); - }); - - test("renders whatever node is passed as the panel", () => { - const mounted = render( - custom}> - Form - , - ); - expect( - mounted.container.querySelector('[data-testid="custom-panel"]'), - ).not.toBeNull(); - mounted.unmount(); - }); -}); diff --git a/src/icon-tokens.test.ts b/src/icon-tokens.test.ts deleted file mode 100644 index 8adb8c97..00000000 --- a/src/icon-tokens.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -const theme = readFileSync(join(import.meta.dir, "./theme.css"), "utf-8"); - -// One named icon-size scale, matching the owner's mock CSS 1:1, so call -// sites stop inventing their own px/rem sizes (or a `size=` prop) per spot. -describe("icon-size tokens", () => { - test("defines exactly the four named scale steps", () => { - expect(theme).toMatch(/--icon-size-nav:\s*0\.9375rem/); - expect(theme).toMatch(/--icon-size-topbar:\s*1rem/); - expect(theme).toMatch(/--icon-size-button:\s*0\.8125rem/); - expect(theme).toMatch(/--icon-size-checkbox:\s*0\.625rem/); - }); - - test("declares the tokens once, on :root, not inside a dark-mode block", () => { - const rootBlock = theme.slice( - theme.indexOf(":root {"), - theme.indexOf(":root {") + - theme.slice(theme.indexOf(":root {")).indexOf("\n}"), - ); - expect(rootBlock).toContain("--icon-size-nav"); - expect(rootBlock).toContain("--icon-size-topbar"); - expect(rootBlock).toContain("--icon-size-button"); - expect(rootBlock).toContain("--icon-size-checkbox"); - }); - - test("re-exposes each step under Tailwind's font-size namespace", () => { - expect(theme).toMatch(/--text-icon-nav:\s*var\(--icon-size-nav\)/); - expect(theme).toMatch(/--text-icon-topbar:\s*var\(--icon-size-topbar\)/); - expect(theme).toMatch(/--text-icon-button:\s*var\(--icon-size-button\)/); - expect(theme).toMatch( - /--text-icon-checkbox:\s*var\(--icon-size-checkbox\)/, - ); - }); -}); diff --git a/src/ui/block-card.test.tsx b/src/ui/block-card.test.tsx deleted file mode 100644 index f0be0af9..00000000 --- a/src/ui/block-card.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, test } from "bun:test"; - -import { BlockCard, RiskBadge } from "./block-card.js"; - -type Mounted = { - container: HTMLElement; - unmount: () => void; -}; - -function render(node: React.ReactElement): Mounted { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render(node); - }); - return { - container, - unmount: () => { - act(() => root.unmount()); - container.remove(); - }, - }; -} - -describe("BlockCard", () => { - test("renders zero-radius bordered frame with title and body", () => { - const mounted = render( - Body content, - ); - const card = mounted.container.querySelector("[data-slot='block-card']"); - expect(card?.getAttribute("class") ?? "").toContain("rounded-none"); - expect( - mounted.container.querySelector("[data-slot='block-card-title']") - ?.textContent, - ).toBe("Cancel order"); - expect(mounted.container.textContent).toContain("Body content"); - mounted.unmount(); - }); - - test("header pulse dot is decorative and not announced twice", () => { - const mounted = render(Body); - const header = mounted.container.querySelector( - "[data-slot='block-card-header']", - ); - const dot = header?.firstElementChild; - expect(dot?.getAttribute("aria-hidden")).toBe("true"); - mounted.unmount(); - }); -}); - -describe("RiskBadge", () => { - test.each([ - ["low", "text-muted-foreground"], - ["medium", "text-warn"], - ["high", "text-destructive"], - ] as const)("level %s carries its own tone class", (level, toneClass) => { - const mounted = render(); - const badge = mounted.container.querySelector("[data-slot='risk-badge']"); - expect(badge?.getAttribute("data-risk")).toBe(level); - expect(badge?.getAttribute("class") ?? "").toContain(toneClass); - mounted.unmount(); - }); - - test("renders the label as real text, and an optional note alongside it", () => { - const mounted = render( - , - ); - expect(mounted.container.textContent).toContain("High risk"); - expect(mounted.container.textContent).toContain("No undo"); - mounted.unmount(); - }); - - test("omits the note span when not provided", () => { - const mounted = render(); - const badge = mounted.container.querySelector("[data-slot='risk-badge']"); - expect(badge?.children.length).toBe(2); - mounted.unmount(); - }); -}); diff --git a/src/ui/button.test.tsx b/src/ui/button.test.tsx deleted file mode 100644 index e7fb72f4..00000000 --- a/src/ui/button.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, test } from "bun:test"; - -import { Button, buttonVariants } from "./button.js"; - -type Mounted = { - container: HTMLElement; - unmount: () => void; -}; - -function render(node: React.ReactElement): Mounted { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render(node); - }); - return { - container, - unmount: () => { - act(() => root.unmount()); - container.remove(); - }, - }; -} - -describe("Button link variant", () => { - test("carries the underline treatment and no fill or border", () => { - const mounted = render(); - const className = - mounted.container.querySelector("button")?.getAttribute("class") ?? ""; - expect(className).toContain("text-primary-emphasis"); - expect(className).toContain("hover:underline"); - expect(className).not.toContain("bg-primary"); - expect(className).not.toContain("border-input"); - mounted.unmount(); - }); - - // Orange as fill (bg-primary/text-primary-foreground) clears AA; orange as - // *text* on the page ground does not (#e98428 on white is 2.69:1) — only - // --primary-emphasis is calibrated for that use. A bare `text-primary` token - // on the link variant is exactly the class of bug the pair-based contrast - // gate can't see, since it only checks declared token pairs, not which - // token an unrelated variant happens to reach for. - test("never emits raw text-primary, only text-primary-emphasis", () => { - const className = buttonVariants({ variant: "link" }); - const tokens = className.split(/\s+/); - expect(tokens).not.toContain("text-primary"); - expect(tokens).toContain("text-primary-emphasis"); - }); - - // link reads as inline text, not a control — every other variant gets a - // fixed h-*/px-* box from its size, link must opt out regardless of which - // size prop is passed (including the implicit md default). - test("carries no fixed height or horizontal padding classes, at every size", () => { - for (const size of [undefined, "sm", "md", "lg", "icon"] as const) { - const tokens = buttonVariants({ variant: "link", size }).split(/\s+/); - expect(tokens.some((token) => /^h-(?!auto)/.test(token))).toBe(false); - expect(tokens.some((token) => /^px-(?!0$)/.test(token))).toBe(false); - } - }); -}); diff --git a/src/ui/dictation-status-line.test.tsx b/src/ui/dictation-status-line.test.tsx deleted file mode 100644 index e3d8c8e7..00000000 --- a/src/ui/dictation-status-line.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { DictationStatusLine } from "./dictation-status-line.js"; - -describe("DictationStatusLine", () => { - test("default starting copy is short — no permission lecture", () => { - const html = renderToStaticMarkup( - createElement(DictationStatusLine, { state: "starting" }), - ); - expect(html).toContain("Starting microphone…"); - expect(html).not.toContain("If asked"); - expect(html).not.toContain("allow it"); - }); - - test("listening waveform uses a text-rated token, never a fill token", () => { - const html = renderToStaticMarkup( - createElement(DictationStatusLine, { - state: "listening", - levels: [0.4, 0.8], - }), - ); - expect(html).toContain("text-ok"); - expect(html).not.toContain("text-success"); - expect(html).not.toContain("text-secondary"); - expect(html).not.toContain("text-accent"); - }); - - test("denied without an error message still announces the refusal", () => { - const html = renderToStaticMarkup( - createElement(DictationStatusLine, { state: "denied" }), - ); - expect(html).toContain('role="alert"'); - expect(html).toContain("Microphone access was blocked."); - }); -}); diff --git a/src/ui/filter-chip.test.tsx b/src/ui/filter-chip.test.tsx deleted file mode 100644 index 5f3e840c..00000000 --- a/src/ui/filter-chip.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, test } from "bun:test"; - -import { FilterChip } from "./filter-chip.js"; - -type Mounted = { - container: HTMLElement; - unmount: () => void; -}; - -function render(node: React.ReactElement): Mounted { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render(node); - }); - return { - container, - unmount: () => { - act(() => root.unmount()); - container.remove(); - }, - }; -} - -describe("FilterChip unselected affordance", () => { - test("unselected renders a real border, not a bare-text button", () => { - const mounted = render(Live); - const button = mounted.container.querySelector("button"); - const className = button?.getAttribute("class") ?? ""; - expect(className).toContain("border-border"); - expect(className).not.toContain("border-transparent"); - mounted.unmount(); - }); - - test("selected keeps its own card treatment", () => { - const mounted = render(Live); - const button = mounted.container.querySelector("button"); - expect(button?.getAttribute("aria-pressed")).toBe("true"); - expect(button?.getAttribute("class") ?? "").toContain("bg-card"); - mounted.unmount(); - }); -}); diff --git a/src/ui/rich-empty-state.test.tsx b/src/ui/rich-empty-state.test.tsx deleted file mode 100644 index 3867ac36..00000000 --- a/src/ui/rich-empty-state.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, test } from "bun:test"; - -import { RichEmptyState } from "./rich-empty-state.js"; - -type Mounted = { - container: HTMLElement; - unmount: () => void; -}; - -function render(node: React.ReactElement): Mounted { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render(node); - }); - return { - container, - unmount: () => { - act(() => root.unmount()); - container.remove(); - }, - }; -} - -describe("RichEmptyState action size", () => { - test("defaults its action button to size md", () => { - const mounted = render( - , - ); - const button = mounted.container.querySelector("[data-slot='button']"); - expect(button?.getAttribute("class") ?? "").toContain("h-9"); - mounted.unmount(); - }); - - test("actionSize='sm' shrinks the action button to match denser chrome", () => { - const mounted = render( - , - ); - const button = mounted.container.querySelector("[data-slot='button']"); - expect(button?.getAttribute("class") ?? "").toContain("h-8"); - mounted.unmount(); - }); -}); diff --git a/src/ui/select.test.tsx b/src/ui/select.test.tsx deleted file mode 100644 index e192fafe..00000000 --- a/src/ui/select.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { act, createElement } from "react"; -import type { ComponentProps } from "react"; -import { createRoot } from "react-dom/client"; - -import { Select } from "./select.js"; - -function mount(props: ComponentProps) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - act(() => { - root.render(createElement(Select, props)); - }); - return { - select: () => container.querySelector("select") as HTMLSelectElement, - unmount: () => root.unmount(), - }; -} - -const options = createElement("option", { value: "a" }, "A"); - -describe("Select", () => { - test("renders a native select carrying passed-through props", () => { - const { select, unmount } = mount({ - value: "a", - onChange: () => {}, - disabled: false, - children: options, - }); - expect(select().tagName).toBe("SELECT"); - expect(select().value).toBe("a"); - unmount(); - }); - - test("disabled disables the underlying control", () => { - const { select, unmount } = mount({ - value: "a", - onChange: () => {}, - disabled: true, - children: options, - }); - expect(select().disabled).toBe(true); - unmount(); - }); - - test("aria-invalid and aria-describedby pass through to the control", () => { - const { select, unmount } = mount({ - value: "a", - onChange: () => {}, - "aria-invalid": true, - "aria-describedby": "field-error", - children: options, - }); - expect(select().getAttribute("aria-invalid")).toBe("true"); - expect(select().getAttribute("aria-describedby")).toBe("field-error"); - unmount(); - }); - - test("onChange fires when the selection changes", () => { - let received: string | undefined; - const { select, unmount } = mount({ - value: "a", - onChange: (event) => { - received = event.target.value; - }, - children: [ - createElement("option", { key: "a", value: "a" }, "A"), - createElement("option", { key: "b", value: "b" }, "B"), - ], - }); - const node = select(); - act(() => { - node.value = "b"; - node.dispatchEvent(new Event("change", { bubbles: true })); - }); - expect(received).toBe("b"); - unmount(); - }); -}); diff --git a/src/ui/shimmer-text.test.tsx b/src/ui/shimmer-text.test.tsx deleted file mode 100644 index c54ea2bd..00000000 --- a/src/ui/shimmer-text.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { ShimmerText } from "./shimmer-text.js"; - -describe("ShimmerText", () => { - test("reduced-motion and forced-colors fall back to solid muted text without clip", () => { - const html = renderToStaticMarkup( - createElement(ShimmerText, { children: "Thinking" }), - ); - expect(html).toContain("motion-reduce:text-muted-foreground"); - expect(html).toContain("motion-reduce:bg-none"); - expect(html).toContain("motion-reduce:bg-clip-border"); - expect(html).toContain("forced-colors:text-muted-foreground"); - expect(html).toContain("forced-colors:bg-none"); - expect(html).toContain("forced-colors:bg-clip-border"); - expect(html).toContain("text-transparent"); - }); -}); diff --git a/src/ui/sidebar-item-row.test.tsx b/src/ui/sidebar-item-row.test.tsx deleted file mode 100644 index 8581a2ba..00000000 --- a/src/ui/sidebar-item-row.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, test } from "bun:test"; - -import { SidebarItemRow } from "./sidebar-item-row.js"; - -type Mounted = { - container: HTMLElement; - unmount: () => void; -}; - -function render(node: React.ReactElement): Mounted { - const container = document.createElement("ul"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render(node); - }); - return { - container, - unmount: () => { - act(() => root.unmount()); - container.remove(); - }, - }; -} - -describe("SidebarItemRow theming tokens", () => { - test("selected row's background reads the --sidebar-row-selected-bg token, not a hardcoded utility", () => { - const mounted = render(); - const row = mounted.container.querySelector( - "[data-slot='sidebar-item-row'] > div", - ); - const className = row?.getAttribute("class") ?? ""; - expect(className).toContain("bg-[var(--sidebar-row-selected-bg)]"); - expect(className).not.toContain("bg-primary/10"); - mounted.unmount(); - }); - - test("row and its button read the --sidebar-row-radius token, not a hardcoded rounded-md", () => { - const mounted = render(); - const row = mounted.container.querySelector( - "[data-slot='sidebar-item-row'] > div", - ); - const button = mounted.container.querySelector("button"); - expect(row?.getAttribute("class") ?? "").toContain( - "rounded-[var(--sidebar-row-radius)]", - ); - expect(button?.getAttribute("class") ?? "").toContain( - "rounded-[var(--sidebar-row-radius)]", - ); - expect(row?.getAttribute("class") ?? "").not.toContain("rounded-md"); - mounted.unmount(); - }); -}); diff --git a/src/ui/thinking-indicator.test.tsx b/src/ui/thinking-indicator.test.tsx deleted file mode 100644 index 6e637a97..00000000 --- a/src/ui/thinking-indicator.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { ThinkingIndicator } from "./thinking-indicator.js"; -import { ThinkingMark } from "./thinking-mark.js"; - -describe("ThinkingIndicator", () => { - test("renders an accessible status with a decorative Silk mark", () => { - const html = renderToStaticMarkup(createElement(ThinkingIndicator)); - - expect(html).toContain('role="status"'); - expect(html).toContain("Thinking..."); - expect(html).toContain('aria-hidden="true"'); - expect(html).toContain("corbits-thinking-silk"); - }); - - test("renders every motion variant", () => { - for (const variant of ["silk", "strata", "echo"] as const) { - const html = renderToStaticMarkup( - createElement(ThinkingMark, { variant }), - ); - - expect(html).toContain(`corbits-thinking-${variant}`); - } - }); - - test("paused freezes every animated node", () => { - const html = renderToStaticMarkup( - createElement(ThinkingMark, { paused: true }), - ); - - expect(html).toContain("animation-play-state:paused"); - }); - - test("echo hides its second wave under reduced motion", () => { - const html = renderToStaticMarkup( - createElement(ThinkingMark, { variant: "echo" }), - ); - - expect(html).toContain("motion-reduce:hidden"); - }); -}); diff --git a/src/ui/voice-waveform.test.tsx b/src/ui/voice-waveform.test.tsx deleted file mode 100644 index 238dad7d..00000000 --- a/src/ui/voice-waveform.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { VoiceWaveform } from "./voice-waveform.js"; - -describe("VoiceWaveform", () => { - test("is aria-hidden because DictationStatusLine speaks the state", () => { - const html = renderToStaticMarkup( - createElement(VoiceWaveform, { levels: [0.2, 0.8] }), - ); - expect(html).toContain("aria-hidden"); - }); -});