pcbjam/tests/e2e/aui-resize.spec.ts
Viktor Vaczi 63ed1f3c1f 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:21:54 +02:00

260 lines
12 KiB
TypeScript

// wxAuiManager dock-sash RESIZE UX tests (pcbjam#20).
//
// KiCad's dockable side panels are managed by wxAuiManager; resizing one means
// dragging its dock sash. This spec pins the three reported UX bugs on the
// faithful surface (the default-flag wxAuiManager in aui_test.cpp, exactly how
// KiCad docks panels):
// 1. Hovering the sash must show an east-west resize cursor.
// 2. The pane must resize LIVE during the drag (preview = the real content).
// 3. Dragging must NOT paint grey XOR "resize hint" streaks on the pane.
//
// These are RED before the wasm-layer fix and GREEN after it. See
// features/kicad-resize-panel / docs and the plan for the fix details.
//
// Determinism: readiness via waitForWxApp (loud). The hover-cursor and the
// live-resize width are real observables, so we poll them (same conditions the
// assertions check) instead of sleeping. The remaining waits are genuine
// interaction dwells around mouse press/drag-commit with no positive observable,
// kept and documented. Static hover / mid-drag states use stableShot; the
// two stipple frames feed a functional pixel-diff so they stay Buffer-only.
import { test, expect, waitForWxApp, MAIN_CANVAS } from './utils/fixtures';
import {
findRenderedByType,
findAuiPaneContent, stableShot, shotPath } from './utils/element-tracker';
import type { Page } from '@playwright/test';
const APP = '/standalone/aui/aui_test.html';
const RESIZE_CURSORS = ['ew-resize', 'col-resize', 'e-resize', 'w-resize'];
interface SashPoint {
x: number;
y: number;
source: 'registry-sash' | 'geometry-gap';
}
/**
* Locate the LEFT (Properties) dock sash — the vertical divider between the
* Properties pane and the center Event Log. Two-tier and deterministic:
* 1. Prefer a registry 'sash' element (AUI dock sashes publish as 'sash').
* 2. Fall back to the geometric gap between the Properties content's right
* edge and the center content's left edge.
*/
async function locateLeftSash(page: Page): Promise<SashPoint> {
const propsContent = await findAuiPaneContent(page, 'Properties');
const centerContent = await findAuiPaneContent(page, 'Event Log');
// Tier 1: registry sash, vertical, nearest the Properties pane's right edge.
const sashes = await findRenderedByType(page, 'sash');
const vertical = sashes.filter((s) => s.height > s.width);
if (vertical.length && propsContent) {
const rightEdge = propsContent.screenX + propsContent.width;
const candidates = vertical
.filter((s) => s.centerX >= rightEdge - 8)
.sort(
(a, b) =>
Math.abs(a.centerX - rightEdge) - Math.abs(b.centerX - rightEdge)
);
const sash = candidates[0] ?? vertical.sort((a, b) => a.centerX - b.centerX)[0];
if (sash) {
return { x: Math.round(sash.centerX), y: Math.round(sash.centerY), source: 'registry-sash' };
}
}
// Tier 2: geometry gap between Properties and center content.
if (propsContent && centerContent) {
const x = (propsContent.screenX + propsContent.width + centerContent.screenX) / 2;
const y = propsContent.centerY;
return { x: Math.round(x), y: Math.round(y), source: 'geometry-gap' };
}
throw new Error('Could not locate the Properties dock sash (no registry sash, no pane content)');
}
/** Read the CSS cursor of the element under (x,y), falling back to #canvas. */
async function readCursorAt(page: Page, x: number, y: number): Promise<string> {
return page.evaluate(
({ x, y, canvasSel }) => {
const hit = document.elementFromPoint(x, y) as HTMLElement | null;
const el = hit ?? (document.querySelector(canvasSel) as HTMLElement | null);
return el ? getComputedStyle(el).cursor : '<no-element>';
},
{ x, y, canvasSel: MAIN_CANVAS }
);
}
/**
* Fraction of a screenshot region covered by the XOR "resize hint" stipple — the
* broken grey-lines artifact. wxAuiManager paints the non-live resize hint with
* `wxPaneCreateStippleBitmap` under `wxXOR`; on a Canvas2D surface the XOR neither
* composites nor erases, so a 1px black/light checkerboard is smeared across the
* pane (and left behind after release). Its signature is a near-black pixel
* FLANKED by light pixels on both immediate sides (a 1px-period stipple) — which
* solid panels (no black) and text (contiguous black runs) do not produce.
*
* Calibrated: striped frames read ~0.12, clean frames ~0.0013 — see the spec
* header. NB: relies on 1:1 CSS-pixel screenshots (`scale: 'css'`, dpr 1).
*/
async function stippleFraction(
page: Page,
png: Buffer,
region: { x: number; y: number; w: number; h: number }
): Promise<number> {
return page.evaluate(
async ({ b64, region }) => {
const img = new Image();
img.src = `data:image/png;base64,${b64}`;
await img.decode();
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
const ctx = c.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const x0 = Math.max(1, Math.round(region.x));
const x1 = Math.min(img.width - 1, Math.round(region.x + region.w));
const y0 = Math.max(0, Math.round(region.y));
const y1 = Math.min(img.height, Math.round(region.y + region.h));
if (x1 <= x0 || y1 <= y0) return 0;
const data = ctx.getImageData(0, 0, img.width, img.height).data;
const luma = (i: number) => 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
let hits = 0;
let total = 0;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
total++;
const i = (y * img.width + x) * 4;
if (luma(i) < 70) {
const left = luma((y * img.width + (x - 1)) * 4);
const right = luma((y * img.width + (x + 1)) * 4);
if (left > 140 && right > 140) hits++; // black flanked by light => stipple, not text
}
}
}
return total ? hits / total : 0;
},
{ b64: png.toString('base64'), region }
);
}
/**
* Region (CSS px) covering the Properties pane and the sash's drag trail, where
* a narrow-drag XOR stipple would smear. Spans from the pane's left edge to just
* past the (pre-drag) sash position, and includes the caption row above.
*/
async function leftPaneTrailRegion(
page: Page,
sashStartX: number
): Promise<{ x: number; y: number; w: number; h: number }> {
const props = await findAuiPaneContent(page, 'Properties');
if (!props) throw new Error('Properties pane content not found');
const left = Math.max(0, props.screenX);
const top = Math.max(0, props.screenY - 30); // include the caption row above content
const bottom = props.screenY + props.height;
const right = sashStartX + 6;
return { x: left, y: top, w: Math.max(1, right - left), h: bottom - top };
}
async function load(page: Page): Promise<void> {
await page.goto(APP);
await waitForWxApp(page);
}
test.describe('wxAuiManager dock-sash resize UX (pcbjam#20)', () => {
test('hovering the dock sash shows a resize cursor', async ({ page }) => {
await load(page);
const sash = await locateLeftSash(page);
console.log(`[aui-resize] sash via ${sash.source} at (${sash.x},${sash.y})`);
// Move OFF the sash first (into the Properties pane) and confirm no resize cursor.
const props = await findAuiPaneContent(page, 'Properties');
expect(props, 'Properties pane content should be present to test off-sash cursor').not.toBeNull();
await page.mouse.move(props!.centerX, props!.centerY);
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell: let the cursor settle after moving into the pane before reading it (negative assertion, no positive observable to poll)
const offCursor = await readCursorAt(page, props!.centerX, props!.centerY);
console.log(`[aui-resize] cursor off-sash = ${offCursor}`);
expect(RESIZE_CURSORS, 'cursor should NOT be a resize cursor inside the pane').not.toContain(offCursor);
// Hover the sash → resize cursor. Poll the live CSS cursor until it becomes a
// resize cursor (same observable the assertion below checks), replacing the sleep.
await page.mouse.move(sash.x, sash.y);
await expect
.poll(async () => RESIZE_CURSORS.includes(await readCursorAt(page, sash.x, sash.y)), {
message: 'expected a resize cursor over the sash',
})
.toBe(true);
const cursor = await readCursorAt(page, sash.x, sash.y);
console.log(`[aui-resize] cursor on-sash = ${cursor}`);
await stableShot(page, 'aui-resize-01-hover.png');
expect(RESIZE_CURSORS, `expected a resize cursor over the sash, got "${cursor}"`).toContain(cursor);
});
test('dragging the dock sash resizes the pane LIVE (no deferral to mouse-up)', async ({ page }) => {
await load(page);
const sash = await locateLeftSash(page);
const widthOf = async (): Promise<number | null> =>
(await findAuiPaneContent(page, 'Properties'))?.width ?? null;
const before = await widthOf();
expect(before, 'Properties pane width should be readable').not.toBeNull();
await page.mouse.move(sash.x, sash.y);
await page.waitForTimeout(100); // eslint-disable-line -- documented interaction dwell: settle the hover before pressing the sash
await page.mouse.down();
// Widen the pane (move right) without releasing — avoids the MinSize clamp.
await page.mouse.move(sash.x + 80, sash.y, { steps: 8 });
// LIVE preview is deterministic: poll the pane width until it has changed
// mid-drag (same observable the assertion below checks), replacing the sleep.
await expect
.poll(async () => Math.abs(((await widthOf()) ?? 0) - (before ?? 0)), {
message: 'pane should resize live during drag',
})
.toBeGreaterThan(20);
const midDrag = await widthOf(); // read while button STILL held
await stableShot(page, 'aui-resize-02-mid-drag-live.png');
await page.mouse.up();
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell: let the drag-release commit before reading the final width (no positive observable — the good path leaves the width unchanged)
const after = await widthOf();
console.log(`[aui-resize] width before=${before} mid=${midDrag} after=${after}`);
// LIVE: the pane width already changed mid-drag, before release.
expect(Math.abs((midDrag ?? 0) - (before ?? 0)), 'pane should resize live during drag').toBeGreaterThan(20);
// No jump-on-release: the committed width ≈ the mid-drag preview.
expect(Math.abs((after ?? 0) - (midDrag ?? 0)), 'final width should match the live preview').toBeLessThan(25);
});
test('dragging narrower paints no grey XOR stipple (and none left behind)', async ({ page }) => {
await load(page);
const sash = await locateLeftSash(page);
const region = await leftPaneTrailRegion(page, sash.x);
await page.mouse.move(sash.x, sash.y);
await page.waitForTimeout(100); // eslint-disable-line -- documented interaction dwell: settle the hover before pressing the sash
await page.mouse.down();
// Drag NARROWER (the user's specific complaint) in steps; capture an
// intermediate frame while the button is still held.
await page.mouse.move(sash.x - 90, sash.y, { steps: 10 });
await page.waitForTimeout(70); // eslint-disable-line -- documented interaction dwell: let the mid-drag frame paint before capturing it for the pixel-diff
const midShot = await page.screenshot({ path: shotPath(page, 'aui-resize-03-mid-drag.png'), scale: 'css' });
await page.mouse.up();
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: let the release settle before capturing the after frame for the pixel-diff
const afterShot = await page.screenshot({ path: shotPath(page, 'aui-resize-04-after.png'), scale: 'css' });
const midFrac = await stippleFraction(page, midShot, region);
const afterFrac = await stippleFraction(page, afterShot, region);
console.log(`[aui-resize] stipple frac mid=${midFrac.toFixed(4)} after=${afterFrac.toFixed(4)} region=${JSON.stringify(region)}`);
// Calibrated: broken XOR hint reads ~0.12; a clean frame ~0.0013. 0.02 sits
// an order of magnitude away from both.
expect(midFrac, 'no grey XOR resize-hint stipple during drag').toBeLessThan(0.02);
expect(afterFrac, 'no grey XOR stipple left behind after release').toBeLessThan(0.02);
});
});