v2 installed two globals and the fake replaced both. v3 has neither — the runtime is an npm module and the generated bindings call into it. What it has instead is better: setTransport() is a public seam for replacing the IPC transport, and *every* runtime call goes through it, so the fake is smaller than v2's and covers strictly more. The event dispatcher is no longer mirrored at all. v2's fake reimplemented desktop/events.js — the listener list, maxCallbacks expiry, the reverse iteration — because there was no way to reach the real one; emit() now goes through window._wails.dispatchWailsEvent, which is the entry point the backend's own push uses. What is mirrored instead is one line of Go: how EventManager.Emit packs variadic data into an event's single data field. Registration and unregistration are the public Events API. The one non-public thing left is the listener registry, aliased in vitest.config.mts and used only by listenerNames() — a test asks whether importing a store subscribed it, which nothing public can answer. A binding carries a method ID, not a name, so the fake derives the ID -> path map from the generated tree: FNV-1a over the FQN, with the Go type's casing recovered from each package's index.ts, which is the only place it survives (library/library.ts cannot tell you it is FrontendUtil). The map has to be complete rather than lazy because 21 assertions read calls() with no argument and compare the whole list. Two things had to move that are not the fake. fixture() drains microtasks between two renders: a v3 binding settles several hops later than v2's, and tests were already written as though fixture() meant "mounted and loaded". Microtasks and not a timer, which would hang under the suites that install fake ones. tracklist-store keeps its defaults on an empty answer instead of emptying the column list. GetTrackListColumns substitutes DefaultColumns only when the whole config section is missing; a section that exists with no columns returns nothing. Until now this was accidental — the binding was typed Column[], an absent answer arrived as undefined, and .map threw into the catch. 757 tests pass across all 63 files. They are run in batches: a single browser session dies partway through the 58 it queues, which reproduces unchanged at the pre-migration commit and is a resource limit on this machine rather than anything here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
184 lines
5.7 KiB
TypeScript
184 lines
5.7 KiB
TypeScript
/**
|
|
* 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<T extends LitElement>(
|
|
tag: string,
|
|
props: Record<string, unknown> = {},
|
|
): Promise<T> {
|
|
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<T extends LitElement>(
|
|
el: T,
|
|
props: Record<string, unknown>,
|
|
): Promise<T> {
|
|
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<E extends Element = Element>(
|
|
host: Element,
|
|
selector: string,
|
|
): E | null {
|
|
return host.shadowRoot?.querySelector<E>(selector) ?? null;
|
|
}
|
|
|
|
/** Query all matching elements inside a component's shadow root. */
|
|
export function shadowAll<E extends Element = Element>(
|
|
host: Element,
|
|
selector: string,
|
|
): E[] {
|
|
return [...(host.shadowRoot?.querySelectorAll<E>(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<E extends Element = Element>(
|
|
root: Element,
|
|
selector: string,
|
|
): E | null {
|
|
const queue: Array<Element | ShadowRoot> = [root.shadowRoot ?? root];
|
|
|
|
while (queue.length > 0) {
|
|
const node = queue.shift()!;
|
|
const hit = node.querySelector<E>(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<void> {
|
|
const target = shadow<HTMLElement>(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<void> {
|
|
if (!visualEnabled) return;
|
|
|
|
await expect(el).toMatchScreenshot(name);
|
|
}
|