From 272da8ef5aefeca9c28ea720d626c11f6ef0b9e7 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Mon, 13 Jul 2026 11:48:37 +1000 Subject: [PATCH] Add visual theme matrix coverage (#111) --- docs/VISUAL_TESTING.md | 41 ++++++++++++ .../scripts/visual-regression-lib.test.ts | 33 ++++++++++ .../react/scripts/visual-regression-lib.ts | 35 ++++++++-- .../combobox-field.visual.test.tsx | 25 ++++++++ .../loading-spinner.visual.test.tsx | 64 ++++++++++++++++++- .../test-utils/render-visual.browser.test.tsx | 36 +++++++++++ .../react/src/test-utils/render-visual.tsx | 36 ++++++++++- 7 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx diff --git a/docs/VISUAL_TESTING.md b/docs/VISUAL_TESTING.md index 5a50255b..908206ae 100644 --- a/docs/VISUAL_TESTING.md +++ b/docs/VISUAL_TESTING.md @@ -49,3 +49,44 @@ tradeoff is that each comparison renders two revisions; the disposable base cach work. See [`TESTING.md`](./TESTING.md#visual-regression-tests) for how to write a visual test. + +## Test every theme and mode + +Migrated components use the shared appearance matrix for Machined edge and ELMO in explicit light +and dark modes. Pass each appearance to `renderVisual`, then capture it with +`captureVisualAppearance`: + +```tsx +import { test } from 'vite-plus/test'; +import { + captureVisualAppearance, + renderVisual, + visualAppearances, +} from '../test-utils/render-visual.js'; + +test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => { + const scene = renderVisual(, appearance); + + await captureVisualAppearance(scene, 'button/theme-matrix', appearance); +}); +``` + +The helper appends the selected appearance to the literal base ID. The example creates these stable +capture IDs: + +- `button/theme-matrix-machined-edge-light` +- `button/theme-matrix-machined-edge-dark` +- `button/theme-matrix-elmo-light` +- `button/theme-matrix-elmo-dark` + +Use one literal base ID for the matrix. The visual runner expands it during duplicate-ID validation, +so each look remains independently reviewable without repeating theme setup. Existing tests that +call `renderVisual(node)` continue to render in Machined edge light. + +Theme identity and colour mode stay separate. To cover nested mode, put `data-color-mode="dark"` or +`data-color-mode="light"` on a descendant inside the rendered scene. Do not add a nested theme +identity because identity classes are not nestable. + +For a portalled surface, render the real component with the selected appearance, open it through +`userEvent`, and capture the portal or `document.body`. The component carries the identity class and +explicit colour mode from its trigger. Do not copy theme classes onto a test-only portal wrapper. diff --git a/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts b/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts index f51fbece..08dbb34f 100644 --- a/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts +++ b/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts @@ -54,3 +54,36 @@ test('accepts multiline calls with trailing commas', async () => { const owners = await validateCaptureIds(root); expect([...owners.keys()]).toEqual(['button/multiline']); }); + +test('expands appearance capture IDs and rejects collisions with explicit captures', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'visual-ids-')); + await writeFile( + path.join(root, 'matrix.visual.test.tsx'), + "captureVisualAppearance(scene, 'button/tones', appearance)", + ); + await writeFile( + path.join(root, 'single.visual.test.tsx'), + "captureVisual(scene, 'button/tones-elmo-dark')", + ); + + await expect(validateCaptureIds(root)).rejects.toThrow( + 'Duplicate visual capture ID button/tones-elmo-dark', + ); +}); + +test('registers every independently named appearance capture', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'visual-ids-')); + await writeFile( + path.join(root, 'matrix.visual.test.tsx'), + "captureVisualAppearance(scene, 'button/tones', appearance)", + ); + + const owners = await validateCaptureIds(root); + + expect([...owners.keys()]).toEqual([ + 'button/tones-machined-edge-light', + 'button/tones-machined-edge-dark', + 'button/tones-elmo-light', + 'button/tones-elmo-dark', + ]); +}); diff --git a/packages/@luke-ui/react/scripts/visual-regression-lib.ts b/packages/@luke-ui/react/scripts/visual-regression-lib.ts index ac50f974..d6d818b1 100644 --- a/packages/@luke-ui/react/scripts/visual-regression-lib.ts +++ b/packages/@luke-ui/react/scripts/visual-regression-lib.ts @@ -17,6 +17,13 @@ export type VisualResult = { currentViewport?: string; }; +const appearanceSuffixes = [ + 'machined-edge-light', + 'machined-edge-dark', + 'elmo-light', + 'elmo-dark', +] as const; + type CaptureFile = { file: string; viewport?: string }; export async function validateCaptureIds(sourceRoot: string) { @@ -33,14 +40,19 @@ export async function validateCaptureIds(sourceRoot: string) { if (calls.length !== matches.length) { throw new Error(`Visual capture IDs must be string literals: ${file}`); } - for (const match of matches) { - const id = match[1]; - if (!id || !/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { - throw new Error(`Visual capture ID must be namespaced: ${id}`); + for (const match of matches) registerCaptureId(match[1], file, owners); + + const appearanceCalls = [...source.matchAll(/captureVisualAppearance\(/g)]; + const appearanceMatches = [ + ...source.matchAll(/captureVisualAppearance\([\s\S]*?,\s*'([^']+)'\s*,/g), + ]; + if (appearanceCalls.length !== appearanceMatches.length) { + throw new Error(`Visual appearance capture IDs must be string literals: ${file}`); + } + for (const match of appearanceMatches) { + for (const suffix of appearanceSuffixes) { + registerCaptureId(`${match[1]}-${suffix}`, file, owners); } - const owner = owners.get(id); - if (owner) throw new Error(`Duplicate visual capture ID ${id}: ${owner} and ${file}`); - owners.set(id, file); } } }), @@ -50,6 +62,15 @@ export async function validateCaptureIds(sourceRoot: string) { return owners; } +function registerCaptureId(id: string | undefined, file: string, owners: Map) { + if (!id || !/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { + throw new Error(`Visual capture ID must be namespaced: ${id}`); + } + const owner = owners.get(id); + if (owner) throw new Error(`Duplicate visual capture ID ${id}: ${owner} and ${file}`); + owners.set(id, file); +} + async function listPngs(root: string) { const result = new Map(); async function visit(directory: string) { diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx index ee92c57d..fbaf02a2 100644 --- a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx @@ -1,6 +1,7 @@ import { expect, test } from 'vite-plus/test'; import { page, userEvent } from 'vite-plus/test/context'; import { captureVisual, renderVisual, Stack } from '../test-utils/render-visual.js'; +import { elmoThemeClassName } from '../themes/index.js'; import { ComboboxField } from './index.js'; import { ComboboxItem } from './primitive/index.js'; @@ -63,6 +64,30 @@ test('open menu', async () => { await captureVisual(page.elementLocator(document.body), 'combobox-field/open'); }); +test('open menu carries the selected theme and mode through its portal', async () => { + renderVisual( + + + {renderCountryItem} + + , + { mode: 'dark', theme: 'elmo' }, + ); + + await userEvent.click(page.getByRole('combobox', { name: 'Themed country' })); + const listbox = page.getByRole('listbox'); + const portal = listbox.element().closest('[data-color-mode]'); + + expect(portal).toHaveClass(elmoThemeClassName); + expect(portal).toHaveAttribute('data-color-mode', 'dark'); + await captureVisual(page.elementLocator(document.body), 'combobox-field/open-elmo-dark'); +}); + test('mobile tray', async () => { renderVisual( diff --git a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx index c01f2534..26720b2e 100644 --- a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx @@ -1,11 +1,14 @@ -import type { CSSProperties } from 'react'; -import { test } from 'vite-plus/test'; +import type { CSSProperties, ReactNode } from 'react'; +import { expect, test } from 'vite-plus/test'; import { + captureVisualAppearance, captureVisual, renderVisual, Stack, variantValuesFor, + visualAppearances, } from '../test-utils/render-visual.js'; +import { vars } from '../theme/index.js'; import { LoadingSpinner } from './index.js'; const rowStyle = { @@ -43,3 +46,60 @@ test('sizes colors and modes', async () => { await captureVisual(locator, 'loading-spinner/sizes-colors-modes'); }); + +test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => { + const oppositeMode = appearance.mode === 'light' ? 'dark' : 'light'; + const scene = renderVisual( +
+ + + + + + +
, + appearance, + ); + + await expect.element(scene).toHaveAttribute('data-color-mode', appearance.mode); + await captureVisualAppearance(scene, 'loading-spinner/theme-matrix', appearance); +}); + +const themeMatrixStyle = { + backgroundColor: vars.color.surface.canvas, + display: 'flex', + gap: '1rem', + padding: '1rem', +} satisfies CSSProperties; + +const spinnerStyle = { + color: vars.color.intent.accent.text, +} satisfies CSSProperties; + +function ThemeMatrixScope({ + children, + label, + mode, +}: { + children: ReactNode; + label: string; + mode?: 'light' | 'dark'; +}) { + return ( +
+ {children} + {label} +
+ ); +} diff --git a/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx b/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx new file mode 100644 index 00000000..c81c617e --- /dev/null +++ b/packages/@luke-ui/react/src/test-utils/render-visual.browser.test.tsx @@ -0,0 +1,36 @@ +import { expect, test } from 'vite-plus/test'; +import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js'; +import { cleanupVisual, renderVisual, visualAppearances } from './render-visual.js'; + +test('renders every bundled identity and explicit colour mode independently', () => { + for (const appearance of visualAppearances) { + const scene = renderVisual(Theme contract, appearance); + const root = scene.element(); + + expect(root).toHaveClass( + appearance.theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName, + ); + expect(root).toHaveAttribute('data-color-mode', appearance.mode); + expect(getComputedStyle(root).colorScheme).toBe(appearance.mode); + + cleanupVisual(); + } +}); + +test('defaults existing callers to Machined edge light', () => { + const root = renderVisual(Default contract).element(); + + expect(root).toHaveClass(machinedEdgeThemeClassName); + expect(root).toHaveAttribute('data-color-mode', 'light'); +}); + +test('allows a nested scope to select the opposite colour mode', () => { + const scene = renderVisual(
Nested contract
, { + mode: 'dark', + theme: 'elmo', + }); + const nestedScope = scene.getByText('Nested contract').element(); + + expect(getComputedStyle(scene.element()).colorScheme).toBe('dark'); + expect(getComputedStyle(nestedScope).colorScheme).toBe('light'); +}); diff --git a/packages/@luke-ui/react/src/test-utils/render-visual.tsx b/packages/@luke-ui/react/src/test-utils/render-visual.tsx index 76819fb5..cd83a8bd 100644 --- a/packages/@luke-ui/react/src/test-utils/render-visual.tsx +++ b/packages/@luke-ui/react/src/test-utils/render-visual.tsx @@ -2,6 +2,8 @@ // Loads the design-token stylesheet into the test document. import '../stylesheet.css.js'; +import '@luke-ui/react/themes/elmo.css'; +import '@luke-ui/react/themes/machined-edge.css'; import type { ComponentProps, ComponentType, CSSProperties, ReactNode } from 'react'; import { act } from 'react'; import type { Root } from 'react-dom/client'; @@ -14,19 +16,36 @@ import { page, userEvent } from 'vite-plus/test/context'; import spritesheetHref from '../../dist/spritesheet.svg?url'; import { IconSpritesheetProvider } from '../icon/index.js'; import { themeRootClassName } from '../theme/index.js'; +import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js'; +import { cx } from '../utils/index.js'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const mounted: Array<{ container: HTMLElement; root: Root }> = []; +export type VisualAppearance = { + mode: 'light' | 'dark'; + theme: 'machined-edge' | 'elmo'; +}; + +export const visualAppearances = [ + { mode: 'light', theme: 'machined-edge' }, + { mode: 'dark', theme: 'machined-edge' }, + { mode: 'light', theme: 'elmo' }, + { mode: 'dark', theme: 'elmo' }, +] as const satisfies ReadonlyArray; + +const defaultVisualAppearance: VisualAppearance = visualAppearances[0]; + /** * Renders `node` inside the same theme root and icon spritesheet provider the * app (and Storybook) wrap components with, then returns a Vitest locator for * the mounted subtree ready to pass to `captureVisual`. */ -export function renderVisual(node: ReactNode) { +export function renderVisual(node: ReactNode, appearance = defaultVisualAppearance) { const container = document.body.appendChild(document.createElement('div')); - container.className = themeRootClassName; + container.className = cx(themeRootClassName, getThemeClassName(appearance.theme)); + container.dataset.colorMode = appearance.mode; const root = createRoot(container); mounted.push({ container, root }); @@ -37,6 +56,10 @@ export function renderVisual(node: ReactNode) { return page.elementLocator(container); } +function getThemeClassName(theme: VisualAppearance['theme']) { + return theme === 'machined-edge' ? machinedEdgeThemeClassName : elmoThemeClassName; +} + /** Captures a named scene into the revision output selected by the visual runner. */ export async function captureVisual(locator: Locator, id: string) { if (!/^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { @@ -46,6 +69,15 @@ export async function captureVisual(locator: Locator, id: string) { await expect.element(locator).toMatchScreenshot(`${id}__viewport-${viewport}`); } +/** Captures one look with a stable identity-and-mode suffix added to `id`. */ +export async function captureVisualAppearance( + locator: Locator, + id: string, + appearance: VisualAppearance, +) { + await captureVisual(locator, `${id}-${appearance.theme}-${appearance.mode}`); +} + /** Unmounts everything rendered by `renderVisual`. Registered globally in `visual-setup.ts`. */ export function cleanupVisual() { for (const { container, root } of mounted) { -- 2.51.2