pcbjam/tests/e2e/utils/element-tracker.ts

2089 lines
62 KiB
TypeScript
Raw Normal View History

e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate Squash of experiment/ff-big-modules vs main. Big-module routing removed: native-EH shrank kicad_editor below SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT crutch are gone — kicad-firefox and kicad-chromium both run the full suite, with the module compiled the way real users' browsers compile it. Per-engine screenshots end to end: stableShot/shotPath write test-results/<engine>/<name>.png; baselines move to baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on <engine>/<name>. Previously Firefox and Chromium renders of one spec overwrote each other and Firefox renders were never actually gated. Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium web-suite shots); manifest generated from the baseline tree. One merged playwright.config.ts (kicad/asyncify/coroutine/perf as projects); ~25 dead npm scripts dropped. The web suite is gated in CI for the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after npm ci; last 26 blind-sleep violations fixed. SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe (--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader transiently failed the first post-board-load draw and the recovery cascade ended in a silent permanent Cairo fallback — that engine flip was the "~1.2% changedRatio both directions" occ-export baseline flake. Validated 160/160 across two 80-repeat rigs; full analysis in docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium baselines shift slightly on llvmpipe — promote once from the first green run. Deflakes the new coverage exposed: presence baselines settle before capture; presence fixtures declare current file formats; perf gets its own outputDir so CI evidence survives; occ-export settles the board paint before the export dialog; menu-item waits (waitForRenderedByLabel before clickMenuItem) in 4 specs + the TESTING.md rule. Web suite runs the PROD build, in parallel: webServer becomes backend `start` + the standalone's e2e:preview (build-preview.mjs: link-wasm → stash the public/wasm symlink aside during vite build, build-demo.mjs's move — then vite preview as the persistent server). The wasm middleware serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread worker script's own response must carry COEP or Chrome kills it with ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time; VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed: shared-page specs become serial groups; locks.spec grabs alice's exact item via the new kicadCollabTestSelectByUuid hook (cross-tab "first footprint" order is not a ysync invariant); quit specs poll page.url() (quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox). Suite: 51 passed / 12 skipped / 0 failed in 1.6m. CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be reachable from the npm scripts the workflows invoke — scraped from .github/workflows/, resolved through package.json, coverage asked from playwright --list itself. Rules: uncovered-spec + orphan-project (with a documented LOCAL_ONLY_PROJECTS allowlist). Gating next to lint:determinism; 138 spec files / 13 projects accounted for. Product fixes kept from the investigations (reachable on real GPUs too): wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush + per-instance font texture + first-frame GL-error drain (GAL recovery recovers instead of falling back to Cairo) + the user-facing eeschema switch navigates again under __EMSCRIPTEN__ (project-sync's FaceRegistered gate had rerouted it into the hidden sync player; caught by the newly-gated web suite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
import { Page, Locator, expect as baseExpect } from '@playwright/test';
import * as fs from 'fs';
export interface WxElement {
id: string;
label: string;
name: string;
typeName: string;
screenX: number;
screenY: number;
width: number;
height: number;
centerX: number;
centerY: number;
parentId: string | null;
visible: boolean;
enabled: boolean;
lastUpdated: number;
}
export interface FindOptions {
visible?: boolean;
enabled?: boolean;
exact?: boolean;
type?: string;
parent?: string;
}
export interface FindFilter extends FindOptions {
label?: string;
name?: string;
}
export interface RegistryStats {
total: number;
byType: Record<string, number>;
}
export interface WxRenderedElement {
id: string;
parentId: string;
elementType: 'tool' | 'menuitem' | 'sash' | 'auipart' | 'tab' | 'gridcell' | 'listitem' | 'datecell' | 'treeitem' | 'dataviewitem' | 'proprow' | 'listboxitem' | 'spinbutton' | 'slider' | 'slidertrack' | 'textctrl' | 'combobutton' | 'combotextarea' | 'searchctrl' | 'searchbutton' | 'columnheader' | 'styledtext';
subType: string;
label: string;
tooltip: string;
screenX: number;
screenY: number;
width: number;
height: number;
centerX: number;
centerY: number;
enabled: boolean;
index: number;
lastUpdated: number;
}
export interface RenderedFindOptions {
enabled?: boolean;
elementType?: string;
subType?: string;
parentId?: string;
exact?: boolean;
}
export interface WxElementRegistry {
elements: Map<string, WxElement>;
version: number;
register(id: string, info: WxElement): void;
update(id: string, updates: Partial<WxElement>): void;
unregister(id: string): void;
findByLabel(label: string, options?: FindOptions): WxElement[];
findByName(name: string, options?: FindOptions): WxElement[];
findByType(typeName: string, options?: FindOptions): WxElement[];
findAll(filter?: FindFilter): WxElement[];
getElement(id: string): WxElement | null;
dump(): void;
getStats(): RegistryStats;
// Rendered elements support
renderedElements?: Map<string, WxRenderedElement>;
renderedVersion?: number;
findRenderedByLabel?(label: string, options?: RenderedFindOptions): WxRenderedElement[];
findRenderedByType?(elementType: string, options?: RenderedFindOptions): WxRenderedElement[];
findRenderedByParent?(parentId: string, options?: RenderedFindOptions): WxRenderedElement[];
findAllRendered?(filter?: RenderedFindOptions & { label?: string }): WxRenderedElement[];
dumpRendered?(): void;
getRenderedStats?(): RegistryStats;
}
declare global {
interface Window {
wxElementRegistry?: WxElementRegistry;
}
}
/**
* Wait for element registry to be available
*/
export async function waitForRegistry(page: Page, timeout = 5000): Promise<boolean> {
try {
await page.waitForFunction(
() => typeof window.wxElementRegistry !== 'undefined',
{ timeout }
);
return true;
} catch {
return false;
}
}
/**
* Find element by label text
*/
export async function findByLabel(
page: Page,
label: string,
options: FindOptions = {}
): Promise<WxElement | null> {
const elements = await page.evaluate(
([label, opts]: [string, FindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry) return [];
return registry.findByLabel(label, opts);
},
[label, options] as [string, FindOptions]
);
return elements.length > 0 ? elements[0] : null;
}
/**
* Find all elements by label text
*/
export async function findAllByLabel(
page: Page,
label: string,
options: FindOptions = {}
): Promise<WxElement[]> {
return page.evaluate(
([label, opts]: [string, FindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry) return [];
return registry.findByLabel(label, opts);
},
[label, options] as [string, FindOptions]
);
}
/**
* Find element by name
*/
export async function findByName(
page: Page,
name: string,
options: FindOptions = {}
): Promise<WxElement | null> {
const elements = await page.evaluate(
([name, opts]: [string, FindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry) return [];
return registry.findByName(name, opts);
},
[name, options] as [string, FindOptions]
);
return elements.length > 0 ? elements[0] : null;
}
/**
* Find elements by type name (e.g., "wxButton", "wxTextCtrl")
*/
export async function findByType(
page: Page,
typeName: string,
options: FindOptions = {}
): Promise<WxElement[]> {
return page.evaluate(
([typeName, opts]: [string, FindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry) return [];
return registry.findByType(typeName, opts);
},
[typeName, options] as [string, FindOptions]
);
}
/**
* Find all elements matching filter
*/
export async function findAll(
page: Page,
filter: FindFilter = {}
): Promise<WxElement[]> {
return page.evaluate(
(filter: FindFilter) => {
const registry = window.wxElementRegistry;
if (!registry) return [];
return registry.findAll(filter);
},
filter
);
}
/**
* Click on an element by label
*/
export async function clickByLabel(
page: Page,
label: string,
options: FindOptions = {}
): Promise<boolean> {
const element = await findByLabel(page, label, options);
if (!element) {
console.warn(`Element with label "${label}" not found`);
return false;
}
await page.mouse.click(element.centerX, element.centerY);
return true;
}
/**
* Click on an element by name
*/
export async function clickByName(
page: Page,
name: string,
options: FindOptions = {}
): Promise<boolean> {
const element = await findByName(page, name, options);
if (!element) {
console.warn(`Element with name "${name}" not found`);
return false;
}
await page.mouse.click(element.centerX, element.centerY);
return true;
}
/**
* Get element position for manual clicking (returns screen coords)
*/
export async function getElementPosition(
page: Page,
labelOrName: string,
options: FindOptions = {}
): Promise<{ x: number; y: number } | null> {
// Try label first, then name
let element = await findByLabel(page, labelOrName, options);
if (!element) {
element = await findByName(page, labelOrName, options);
}
if (!element) return null;
return { x: element.centerX, y: element.centerY };
}
/**
* Dump all elements to console (for debugging)
*/
export async function dumpElements(page: Page): Promise<void> {
await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (registry) {
registry.dump();
} else {
console.log('[wxElementRegistry] Not initialized');
}
});
}
/**
* Get registry statistics
*/
export async function getRegistryStats(page: Page): Promise<RegistryStats | null> {
return page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry) return null;
return registry.getStats();
});
}
/**
* Wait for an element to appear by label
*/
export async function waitForElement(
page: Page,
label: string,
options: FindOptions & { timeout?: number } = {}
): Promise<WxElement | null> {
const timeout = options.timeout || 5000;
const { timeout: _, ...findOptions } = options;
try {
await page.waitForFunction(
([label, opts]: [string, FindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry) return false;
const elements = registry.findByLabel(label, opts);
return elements.length > 0;
},
[label, findOptions] as [string, FindOptions],
{ timeout }
);
return findByLabel(page, label, findOptions);
} catch {
return null;
}
}
// ============================================================================
// Rendered Elements (toolbar tools, menu items, splitter sashes, AUI parts)
// ============================================================================
/**
* Find rendered element by label (e.g., toolbar tool "New", menu item "File")
*/
export async function findRenderedByLabel(
page: Page,
label: string,
options: RenderedFindOptions = {}
): Promise<WxRenderedElement | null> {
const elements = await page.evaluate(
([label, opts]: [string, RenderedFindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry || !registry.findRenderedByLabel) return [];
return registry.findRenderedByLabel(label, opts);
},
[label, options] as [string, RenderedFindOptions]
);
return elements.length > 0 ? elements[0] : null;
}
/**
* Find all rendered elements by label
*/
export async function findAllRenderedByLabel(
page: Page,
label: string,
options: RenderedFindOptions = {}
): Promise<WxRenderedElement[]> {
return page.evaluate(
([label, opts]: [string, RenderedFindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry || !registry.findRenderedByLabel) return [];
return registry.findRenderedByLabel(label, opts);
},
[label, options] as [string, RenderedFindOptions]
);
}
/**
* Find all rendered elements of a type (tool, menuitem, sash, auipart)
*/
export async function findRenderedByType(
page: Page,
elementType: string,
options: RenderedFindOptions = {}
): Promise<WxRenderedElement[]> {
return page.evaluate(
([type, opts]: [string, RenderedFindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry || !registry.findRenderedByType) return [];
return registry.findRenderedByType(type, opts);
},
[elementType, options] as [string, RenderedFindOptions]
);
}
/**
* Find rendered element by tooltip
*/
export async function findByTooltip(
page: Page,
tooltip: string,
options: RenderedFindOptions = {}
): Promise<WxRenderedElement | null> {
const elements = await page.evaluate(
([tooltip, opts]: [string, RenderedFindOptions]) => {
const registry = window.wxElementRegistry;
if (!registry || !registry.findAllRendered) return [];
// Find all rendered elements and filter by tooltip
const all = registry.findAllRendered(opts);
return all.filter(e => e.tooltip && e.tooltip.includes(tooltip));
},
[tooltip, options] as [string, RenderedFindOptions]
);
return elements.length > 0 ? elements[0] : null;
}
/**
* Click on a rendered element by tooltip
*/
export async function clickByTooltip(
page: Page,
tooltip: string,
options: RenderedFindOptions = {}
): Promise<boolean> {
const element = await findByTooltip(page, tooltip, options);
if (!element) {
console.warn(`Element with tooltip "${tooltip}" not found`);
return false;
}
if (!element.enabled) {
console.warn(`Element with tooltip "${tooltip}" is disabled`);
return false;
}
await page.mouse.click(element.centerX, element.centerY);
return true;
}
/**
* Click on a toolbar tool by label or tooltip
*/
export async function clickToolbarTool(
page: Page,
label: string
): Promise<boolean> {
const tool = await findRenderedByLabel(page, label, { elementType: 'tool' });
if (!tool) {
console.warn(`Toolbar tool "${label}" not found`);
return false;
}
if (!tool.enabled) {
console.warn(`Toolbar tool "${label}" is disabled`);
return false;
}
await page.mouse.click(tool.centerX, tool.centerY);
return true;
}
/**
* Click on a menu bar item by label
*/
export async function clickMenuBarItem(
page: Page,
label: string
): Promise<boolean> {
const menuItem = await findRenderedByLabel(page, label, {
elementType: 'menuitem',
subType: 'menubar'
});
if (!menuItem) {
console.warn(`Menu bar item "${label}" not found`);
return false;
}
if (!menuItem.enabled) {
console.warn(`Menu bar item "${label}" is disabled`);
return false;
}
await page.mouse.click(menuItem.centerX, menuItem.centerY);
return true;
}
/**
* Click on a popup menu item by label
*/
export async function clickMenuItem(
page: Page,
label: string
): Promise<boolean> {
const menuItem = await findRenderedByLabel(page, label, {
elementType: 'menuitem'
});
if (!menuItem) {
console.warn(`Menu item "${label}" not found`);
return false;
}
if (!menuItem.enabled) {
console.warn(`Menu item "${label}" is disabled`);
return false;
}
await page.mouse.click(menuItem.centerX, menuItem.centerY);
return true;
}
/**
* Get splitter sash element
*/
export async function getSplitterSash(
page: Page,
parentId?: string
): Promise<WxRenderedElement | null> {
const options: RenderedFindOptions = {};
if (parentId) options.parentId = parentId;
const sashes = await findRenderedByType(page, 'sash', options);
return sashes.length > 0 ? sashes[0] : null;
}
/**
* Click on an AUI pane button (close, pin, maximize)
*/
export async function clickAuiButton(
page: Page,
buttonType: 'close' | 'pin' | 'maximize',
paneCaption?: string
): Promise<boolean> {
const options: RenderedFindOptions = {
elementType: 'auipart',
subType: buttonType
};
let button: WxRenderedElement | null = null;
if (paneCaption) {
// Find the specific pane first, then find the button by parent
const caption = await findRenderedByLabel(page, paneCaption, {
elementType: 'auipart',
subType: 'caption'
});
if (caption) {
// Button index is based on pane index
const paneIndex = caption.index;
const buttons = await findRenderedByType(page, 'auipart', { subType: buttonType });
button = buttons.find(b => Math.floor(b.index / 10) === paneIndex) || null;
}
} else {
// Just find the first button of this type
const buttons = await findRenderedByType(page, 'auipart', options);
button = buttons.length > 0 ? buttons[0] : null;
}
if (!button) {
console.warn(`AUI ${buttonType} button not found`);
return false;
}
await page.mouse.click(button.centerX, button.centerY);
return true;
}
/**
* Find an AUI pane content area by panel caption
*/
export async function findAuiPaneContent(
page: Page,
paneCaption: string
): Promise<WxRenderedElement | null> {
const contents = await findRenderedByType(page, 'auipart', { subType: 'content' });
return contents.find(c => c.label === paneCaption) || null;
}
/**
* Click on an AUI pane content area by panel caption
*/
export async function clickAuiPaneContent(
page: Page,
paneCaption: string
): Promise<boolean> {
const content = await findAuiPaneContent(page, paneCaption);
if (!content) {
console.warn(`AUI pane content "${paneCaption}" not found`);
return false;
}
await page.mouse.click(content.centerX, content.centerY);
return true;
}
/**
* Get all AUI pane content areas
*/
export async function findAllAuiPaneContents(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'auipart', { subType: 'content' });
}
/**
* Click on a rendered element by label (searches all types)
*/
export async function clickRenderedByLabel(
page: Page,
label: string,
options: RenderedFindOptions = {}
): Promise<boolean> {
const element = await findRenderedByLabel(page, label, options);
if (!element) {
console.warn(`Rendered element "${label}" not found`);
return false;
}
if (!element.enabled) {
console.warn(`Rendered element "${label}" is disabled`);
return false;
}
await page.mouse.click(element.centerX, element.centerY);
return true;
}
/**
* Dump all rendered elements to console (for debugging)
*/
export async function dumpRenderedElements(page: Page): Promise<void> {
await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (registry && registry.dumpRendered) {
registry.dumpRendered();
} else {
console.log('[wxElementRegistry] Rendered elements not available');
}
});
}
/**
* Get rendered elements statistics
*/
export async function getRenderedStats(page: Page): Promise<RegistryStats | null> {
return page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry || !registry.getRenderedStats) return null;
return registry.getRenderedStats();
});
}
// ============================================================================
// Grid Cell Helpers
// ============================================================================
/**
* Find a grid cell by row and column
*/
export async function findGridCell(
page: Page,
row: number,
col: number
): Promise<WxRenderedElement | null> {
const elements = await page.evaluate(
([row, col]: [number, number]) => {
const registry = window.wxElementRegistry;
if (!registry || !registry.findAllRendered) return [];
const allCells = registry.findAllRendered({}).filter(
e => e.elementType === 'gridcell' && e.tooltip === `Row ${row}, Col ${col}`
);
return allCells;
},
[row, col] as [number, number]
);
return elements.length > 0 ? elements[0] : null;
}
/**
* Find a grid cell by its label/value
*/
export async function findGridCellByLabel(
page: Page,
label: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, label, { elementType: 'gridcell' });
}
/**
* Click on a grid cell by row and column
*/
export async function clickGridCell(
page: Page,
row: number,
col: number
): Promise<boolean> {
const cell = await findGridCell(page, row, col);
if (!cell) {
console.warn(`Grid cell at row ${row}, col ${col} not found`);
return false;
}
await page.mouse.click(cell.centerX, cell.centerY);
return true;
}
/**
* Click on a grid cell by its label/value
*/
export async function clickGridCellByLabel(
page: Page,
label: string
): Promise<boolean> {
const cell = await findGridCellByLabel(page, label);
if (!cell) {
console.warn(`Grid cell with label "${label}" not found`);
return false;
}
await page.mouse.click(cell.centerX, cell.centerY);
return true;
}
/**
* Get all grid cells
*/
export async function findAllGridCells(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'gridcell');
}
// ============================================================================
// List Item Helpers
// ============================================================================
/**
* Find a list item by its label
*/
export async function findListItem(
page: Page,
label: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, label, { elementType: 'listitem' });
}
/**
* Find a list item by row index
*/
export async function findListItemByIndex(
page: Page,
index: number
): Promise<WxRenderedElement | null> {
const items = await findRenderedByType(page, 'listitem');
return items.find(item => item.index === index) || null;
}
/**
* Click on a list item by label
*/
export async function clickListItem(
page: Page,
label: string
): Promise<boolean> {
const item = await findListItem(page, label);
if (!item) {
console.warn(`List item "${label}" not found`);
return false;
}
await page.mouse.click(item.centerX, item.centerY);
return true;
}
/**
* Click on a list item by row index
*/
export async function clickListItemByIndex(
page: Page,
index: number
): Promise<boolean> {
const item = await findListItemByIndex(page, index);
if (!item) {
console.warn(`List item at index ${index} not found`);
return false;
}
await page.mouse.click(item.centerX, item.centerY);
return true;
}
/**
* Get all list items
*/
export async function findAllListItems(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'listitem');
}
// ============================================================================
// Tab Helpers
// ============================================================================
/**
* Find a tab by its label
*/
export async function findTab(
page: Page,
label: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, label, { elementType: 'tab' });
}
/**
* Click on a tab by label
*/
export async function clickTab(
page: Page,
label: string
): Promise<boolean> {
2026-06-04 13:20:55 +02:00
// Clicking a tab too fast (e.g. immediately after a burst of mouse events)
// can be dropped before the notebook processes the page change. Re-click and
// verify the selection actually switched, retrying a few times.
const maxAttempts = 4;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const tab = await findTab(page, label);
if (!tab) {
console.warn(`Tab "${label}" not found`);
return false;
}
// Already selected (e.g. a previous attempt registered late) -> done.
const selectedBefore = await findSelectedTab(page);
if (selectedBefore?.label === label) return true;
await page.mouse.click(tab.centerX, tab.centerY);
// Poll until the registry reports this tab as the selected one.
const switched = await page
.waitForFunction(
(expected) => {
const registry = (window as any).wxElementRegistry;
if (!registry || !registry.findAllRendered) return false;
return registry
.findAllRendered({ label: undefined })
.some(
(e: any) =>
e.elementType === 'tab' &&
e.subType === 'selected' &&
e.label === expected
);
},
label,
{ timeout: 1000 }
)
.then(() => true)
.catch(() => false);
if (switched) return true;
await page.waitForTimeout(200);
}
2026-06-04 13:20:55 +02:00
console.warn(`Tab "${label}" did not become selected after ${maxAttempts} attempts`);
return false;
}
/**
* Get all tabs
*/
export async function findAllTabs(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'tab');
}
/**
* Get the currently selected tab
*/
export async function findSelectedTab(page: Page): Promise<WxRenderedElement | null> {
const tabs = await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry || !registry.findAllRendered) return [];
return registry.findAllRendered({ label: undefined }).filter(
e => e.elementType === 'tab' && e.subType === 'selected'
);
});
return tabs.length > 0 ? tabs[0] : null;
}
// ============================================================================
// Calendar Date Helpers
// ============================================================================
/**
* Find a calendar date by day number
*/
export async function findCalendarDate(
page: Page,
day: number
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, String(day), { elementType: 'datecell' });
}
/**
* Click on a calendar date by day number
*/
export async function clickCalendarDate(
page: Page,
day: number
): Promise<boolean> {
const dateCell = await findCalendarDate(page, day);
if (!dateCell) {
console.warn(`Calendar date ${day} not found`);
return false;
}
if (!dateCell.enabled) {
console.warn(`Calendar date ${day} is out of range`);
return false;
}
await page.mouse.click(dateCell.centerX, dateCell.centerY);
return true;
}
/**
* Get all calendar date cells
*/
export async function findAllCalendarDates(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'datecell');
}
/**
* Get the currently selected calendar date
*/
export async function findSelectedCalendarDate(page: Page): Promise<WxRenderedElement | null> {
const dates = await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry || !registry.findAllRendered) return [];
return registry.findAllRendered({ label: undefined }).filter(
e => e.elementType === 'datecell' && e.subType === 'selected'
);
});
return dates.length > 0 ? dates[0] : null;
}
// ============================================================================
// Tree Item Helpers
// ============================================================================
/**
* Find a tree item by its label
*/
export async function findTreeItem(
page: Page,
label: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, label, { elementType: 'treeitem' });
}
/**
* Click on a tree item by label
*/
export async function clickTreeItem(
page: Page,
label: string
): Promise<boolean> {
const item = await findTreeItem(page, label);
if (!item) {
console.warn(`Tree item "${label}" not found`);
return false;
}
await page.mouse.click(item.centerX, item.centerY);
return true;
}
/**
* Get all tree items
*/
export async function findAllTreeItems(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'treeitem');
}
/**
* Find tree items by state (expanded, collapsed, leaf)
*/
export async function findTreeItemsByState(
page: Page,
state: 'expanded' | 'collapsed' | 'leaf'
): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'treeitem', { subType: state });
}
// ============================================================================
// DataView Item Helpers
// ============================================================================
/**
* Find a dataview item by its label
*/
export async function findDataViewItem(
page: Page,
label: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, label, { elementType: 'dataviewitem' });
}
/**
* Find a dataview item by row index
*/
export async function findDataViewItemByIndex(
page: Page,
index: number
): Promise<WxRenderedElement | null> {
const items = await findRenderedByType(page, 'dataviewitem');
return items.find(item => item.index === index) || null;
}
/**
* Click on a dataview item by label
*/
export async function clickDataViewItem(
page: Page,
label: string
): Promise<boolean> {
const item = await findDataViewItem(page, label);
if (!item) {
console.warn(`DataView item "${label}" not found`);
return false;
}
await page.mouse.click(item.centerX, item.centerY);
return true;
}
/**
* Click on a dataview item by row index
*/
export async function clickDataViewItemByIndex(
page: Page,
index: number
): Promise<boolean> {
const item = await findDataViewItemByIndex(page, index);
if (!item) {
console.warn(`DataView item at index ${index} not found`);
return false;
}
await page.mouse.click(item.centerX, item.centerY);
return true;
}
/**
* Get all dataview items
*/
export async function findAllDataViewItems(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'dataviewitem');
}
// ============================================================================
// Property Grid Helpers
// ============================================================================
/**
* Find a property row by its label
*/
export async function findPropertyRow(
page: Page,
label: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, label, { elementType: 'proprow' });
}
/**
* Click on a property row by label
*/
export async function clickPropertyRow(
page: Page,
label: string
): Promise<boolean> {
const row = await findPropertyRow(page, label);
if (!row) {
console.warn(`Property row "${label}" not found`);
return false;
}
if (!row.enabled) {
console.warn(`Property row "${label}" is disabled`);
return false;
}
await page.mouse.click(row.centerX, row.centerY);
return true;
}
/**
* Get all property rows
*/
export async function findAllPropertyRows(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'proprow');
}
/**
* Find property rows by type (category, property, selected, disabled)
*/
export async function findPropertyRowsByType(
page: Page,
type: 'category' | 'property' | 'selected' | 'disabled'
): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'proprow', { subType: type });
}
// ============================================================================
// Listbox/Choice Item Helpers (for dropdown lists)
// ============================================================================
/**
* Find a listbox item by its label
*/
export async function findListboxItem(
page: Page,
label: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, label, { elementType: 'listboxitem' });
}
/**
* Find a listbox item by index
*/
export async function findListboxItemByIndex(
page: Page,
index: number
): Promise<WxRenderedElement | null> {
const items = await findRenderedByType(page, 'listboxitem');
return items.find(item => item.index === index) || null;
}
/**
* Click on a listbox item by label (for wxChoice/wxComboBox dropdowns)
*/
export async function clickListboxItem(
page: Page,
label: string
): Promise<boolean> {
const item = await findListboxItem(page, label);
if (!item) {
console.warn(`Listbox item "${label}" not found`);
return false;
}
await page.mouse.click(item.centerX, item.centerY);
return true;
}
/**
* Click on a listbox item by index
*/
export async function clickListboxItemByIndex(
page: Page,
index: number
): Promise<boolean> {
const item = await findListboxItemByIndex(page, index);
if (!item) {
console.warn(`Listbox item at index ${index} not found`);
return false;
}
await page.mouse.click(item.centerX, item.centerY);
return true;
}
/**
* Get all listbox items
*/
export async function findAllListboxItems(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'listboxitem');
}
// ============================================================================
// Spin Button Helpers
// ============================================================================
/**
* Find a spin button arrow by type
*/
export async function findSpinButton(
page: Page,
arrowType: 'up' | 'down' | 'left' | 'right'
): Promise<WxRenderedElement | null> {
const buttons = await findRenderedByType(page, 'spinbutton', { subType: arrowType });
return buttons.length > 0 ? buttons[0] : null;
}
/**
* Click the spin button up arrow (or left for horizontal)
*/
export async function clickSpinUp(page: Page): Promise<boolean> {
// Try 'up' first (vertical), then 'left' (horizontal)
let button = await findSpinButton(page, 'up');
if (!button) {
button = await findSpinButton(page, 'left');
}
if (!button) {
console.warn('Spin up/left button not found');
return false;
}
if (!button.enabled) {
console.warn('Spin up/left button is disabled');
return false;
}
await page.mouse.click(button.centerX, button.centerY);
return true;
}
/**
* Click the spin button down arrow (or right for horizontal)
*/
export async function clickSpinDown(page: Page): Promise<boolean> {
// Try 'down' first (vertical), then 'right' (horizontal)
let button = await findSpinButton(page, 'down');
if (!button) {
button = await findSpinButton(page, 'right');
}
if (!button) {
console.warn('Spin down/right button not found');
return false;
}
if (!button.enabled) {
console.warn('Spin down/right button is disabled');
return false;
}
await page.mouse.click(button.centerX, button.centerY);
return true;
}
/**
* Get all spin buttons
*/
export async function findAllSpinButtons(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'spinbutton');
}
// ============================================================================
// Slider Helpers
// ============================================================================
/**
* Find a slider thumb by name/label
*/
export async function findSlider(
page: Page,
name?: string
): Promise<WxRenderedElement | null> {
if (name) {
return findRenderedByLabel(page, name, { elementType: 'slider' });
}
const sliders = await findRenderedByType(page, 'slider');
return sliders.length > 0 ? sliders[0] : null;
}
/**
* Find a slider track for drag operations
*/
export async function findSliderTrack(
page: Page,
name?: string
): Promise<WxRenderedElement | null> {
if (name) {
return findRenderedByLabel(page, name, { elementType: 'slidertrack' });
}
const tracks = await findRenderedByType(page, 'slidertrack');
return tracks.length > 0 ? tracks[0] : null;
}
/**
* Click on a slider thumb
*/
export async function clickSlider(
page: Page,
name?: string
): Promise<boolean> {
const slider = await findSlider(page, name);
if (!slider) {
console.warn(`Slider${name ? ` "${name}"` : ''} not found`);
return false;
}
if (!slider.enabled) {
console.warn(`Slider${name ? ` "${name}"` : ''} is disabled`);
return false;
}
await page.mouse.click(slider.centerX, slider.centerY);
return true;
}
/**
* Drag slider to a position within the track
* @param position - value between 0 and 1 (0 = min, 1 = max)
*/
export async function dragSliderTo(
page: Page,
position: number,
name?: string
): Promise<boolean> {
const slider = await findSlider(page, name);
const track = await findSliderTrack(page, name);
if (!slider || !track) {
console.warn(`Slider or track${name ? ` "${name}"` : ''} not found`);
return false;
}
if (!slider.enabled) {
console.warn(`Slider${name ? ` "${name}"` : ''} is disabled`);
return false;
}
// Clamp position to [0, 1]
position = Math.max(0, Math.min(1, position));
// Determine if horizontal or vertical based on track dimensions
const isHorizontal = track.width > track.height;
let targetX: number, targetY: number;
if (isHorizontal) {
targetX = track.screenX + track.width * position;
targetY = track.centerY;
} else {
targetX = track.centerX;
// For vertical sliders, 0 is typically at bottom
targetY = track.screenY + track.height * (1 - position);
}
// Drag from current slider position to target
await page.mouse.move(slider.centerX, slider.centerY);
await page.mouse.down();
await page.mouse.move(targetX, targetY);
await page.mouse.up();
return true;
}
/**
* Get all sliders
*/
export async function findAllSliders(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'slider');
}
// ============================================================================
// Text Control Helpers
// ============================================================================
/**
* Find a text control by name
*/
export async function findTextCtrl(
page: Page,
name?: string
): Promise<WxRenderedElement | null> {
if (name) {
return findRenderedByLabel(page, name, { elementType: 'textctrl' });
}
const ctrls = await findRenderedByType(page, 'textctrl');
return ctrls.length > 0 ? ctrls[0] : null;
}
/**
* Find a singleline text control
*/
export async function findSingleLineTextCtrl(
page: Page,
name?: string
): Promise<WxRenderedElement | null> {
if (name) {
const ctrl = await findRenderedByLabel(page, name, { elementType: 'textctrl', subType: 'singleline' });
return ctrl;
}
const ctrls = await findRenderedByType(page, 'textctrl', { subType: 'singleline' });
return ctrls.length > 0 ? ctrls[0] : null;
}
/**
* Find a multiline text control
*/
export async function findMultiLineTextCtrl(
page: Page,
name?: string
): Promise<WxRenderedElement | null> {
if (name) {
const ctrl = await findRenderedByLabel(page, name, { elementType: 'textctrl', subType: 'multiline' });
return ctrl;
}
const ctrls = await findRenderedByType(page, 'textctrl', { subType: 'multiline' });
return ctrls.length > 0 ? ctrls[0] : null;
}
/**
* Click on a text control to focus it
*/
export async function clickTextCtrl(
page: Page,
name?: string
): Promise<boolean> {
const ctrl = await findTextCtrl(page, name);
if (!ctrl) {
console.warn(`TextCtrl${name ? ` "${name}"` : ''} not found`);
return false;
}
if (!ctrl.enabled) {
console.warn(`TextCtrl${name ? ` "${name}"` : ''} is disabled`);
return false;
}
await page.mouse.click(ctrl.centerX, ctrl.centerY);
return true;
}
/**
* Get all text controls
*/
export async function findAllTextCtrls(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'textctrl');
}
// ============================================================================
// Combo/Choice Button Helpers
// ============================================================================
/**
* Find a combo/choice dropdown button
*/
export async function findComboButton(
page: Page,
value?: string
): Promise<WxRenderedElement | null> {
if (value) {
return findRenderedByLabel(page, value, { elementType: 'combobutton' });
}
const buttons = await findRenderedByType(page, 'combobutton');
return buttons.length > 0 ? buttons[0] : null;
}
/**
* Click on a combo/choice dropdown button to open/close the dropdown
*/
export async function clickComboButton(
page: Page,
value?: string
): Promise<boolean> {
const button = await findComboButton(page, value);
if (!button) {
console.warn(`Combo button${value ? ` with value "${value}"` : ''} not found`);
return false;
}
if (!button.enabled) {
console.warn(`Combo button${value ? ` with value "${value}"` : ''} is disabled`);
return false;
}
await page.mouse.click(button.centerX, button.centerY);
return true;
}
/**
* Open a combo/choice dropdown (clicks button if closed)
*/
export async function openComboDropdown(
page: Page,
value?: string
): Promise<boolean> {
const button = await findComboButton(page, value);
if (!button) {
console.warn(`Combo button${value ? ` with value "${value}"` : ''} not found`);
return false;
}
// Only click if dropdown is closed
if (button.subType === 'closed') {
await page.mouse.click(button.centerX, button.centerY);
}
return true;
}
/**
* Select an item from combo/choice dropdown by label
* Opens the dropdown if needed, then clicks the item
*/
export async function selectComboItem(
page: Page,
itemLabel: string,
comboValue?: string
): Promise<boolean> {
// Open the dropdown
const opened = await openComboDropdown(page, comboValue);
if (!opened) return false;
// Wait a bit for dropdown to appear
await page.waitForTimeout(100);
// Click the listbox item
return clickListboxItem(page, itemLabel);
}
/**
* Get all combo buttons
*/
export async function findAllComboButtons(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'combobutton');
}
// ============================================================================
// Search Control Helpers
// ============================================================================
/**
* Find a search control text field
*/
export async function findSearchCtrl(
page: Page,
hint?: string
): Promise<WxRenderedElement | null> {
if (hint) {
return findRenderedByLabel(page, hint, { elementType: 'searchctrl' });
}
const ctrls = await findRenderedByType(page, 'searchctrl');
return ctrls.length > 0 ? ctrls[0] : null;
}
/**
* Click on a search control to focus it
*/
export async function clickSearchCtrl(
page: Page,
hint?: string
): Promise<boolean> {
const ctrl = await findSearchCtrl(page, hint);
if (!ctrl) {
console.warn(`SearchCtrl${hint ? ` "${hint}"` : ''} not found`);
return false;
}
if (!ctrl.enabled) {
console.warn(`SearchCtrl${hint ? ` "${hint}"` : ''} is disabled`);
return false;
}
await page.mouse.click(ctrl.centerX, ctrl.centerY);
return true;
}
/**
* Find the search button in a search control
*/
export async function findSearchButton(
page: Page
): Promise<WxRenderedElement | null> {
const buttons = await findRenderedByType(page, 'searchbutton', { subType: 'search' });
return buttons.length > 0 ? buttons[0] : null;
}
/**
* Find the cancel button in a search control
*/
export async function findSearchCancelButton(
page: Page
): Promise<WxRenderedElement | null> {
const buttons = await findRenderedByType(page, 'searchbutton', { subType: 'cancel' });
return buttons.length > 0 ? buttons[0] : null;
}
/**
* Click the search button
*/
export async function clickSearchButton(page: Page): Promise<boolean> {
const button = await findSearchButton(page);
if (!button) {
console.warn('Search button not found');
return false;
}
await page.mouse.click(button.centerX, button.centerY);
return true;
}
/**
* Click the cancel/clear button in a search control
*/
export async function clickSearchCancelButton(page: Page): Promise<boolean> {
const button = await findSearchCancelButton(page);
if (!button) {
console.warn('Search cancel button not found');
return false;
}
await page.mouse.click(button.centerX, button.centerY);
return true;
}
/**
* Get all search controls
*/
export async function findAllSearchCtrls(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'searchctrl');
}
// ============================================================================
// Column Header Helpers (for wxDataViewCtrl, wxGrid, etc.)
// ============================================================================
/**
* Find a column header by its title
*/
export async function findColumnHeader(
page: Page,
title: string
): Promise<WxRenderedElement | null> {
return findRenderedByLabel(page, title, { elementType: 'columnheader' });
}
/**
* Find a column header by index
*/
export async function findColumnHeaderByIndex(
page: Page,
index: number
): Promise<WxRenderedElement | null> {
const headers = await findRenderedByType(page, 'columnheader');
return headers.find(h => h.index === index) || null;
}
/**
* Click on a column header by title (for sorting)
*/
export async function clickColumnHeader(
page: Page,
title: string
): Promise<boolean> {
const header = await findColumnHeader(page, title);
if (!header) {
console.warn(`Column header "${title}" not found`);
return false;
}
if (!header.enabled) {
console.warn(`Column header "${title}" is disabled`);
return false;
}
await page.mouse.click(header.centerX, header.centerY);
return true;
}
/**
* Click on a column header by index
*/
export async function clickColumnHeaderByIndex(
page: Page,
index: number
): Promise<boolean> {
const header = await findColumnHeaderByIndex(page, index);
if (!header) {
console.warn(`Column header at index ${index} not found`);
return false;
}
if (!header.enabled) {
console.warn(`Column header at index ${index} is disabled`);
return false;
}
await page.mouse.click(header.centerX, header.centerY);
return true;
}
/**
* Get all column headers
*/
export async function findAllColumnHeaders(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'columnheader');
}
/**
* Find sortable column headers
*/
export async function findSortableColumnHeaders(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'columnheader', { subType: 'sortable' });
}
// ============================================================================
// Styled Text Control (STC) Helpers
// ============================================================================
/**
* Find a styled text control (code editor) by name
*/
export async function findStyledTextCtrl(
page: Page,
name?: string
): Promise<WxRenderedElement | null> {
if (name) {
return findRenderedByLabel(page, name, { elementType: 'styledtext' });
}
const ctrls = await findRenderedByType(page, 'styledtext');
return ctrls.length > 0 ? ctrls[0] : null;
}
/**
* Click on a styled text control to focus it
*/
export async function clickStyledTextCtrl(
page: Page,
name?: string
): Promise<boolean> {
const ctrl = await findStyledTextCtrl(page, name);
if (!ctrl) {
console.warn(`StyledTextCtrl${name ? ` "${name}"` : ''} not found`);
return false;
}
if (!ctrl.enabled) {
console.warn(`StyledTextCtrl${name ? ` "${name}"` : ''} is read-only`);
return false;
}
await page.mouse.click(ctrl.centerX, ctrl.centerY);
return true;
}
/**
* Get all styled text controls
*/
export async function findAllStyledTextCtrls(page: Page): Promise<WxRenderedElement[]> {
return findRenderedByType(page, 'styledtext');
}
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
// ============================================================================
// Deterministic waits — the "hard" (throw-on-miss) primitives that replace
// waitForTimeout sleeps and `if (element exists)` branches in the specs.
// ============================================================================
/**
* Poll a page-side predicate until it returns truthy, or throw a readable error.
*
* This is the general "loop until something actually happens" utility: it replaces
* `await page.waitForTimeout(n)` (a blind timer) with a wait on a real condition, and
* fails LOUDLY with `desc` instead of a bare Playwright timeout so a test that no
* longer reaches its expected state reports *what* it was waiting for.
*
* await waitUntil(page, () => !!window.wxElementRegistry, 'registry defined');
* await waitUntil(page, (t) => document.title === t, 'title set', { arg: 'KiCad' });
*/
export async function waitUntil<Arg = undefined>(
page: Page,
pageFunction: (arg: Arg) => unknown,
desc: string,
options: { timeout?: number; arg?: Arg; polling?: number | 'raf' } = {}
): Promise<void> {
const timeout = options.timeout ?? 15000;
try {
await page.waitForFunction(pageFunction, options.arg as Arg, {
timeout,
polling: options.polling ?? 'raf',
});
} catch {
throw new Error(`waitUntil: timed out after ${timeout}ms waiting for: ${desc}`);
}
}
/**
* Wait until a KiCad/wx editor app is interactive: the emscripten canvas is visible,
* the element registry is populated, and at least one toolbar has been laid out.
*
* App-agnostic (no app-specific frame name) so it can gate every editor spec. Replaces
* the per-spec `completeWizard()` copies and their fixed 15002500ms "let it settle"
* sleeps the seeded config already skips the first-run wizard, so readiness is purely
* "is the editor painted and registered".
*/
export async function waitForEditorReady(
page: Page,
options: { timeout?: number } = {}
): Promise<void> {
const timeout = options.timeout ?? 90000;
await page.locator('#canvas').waitFor({ state: 'visible', timeout });
await waitUntil(
page,
() => {
const r = window.wxElementRegistry;
if (!r) return false;
const visible = r.findAll({ visible: true });
const hasToolbar = visible.some((el) => /ToolBar/.test(el.typeName));
return visible.length > 10 && hasToolbar;
},
'editor registry populated + a toolbar laid out',
{ timeout }
);
}
/**
* Wait for a rendered element (menu item, toolbar tool, ) to appear, then return it;
* throw if it never appears. The throwing counterpart to findRenderedByLabel use it to
* replace `await page.waitForTimeout(n)` before reading/clicking a popup that renders async.
*/
export async function waitForRenderedByLabel(
page: Page,
label: string,
options: RenderedFindOptions & { timeout?: number } = {}
): Promise<WxRenderedElement> {
const timeout = options.timeout ?? 15000;
const { timeout: _t, ...find } = options;
await waitUntil(
page,
([label, opts]: [string, RenderedFindOptions]) => {
const r = window.wxElementRegistry;
if (!r || !r.findRenderedByLabel) return false;
return r.findRenderedByLabel(label, opts).length > 0;
},
`rendered element "${label}"`,
{ timeout, arg: [label, find] as [string, RenderedFindOptions] }
);
const el = await findRenderedByLabel(page, label, find);
if (!el) throw new Error(`waitForRenderedByLabel: "${label}" vanished after appearing`);
return el;
}
/**
* Wait until a canvas's pixels stop changing across consecutive animation frames
* a deterministic "the drawing has settled" signal for interaction specs (drawing a
* wire, dragging an item) where the visual effect must land before it can be asserted.
* Replaces fixed post-action sleeps. Works on WebGL canvases: KiCad's GAL sets
* preserveDrawingBuffer=true, so drawImage() can read the buffer back.
*/
export async function waitForCanvasStable(
page: Page,
selector: string,
options: { stableFrames?: number; timeout?: number; sampleSize?: number } = {}
): Promise<void> {
const stableFrames = options.stableFrames ?? 3;
const timeout = options.timeout ?? 15000;
const sampleSize = options.sampleSize ?? 48;
// Clear any accumulator left by a previous wait on this selector.
await page.evaluate((sel) => {
const w = window as unknown as { __canvasStable?: Record<string, unknown> };
if (w.__canvasStable) delete w.__canvasStable[sel];
}, selector);
await waitUntil(
page,
([sel, need, size]: [string, number, number]) => {
const w = window as unknown as { __canvasStable?: Record<string, { sig: number; count: number }> };
const el = document.querySelector(sel) as HTMLCanvasElement | null;
if (!el) return false;
const off = document.createElement('canvas');
off.width = size;
off.height = size;
const ctx = off.getContext('2d', { willReadFrequently: true });
if (!ctx) return false;
try {
ctx.drawImage(el, 0, 0, size, size);
} catch {
return false; // canvas not yet readable
}
const data = ctx.getImageData(0, 0, size, size).data;
let sig = 0;
for (let i = 0; i < data.length; i += 4) {
sig = (sig * 31 + data[i] + data[i + 1] * 7 + data[i + 2] * 13) | 0;
}
const store = (w.__canvasStable ??= {});
const prev = store[sel];
if (prev && prev.sig === sig) prev.count += 1;
else store[sel] = { sig, count: 1 };
return store[sel].count >= need;
},
`canvas "${selector}" stable for ${stableFrames} frames`,
{ timeout, arg: [selector, stableFrames, sampleSize] as [string, number, number], polling: 'raf' }
);
}
// ============================================================================
// Render-settle + capture — feeds the offline tools/screenshots gate
// ============================================================================
/**
* page.screenshot options we forward, plus stabilization knobs. Every capture is taken at
* scale:'css' (1:1 CSS pixels, hardcoded below see SHOT_OPTS), uniformly across both suites, to
* match the DPR-1 CI Linux baselines and avoid the past device-scale mismatch bug. So there's no
* per-call `scale`: it's always 'css'.
*/
type StableShotOptions = {
fullPage?: boolean;
/** Overall stabilization budget (ms). Animating states never settle: we proceed and capture anyway. */
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
timeout?: number;
/** Consecutive identical frames for the fast (per-rAF) convergence pass. */
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
stableFrames?: number;
/** Wider-spaced confirmations required AFTER fast convergence (each `interval` ms apart), to catch a
* slow async repaint that a few ~16ms frames would miss. */
confirmFrames?: number;
/** ms between the wide confirmations. */
interval?: number;
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
/** Canvas whose pixels signal render activity (the wx main surface). Default '#canvas'. */
canvas?: string;
};
/** Screenshot params for the final capture — pinned scale keeps every shot at 1:1 CSS pixels. */
const SHOT_OPTS = { scale: 'css', animations: 'disabled', caret: 'hide' } as const;
/**
* Wait until the render has SETTLED, then let the caller capture. The "wait" half of
* toHaveScreenshot, decoupled so pixel COMPARISON stays in the offline tools/screenshots gate
* (which diffs test-results/ against tests/baseline-screenshots/ on CI's deterministic Linux render).
*
* Runs entirely IN-PAGE (one page.evaluate), hashing a 64px downscale of the wx canvas NO repeated
* CDP screenshots (an earlier screenshot-probe version saturated CDP on the heavy kicad canvases and
* timed the whole suite out). Two-phase HYBRID:
* 1. Fast rAF convergence hash every animation frame until `stableFrames` are identical (~48ms):
* cheap, catches high-frequency motion (animations).
* 2. Wide confirmation then re-hash `confirmFrames` times, each `interval` ms apart (~500ms), so a
jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose The runtime is JSPI-only; this removes everything that still pretended otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove the inventory; every deletion verified by grep closure + full gates. Broken-right-now fixes: - deploy-staging.yml passed the retired opt_level input — the workflow could not even start. Removed. - env.sh carried dead exports with a live -sASYNCIFY=1 inside (WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason. - docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone. Dead weight removed: - binaryen submodule (nothing builds or invokes it), wasm-opt-bench workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess scaffolding (existed to parallelize the deleted wasm-opt phase; the postprocess is a seconds-long node script and now runs inline), build-monitor's dead asyncify rows, sched-context orphan build output, dead .gitignore entries, the .jspi-assets spike dir (the two wf-result research JSONs moved to docs/features/async/migration-evidence/). - bindings: fiber_park.h + its 12 embind registrations (broken-if- called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route, main_stack_runner.h + 5 includes, the always-null context-sleep weak hook in nanosleep_yield.c. - shim: the backend field (installed-flag idempotency instead), noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the WasmTool fallback and string-dump normalize branch). - web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts (gerber-demo keeps it: it loads the deployed CDN release, which predates emscripten 6 — noted inline). Conditionals: all 'backend === jspi' checks reduced to scheduler- presence checks; races_quiescent re-keyed from Asyncify.state (vacuous) to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive, which is the probing activation's own window by definition). Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→ JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS, kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests), collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→ wasmTrapSignatures (lists byte-identical). Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused asserts re-keyed to live JSPI beacons; eeschema-load's failure message no longer sends the developer to a deleted script; wait-beacons' dead families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is unconstructible); the embind test.fail re-gated with the JSPI reason (plain embind invokers cannot suspend — verified still failing); lint-determinism now scans tests/jspi (166 files clean); eeschema-collab local-move gated to chromium (~50% flaky on FF even solo; pcbnew twin covers both engines). Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md describes the single-phase build; docs/features/async/README.md banner-marked historical and repointed at the NEW 23-jspi-runtime.md (current architecture: export census, turnstile, libcontext ownership + refusal contract, embind call shapes, the em-pthread service-wrapper trick, exception policy, known gaps). Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the quiescence-probe fix; the 3 other reds were verified contention flakes solo-green or the documented FF gate), web 76/0, jspi 18/18 both engines, vitest 295/295 + 17/17, all lints green, live-app census clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-14 09:25:32 +02:00
* SLOW async repaint (e.g. a file list arriving after a suspended readdir) that a few 16ms frames
* would sail past forces a reset back to phase 1.
* Resolves once both phases pass, or when `timeout` elapses genuinely-animating states (timers,
* mid-slide) never converge and are captured at the deadline, exactly as the old raw screenshots did.
* With no canvas we just wait `stableFrames` paint frames (the spec's deterministic waits already
* reached the target state).
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
*/
export async function waitForRenderStable(page: Page, options: StableShotOptions = {}): Promise<void> {
await page.evaluate(
({ sel, need, confirmN, interval, timeout }: { sel: string; need: number; confirmN: number; interval: number; timeout: number }) =>
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
new Promise<void>((resolve) => {
const el = document.querySelector(sel) as HTMLCanvasElement | null;
if (!el || typeof el.getContext !== 'function') {
let i = 0;
const paint = () => (++i >= need ? resolve() : requestAnimationFrame(paint));
requestAnimationFrame(paint);
return;
}
const size = 64;
const off = document.createElement('canvas');
off.width = size;
off.height = size;
const ctx = off.getContext('2d', { willReadFrequently: true });
const start = performance.now();
const hash = (): number => {
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
try {
ctx!.drawImage(el, 0, 0, size, size);
const d = ctx!.getImageData(0, 0, size, size).data;
let sig = 0;
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
for (let i = 0; i < d.length; i += 4) sig = (sig * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0;
return sig;
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
} catch {
return NaN; // canvas not yet readable
}
};
const expired = () => performance.now() - start > timeout;
let prev = NaN;
let stable = 0;
let confirms = 0;
// Phase 2: wide-spaced confirmations. A differing/unreadable sample => a slow change is still
// in flight, so restart the fast pass.
const confirm = () => {
if (expired()) return resolve();
const cur = hash();
if (cur === prev && !Number.isNaN(cur)) {
if (++confirms >= confirmN) return resolve();
setTimeout(confirm, interval);
} else {
prev = cur;
stable = Number.isNaN(cur) ? 0 : 1;
confirms = 0;
requestAnimationFrame(fast);
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
}
};
// Phase 1: fast per-frame convergence.
const fast = () => {
if (expired()) return resolve();
const cur = hash();
if (cur === prev && !Number.isNaN(cur)) {
if (++stable >= need) {
confirms = 0;
setTimeout(confirm, interval);
return;
}
} else {
prev = cur;
stable = Number.isNaN(cur) ? 0 : 1;
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
}
requestAnimationFrame(fast);
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
};
requestAnimationFrame(fast);
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
}),
{
sel: options.canvas ?? '#canvas',
need: options.stableFrames ?? 3,
confirmN: options.confirmFrames ?? 2,
interval: options.interval ?? 250,
timeout: options.timeout ?? 3000,
}
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
);
}
/**
e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate Squash of experiment/ff-big-modules vs main. Big-module routing removed: native-EH shrank kicad_editor below SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT crutch are gone — kicad-firefox and kicad-chromium both run the full suite, with the module compiled the way real users' browsers compile it. Per-engine screenshots end to end: stableShot/shotPath write test-results/<engine>/<name>.png; baselines move to baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on <engine>/<name>. Previously Firefox and Chromium renders of one spec overwrote each other and Firefox renders were never actually gated. Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium web-suite shots); manifest generated from the baseline tree. One merged playwright.config.ts (kicad/asyncify/coroutine/perf as projects); ~25 dead npm scripts dropped. The web suite is gated in CI for the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after npm ci; last 26 blind-sleep violations fixed. SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe (--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader transiently failed the first post-board-load draw and the recovery cascade ended in a silent permanent Cairo fallback — that engine flip was the "~1.2% changedRatio both directions" occ-export baseline flake. Validated 160/160 across two 80-repeat rigs; full analysis in docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium baselines shift slightly on llvmpipe — promote once from the first green run. Deflakes the new coverage exposed: presence baselines settle before capture; presence fixtures declare current file formats; perf gets its own outputDir so CI evidence survives; occ-export settles the board paint before the export dialog; menu-item waits (waitForRenderedByLabel before clickMenuItem) in 4 specs + the TESTING.md rule. Web suite runs the PROD build, in parallel: webServer becomes backend `start` + the standalone's e2e:preview (build-preview.mjs: link-wasm → stash the public/wasm symlink aside during vite build, build-demo.mjs's move — then vite preview as the persistent server). The wasm middleware serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread worker script's own response must carry COEP or Chrome kills it with ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time; VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed: shared-page specs become serial groups; locks.spec grabs alice's exact item via the new kicadCollabTestSelectByUuid hook (cross-tab "first footprint" order is not a ysync invariant); quit specs poll page.url() (quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox). Suite: 51 passed / 12 skipped / 0 failed in 1.6m. CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be reachable from the npm scripts the workflows invoke — scraped from .github/workflows/, resolved through package.json, coverage asked from playwright --list itself. Rules: uncovered-spec + orphan-project (with a documented LOCAL_ONLY_PROJECTS allowlist). Gating next to lint:determinism; 138 spec files / 13 projects accounted for. Product fixes kept from the investigations (reachable on real GPUs too): wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush + per-instance font texture + first-frame GL-error drain (GAL recovery recovers instead of falling back to Cairo) + the user-facing eeschema switch navigates again under __EMSCRIPTEN__ (project-sync's FaceRegistered gate had rerouted it into the hidden sync player; caught by the newly-gated web suite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
* Engine-scoped path for a gate screenshot: test-results/<engine>/<name>, where <engine> is the
* running browser family (chromium | firefox | webkit). Every engine gets its OWN namespace so a
* spec running on two browsers in one invocation can't have the engines overwrite each other's
* PNG (the old flat test-results/<name> made the surviving file whichever worker wrote last).
* The offline gate (tools/screenshots) compares each engine against its own committed baselines
* in tests/baseline-screenshots/<engine>/.
*/
export function shotPath(page: Page, name: string): string {
const engine = page.context().browser()!.browserType().name();
const dir = `test-results/${engine}`;
// page.screenshot creates parent dirs itself, but fs writers (canvas dumps) don't.
fs.mkdirSync(dir, { recursive: true });
return `${dir}/${name}`;
}
/**
* Stabilize the render, then write a raw PNG to test-results/<engine>/<name> for the offline
* screenshot gate (tools/screenshots/compare.ts vs tests/baseline-screenshots/<engine>/). Drop-in
* replacement for `expect(page).toHaveScreenshot(name, opts)`: keeps toHaveScreenshot's
* render-settle but NOT its inline comparison (that's the calibrated offline pipeline's job) nor
* its per-platform baseline files. Non-asserting a render regression is caught by
* `npm run screenshots:check`, not the test. A name containing '/' is used verbatim (for writers
* outside the gate, e.g. gal-regression outputs).
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
*/
export async function stableShot(page: Page, name: string, options: StableShotOptions = {}): Promise<void> {
await waitForRenderStable(page, options);
await page.screenshot({
e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate Squash of experiment/ff-big-modules vs main. Big-module routing removed: native-EH shrank kicad_editor below SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT crutch are gone — kicad-firefox and kicad-chromium both run the full suite, with the module compiled the way real users' browsers compile it. Per-engine screenshots end to end: stableShot/shotPath write test-results/<engine>/<name>.png; baselines move to baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on <engine>/<name>. Previously Firefox and Chromium renders of one spec overwrote each other and Firefox renders were never actually gated. Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium web-suite shots); manifest generated from the baseline tree. One merged playwright.config.ts (kicad/asyncify/coroutine/perf as projects); ~25 dead npm scripts dropped. The web suite is gated in CI for the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after npm ci; last 26 blind-sleep violations fixed. SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe (--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader transiently failed the first post-board-load draw and the recovery cascade ended in a silent permanent Cairo fallback — that engine flip was the "~1.2% changedRatio both directions" occ-export baseline flake. Validated 160/160 across two 80-repeat rigs; full analysis in docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium baselines shift slightly on llvmpipe — promote once from the first green run. Deflakes the new coverage exposed: presence baselines settle before capture; presence fixtures declare current file formats; perf gets its own outputDir so CI evidence survives; occ-export settles the board paint before the export dialog; menu-item waits (waitForRenderedByLabel before clickMenuItem) in 4 specs + the TESTING.md rule. Web suite runs the PROD build, in parallel: webServer becomes backend `start` + the standalone's e2e:preview (build-preview.mjs: link-wasm → stash the public/wasm symlink aside during vite build, build-demo.mjs's move — then vite preview as the persistent server). The wasm middleware serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread worker script's own response must carry COEP or Chrome kills it with ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time; VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed: shared-page specs become serial groups; locks.spec grabs alice's exact item via the new kicadCollabTestSelectByUuid hook (cross-tab "first footprint" order is not a ysync invariant); quit specs poll page.url() (quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox). Suite: 51 passed / 12 skipped / 0 failed in 1.6m. CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be reachable from the npm scripts the workflows invoke — scraped from .github/workflows/, resolved through package.json, coverage asked from playwright --list itself. Rules: uncovered-spec + orphan-project (with a documented LOCAL_ONLY_PROJECTS allowlist). Gating next to lint:determinism; 138 spec files / 13 projects accounted for. Product fixes kept from the investigations (reachable on real GPUs too): wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush + per-instance font texture + first-frame GL-error drain (GAL recovery recovers instead of falling back to Cairo) + the user-facing eeschema switch navigates again under __EMSCRIPTEN__ (project-sync's FaceRegistered gate had rerouted it into the hidden sync player; caught by the newly-gated web suite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
path: name.includes('/') ? name : shotPath(page, name),
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
fullPage: options.fullPage ?? false,
...SHOT_OPTS,
});
}
e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate Squash of experiment/ff-big-modules vs main. Big-module routing removed: native-EH shrank kicad_editor below SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT crutch are gone — kicad-firefox and kicad-chromium both run the full suite, with the module compiled the way real users' browsers compile it. Per-engine screenshots end to end: stableShot/shotPath write test-results/<engine>/<name>.png; baselines move to baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on <engine>/<name>. Previously Firefox and Chromium renders of one spec overwrote each other and Firefox renders were never actually gated. Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium web-suite shots); manifest generated from the baseline tree. One merged playwright.config.ts (kicad/asyncify/coroutine/perf as projects); ~25 dead npm scripts dropped. The web suite is gated in CI for the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after npm ci; last 26 blind-sleep violations fixed. SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe (--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader transiently failed the first post-board-load draw and the recovery cascade ended in a silent permanent Cairo fallback — that engine flip was the "~1.2% changedRatio both directions" occ-export baseline flake. Validated 160/160 across two 80-repeat rigs; full analysis in docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium baselines shift slightly on llvmpipe — promote once from the first green run. Deflakes the new coverage exposed: presence baselines settle before capture; presence fixtures declare current file formats; perf gets its own outputDir so CI evidence survives; occ-export settles the board paint before the export dialog; menu-item waits (waitForRenderedByLabel before clickMenuItem) in 4 specs + the TESTING.md rule. Web suite runs the PROD build, in parallel: webServer becomes backend `start` + the standalone's e2e:preview (build-preview.mjs: link-wasm → stash the public/wasm symlink aside during vite build, build-demo.mjs's move — then vite preview as the persistent server). The wasm middleware serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread worker script's own response must carry COEP or Chrome kills it with ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time; VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed: shared-page specs become serial groups; locks.spec grabs alice's exact item via the new kicadCollabTestSelectByUuid hook (cross-tab "first footprint" order is not a ysync invariant); quit specs poll page.url() (quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox). Suite: 51 passed / 12 skipped / 0 failed in 1.6m. CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be reachable from the npm scripts the workflows invoke — scraped from .github/workflows/, resolved through package.json, coverage asked from playwright --list itself. Rules: uncovered-spec + orphan-project (with a documented LOCAL_ONLY_PROJECTS allowlist). Gating next to lint:determinism; 138 spec files / 13 projects accounted for. Product fixes kept from the investigations (reachable on real GPUs too): wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush + per-instance font texture + first-frame GL-error drain (GAL recovery recovers instead of falling back to Cairo) + the user-facing eeschema switch navigates again under __EMSCRIPTEN__ (project-sync's FaceRegistered gate had rerouted it into the hidden sync player; caught by the newly-gated web suite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
/**
* Byte-stable screenshot of a locator (or the whole page): capture only once two consecutive
* screenshots are identical the same equality a later "pixels restored" poll asserts, so
* before/after comparisons are self-consistent. Guards against baselining a mid-convergence
* frame the canvas never shows again. Pass the Page when the settled region must include
* wx dialogs/popups that render outside #canvas. (Shared by the presence overlay specs and
* the web gesture/chooser specs.)
*/
export async function settledShot(canvas: Locator | Page, expectImpl: typeof baseExpect = baseExpect): Promise<Buffer> {
let prev = await canvas.screenshot();
await expectImpl
.poll(
async () => {
const next = await canvas.screenshot();
const settled = next.equals(prev);
prev = next;
return settled;
},
{ timeout: 30000, intervals: [250] },
)
.toBe(true);
return prev;
}
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
// ============================================================================
// Label normalization + app-readiness (shared by the kicad and e2e/widget suites)
// ============================================================================
/**
* Normalize a wx label / menu item / tooltip for matching: drop the '&' accelerator
* marker and any trailing "..." / "…" / whitespace, so "Open", "Open...", "Open…" and
* "&Open..." all compare equal. Lets a spec name the ONE canonical label and match it
* regardless of the build's ellipsis rendering replacing "try A, else A…, else A"
* fallback chains (which silently mask a real label regression).
*/
export function labelBase(s: string | undefined | null): string {
return (s ?? '').replace(/&/g, '').replace(/[.\s]+$/u, '').trim();
}
/**
* Wait until a wx widget-harness app is interactive: the emscripten canvas is visible and
* the element registry has registered its widgets. The e2e counterpart to
* waitForEditorReady (no toolbar requirement widget demos have no toolbar). Replaces the
* `tryLoadApp()` + fixed 500ms settle + `waitForRegistry()` trio with one deterministic
* wait that FAILS LOUDLY (not a swallowed boolean). Only for registry-backed harnesses.
*/
export async function waitForWxApp(
page: Page,
options: { timeout?: number; selector?: string } = {}
): Promise<void> {
const timeout = options.timeout ?? 30000;
const selector = options.selector ?? '#canvas';
await page.locator(selector).waitFor({ state: 'visible', timeout });
await waitUntil(
page,
() => {
const r = window.wxElementRegistry;
return !!r && typeof r.findAll === 'function' && r.findAll({}).length > 0;
},
'wx element registry populated',
{ timeout }
);
}
/**
* Wait until a registry-LESS wx canvas app (e.g. the drag-drop / GAL harnesses that draw to
* #canvas but register no widgets) is interactive: the canvas is visible and its pixels have
* settled. The deterministic counterpart to waitForWxApp for apps whose wxElementRegistry
* never populates replaces `tryLoadApp` + a fixed 500ms settle with a real paint-settle.
*/
export async function waitForCanvasApp(
page: Page,
options: { timeout?: number; selector?: string } = {}
): Promise<void> {
const timeout = options.timeout ?? 30000;
const selector = options.selector ?? '#canvas';
await page.locator(selector).waitFor({ state: 'visible', timeout });
await waitForCanvasStable(page, selector, { timeout });
}
/**
* Wait for a rendered menu item whose text matches `base` (ignoring '&' and a trailing
* "..."/"…"), then click it; throw if it never appears. Collapses the
* `clickMenuItem('Open...') || clickMenuItem('Open…') || clickMenuItem('Open')` fallback
* chains AND the fixed post-menu-open sleep into one deterministic, loud call.
*/
export async function clickMenuItemByText(
page: Page,
base: string,
options: { timeout?: number } = {}
): Promise<void> {
const timeout = options.timeout ?? 15000;
const want = labelBase(base);
// Match an ENABLED menu item (mirrors the old clickMenuItem, which returned false for a
// present-but-disabled item — so a disabled target still fails loudly).
await waitUntil(
page,
(w: string) => {
const norm = (s: string) => (s || '').replace(/&/g, '').replace(/[.\s]+$/u, '').trim();
const r = window.wxElementRegistry;
if (!r || !r.findAllRendered) return false;
return r.findAllRendered({ elementType: 'menuitem' })
.some((m) => norm(m.label || '') === w && m.enabled !== false);
},
`enabled menu item "${base}"`,
{ timeout, arg: want }
);
const pos = await page.evaluate((w: string) => {
const norm = (s: string) => (s || '').replace(/&/g, '').replace(/[.\s]+$/u, '').trim();
const r = window.wxElementRegistry!;
const hit = r.findAllRendered!({ elementType: 'menuitem' })
.find((m) => norm(m.label || '') === w && m.enabled !== false);
return hit ? { x: hit.centerX, y: hit.centerY } : null;
}, want);
if (!pos) throw new Error(`clickMenuItemByText: "${base}" vanished after appearing`);
await page.mouse.click(pos.x, pos.y);
}
/**
* Focus the emscripten canvas by clicking its centre; asserts the canvas is visible and
* has a layout box (throws loudly) instead of the `if (box) click` defensive skip that was
* copy-pasted across specs.
*/
export async function focusCanvas(page: Page, selector = '#canvas'): Promise<void> {
const loc = page.locator(selector);
await loc.waitFor({ state: 'visible', timeout: 30000 });
const box = await loc.boundingBox();
if (!box) throw new Error(`focusCanvas: ${selector} has no bounding box`);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}