/** * Mounting helpers for component tests. * * Components are mounted into a real document and queried through their * real (open) shadow roots — nothing here approximates the DOM, which * is the whole reason this tier runs in a browser. */ import type { LitElement } from 'lit'; import { expect } from 'vitest'; const mounted: HTMLElement[] = []; /** * Create an element, apply properties, mount it and wait for it to * settle. Properties are set as *properties*, not attributes, so * non-string values survive. * * "Settle" drains the microtask queue between two renders rather than * awaiting one render. A component that loads from the backend in * `firstUpdated` is only renderable once that call resolves, and under * v3 a binding takes several microtasks longer to settle than v2's * did: the generated function goes through `Call()`, an async * `runtimeCallWithID`, the transport and a `CancellablePromise`, where * v2's `window.go` proxy resolved one promise. Tests were already * written as though `fixture()` meant "mounted and loaded" — it now * does, rather than each of them counting ticks. * * Microtasks, deliberately, and not `setTimeout`: every hop in that * chain is a promise, and a timer would hang forever in the suites * that install fake ones. */ export async function fixture( tag: string, props: Record = {}, ): Promise { const el = document.createElement(tag) as T; Object.assign(el, props); document.body.append(el); mounted.push(el); await el.updateComplete; for (let hop = 0; hop < MICROTASK_HOPS; hop += 1) { await Promise.resolve(); } await el.updateComplete; return el; } /** * How many promise hops to drain before the second render. Generously * over the ~6 a binding actually takes, since an unused hop costs * nothing and a missing one is a flake. */ const MICROTASK_HOPS = 20; /** Apply properties to a mounted element and wait for the re-render. */ export async function update( el: T, props: Record, ): Promise { Object.assign(el, props); el.requestUpdate(); await el.updateComplete; return el; } /** Remove everything mounted by this module. Called from setup. */ export function cleanupFixtures(): void { while (mounted.length > 0) mounted.pop()?.remove(); } // =================================================================== // SHADOW DOM QUERIES // =================================================================== /** Query one element inside a component's shadow root. */ export function shadow( host: Element, selector: string, ): E | null { return host.shadowRoot?.querySelector(selector) ?? null; } /** Query all matching elements inside a component's shadow root. */ export function shadowAll( host: Element, selector: string, ): E[] { return [...(host.shadowRoot?.querySelectorAll(selector) ?? [])]; } /** * Query through nested shadow roots. * * A component that composes another component is still one thing to the * user, and to Playwright — `shadow()` stops at the first boundary, * which makes an assertion depend on which component happens to own the * markup today. */ export function deepShadow( root: Element, selector: string, ): E | null { const queue: Array = [root.shadowRoot ?? root]; while (queue.length > 0) { const node = queue.shift()!; const hit = node.querySelector(selector); if (hit) return hit; for (const el of node.querySelectorAll('*')) { if (el.shadowRoot) queue.push(el.shadowRoot); } } return null; } /** Trimmed text content of the first deep match, or null if absent. */ export function deepText(host: Element, selector: string): string | null { return deepShadow(host, selector)?.textContent?.trim() ?? null; } /** Trimmed text content of the first match, or null if absent. */ export function text(host: Element, selector: string): string | null { return shadow(host, selector)?.textContent?.trim() ?? null; } /** Trimmed text content of every match. */ export function texts(host: Element, selector: string): string[] { return shadowAll(host, selector).map((el) => el.textContent?.trim() ?? ''); } /** The accessible names of every match, for assertions that mirror * what a screen reader — and a Playwright selector — would see. */ export function labels(host: Element, selector: string): string[] { return shadowAll(host, selector).map( (el) => el.getAttribute('aria-label') ?? '', ); } /** Click something inside a shadow root and let the update settle. */ export async function click( host: LitElement, selector: string, ): Promise { const target = shadow(host, selector); if (!target) throw new Error(`no element matching ${selector}`); target.click(); await host.updateComplete; } // =================================================================== // VISUAL REGRESSION // =================================================================== /** * Visual regression is opt-in: `toMatchScreenshot` baselines depend on * font hinting and compositing, so a baseline taken on one machine * fails on another for reasons that have nothing to do with the * component. `make ui-visual` sets YJ_VISUAL=1; the default run * asserts behaviour only. */ export const visualEnabled = import.meta.env['YJ_VISUAL'] === '1'; /** * Screenshot a component against its baseline, when visual regression * is enabled. A no-op otherwise — deliberately not a skipped test, so * the behavioural assertions around it still run. */ export async function visual(el: Element, name: string): Promise { if (!visualEnabled) return; await expect(el).toMatchScreenshot(name); }