pcbjam/tests/e2e/modal.spec.ts
Viktor Vaczi 9c475a804e 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

293 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Modal dialog styling + drag tests — regression coverage for pcbjam #22.
//
// A wxWidgets top-level window (every modal dialog) is rendered by the wx.js
// runtime glue as `<div class="window toplevel">` (the page-positioned frame)
// containing a `<canvas class="window-canvas">` that C++ paints into. The main
// app frame is id=0 (the page's #canvas) and is NOT a `.window`, so a visible
// `#window-container .window.toplevel` is exactly a dialog.
//
// Two bugs, each asserted here:
// 1. No border/shadow — the `.window` CSS had no border or box-shadow, so
// dialogs looked flat. Fixed by a `.window.toplevel` rule in the shell
// template (and the production shells).
// 2. Black background on drag — `setWindowRect` unconditionally reset
// `canvas.width/height` on every move, clearing the canvas to transparent;
// the `.window` div's `background-color:black` then showed through with no
// repaint queued. Fixed by only resizing the canvas on a real size change.
import { test, expect, waitForWxApp, clickByLabel, findByType } from './utils/fixtures';
import { stableShot } from './utils/element-tracker';
const DIALOG_APP = '/standalone/dialog/dialog_test.html';
// Selector for the (single) visible modal frame.
const MODAL_SEL = '#window-container .window.toplevel';
// Wait until a visible modal `.window.toplevel` exists, then return its
// page-space rect (the div keeps a real getBoundingClientRect even though it is
// pointer-events:none).
async function waitForModalRect(page: import('@playwright/test').Page) {
await page.waitForFunction(
(sel) =>
Array.from(document.querySelectorAll(sel)).some((w) => {
const el = w as HTMLElement;
return el.style.display !== 'none' && el.getBoundingClientRect().width > 0;
}),
MODAL_SEL,
{ timeout: 5000 }
);
const rect = await page.evaluate((sel) => {
const el = Array.from(document.querySelectorAll(sel)).find((w) => {
const e = w as HTMLElement;
return e.style.display !== 'none' && e.getBoundingClientRect().width > 0;
}) as HTMLElement | undefined;
if (!el) return null;
const r = el.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}, MODAL_SEL);
return rect;
}
// Sample the modal's own canvas and report what fraction of a client-area band
// is opaque (alpha) and non-black. With the drag bug the canvas is cleared to
// transparent (alpha 0) → the black div shows through → opaqueFrac ≈ 0.
async function sampleModalCanvas(page: import('@playwright/test').Page) {
return page.evaluate((sel) => {
const modal = Array.from(document.querySelectorAll(sel)).find((w) => {
const e = w as HTMLElement;
return e.style.display !== 'none' && e.getBoundingClientRect().width > 0;
}) as HTMLElement | undefined;
if (!modal) return null;
const canvas = modal.querySelector('canvas.window-canvas') as HTMLCanvasElement | null;
if (!canvas || !canvas.width || !canvas.height) return null;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Band across the client area, just below the title bar; skip the edges.
const x0 = Math.floor(canvas.width * 0.1);
const y0 = Math.floor(canvas.height * 0.2);
const w = Math.max(1, Math.floor(canvas.width * 0.8));
const h = Math.max(1, Math.floor(canvas.height * 0.25));
const data = ctx.getImageData(x0, y0, w, h).data;
let opaque = 0;
let nonBlack = 0;
const total = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a > 128) {
opaque++;
if (data[i] > 24 || data[i + 1] > 24 || data[i + 2] > 24) nonBlack++;
}
}
return {
total,
opaqueFrac: opaque / total,
nonBlackFrac: nonBlack / total,
canvasW: canvas.width,
canvasH: canvas.height,
};
}, MODAL_SEL);
}
test.describe('Modal dialog border + drag (pcbjam #22)', () => {
test('modal has a visible border and shadow', async ({ page, testLogger }) => {
await page.goto(DIALOG_APP);
await waitForWxApp(page);
await clickByLabel(page, 'Custom Dialog');
const rect = await waitForModalRect(page);
expect(rect, 'modal should be visible').not.toBeNull();
await stableShot(page, 'modal-01-border.png', { fullPage: true });
const style = await page.evaluate((sel) => {
const el = Array.from(document.querySelectorAll(sel)).find((w) => {
const e = w as HTMLElement;
return e.style.display !== 'none' && e.getBoundingClientRect().width > 0;
}) as HTMLElement | undefined;
if (!el) return null;
const cs = getComputedStyle(el);
return {
borderTopStyle: cs.borderTopStyle,
borderTopWidth: cs.borderTopWidth,
boxShadow: cs.boxShadow,
};
}, MODAL_SEL);
expect(style, 'modal element should exist').not.toBeNull();
testLogger.consoleLogs.push(`[MODAL_BORDER] ${JSON.stringify(style)}`);
const hasBorder = style!.borderTopStyle !== 'none' && parseFloat(style!.borderTopWidth) > 0;
const hasShadow = style!.boxShadow !== 'none' && style!.boxShadow !== '';
expect(hasBorder, `expected a visible border, got ${JSON.stringify(style)}`).toBe(true);
expect(hasShadow, `expected a box-shadow, got ${JSON.stringify(style)}`).toBe(true);
});
test('modal background stays painted (not black) after drag', async ({ page, testLogger }) => {
await page.goto(DIALOG_APP);
await waitForWxApp(page);
// The bare test shell lays out #window-container BELOW the full-size main
// canvas, so the modal (and its DOM title bar) render off the bottom of the
// viewport — the real app shells overlay it. Overlay it here so the DOM title
// bar is reachable by the pointer (the old canvas bar was reached indirectly
// via #canvas registry coords; the DOM bar must be clicked where it renders).
await page.evaluate(() => {
const wc = document.getElementById('window-container');
if (wc) {
wc.style.position = 'absolute';
wc.style.top = '0';
wc.style.left = '0';
}
});
await clickByLabel(page, 'Custom Dialog');
await waitForModalRect(page); // wait for the .window.toplevel to exist
// The modal's INPUT model and VISUAL position are decoupled: the wasm places
// the dialog at its registry screen coords (mouse events route through the
// full-viewport #canvas), while the `.window` div renders lower because
// #window-container flows below #main-window. So grab the title bar at the
// registry coords, NOT getBoundingClientRect.
const dlgBefore = (await findByType(page, 'wxDialog'))[0];
expect(dlgBefore, 'dialog should be in the registry').toBeTruthy();
// Sanity: the freshly painted modal canvas is opaque.
const beforeStats = await sampleModalCanvas(page);
testLogger.consoleLogs.push(
`[MODAL_DRAG] before=${JSON.stringify(beforeStats)} dlg=${JSON.stringify(dlgBefore)}`
);
await stableShot(page, 'modal-02-before-drag.png', { fullPage: true });
// The dialog now drags via its real DOM title bar (`.window-titlebar`):
// pointer events on it → wx_window_move → wxWindow::Move. So grab the element
// at its actual on-screen position (getBoundingClientRect), NOT the registry
// coords the old canvas title bar needed. Center is over the title text, clear
// of the close × at the right.
const titlebar = page.locator(`${MODAL_SEL} .window-titlebar`);
const tbox = await titlebar.boundingBox();
expect(tbox, 'modal should have a DOM title bar').not.toBeNull();
const startX = tbox!.x + tbox!.width / 2;
const startY = tbox!.y + tbox!.height / 2;
await page.mouse.move(startX, startY);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell (pointer settle before grabbing the title bar)
await page.mouse.down();
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell (press commit before the drag begins)
// Drag in many small steps, sampling the modal canvas immediately after each
// move. Each move calls setWindowRect, which clears the canvas; the dialog's
// own content is NOT marked for repaint by a move (DoMoveWindow only
// refreshes the parent), so with the bug the canvas stays transparent and
// the black `.window` div shows through. We track the WORST (minimum) opaque
// fraction seen across the drag.
let minOpaque = 1;
let lowFrames = 0;
const STEPS = 16;
for (let i = 1; i <= STEPS; i++) {
await page.mouse.move(startX + i * 4, startY + i * 3);
const s = await sampleModalCanvas(page);
if (s) {
if (s.opaqueFrac < minOpaque) minOpaque = s.opaqueFrac;
if (s.opaqueFrac < 0.5) lowFrames++;
}
}
await page.mouse.up();
const dlgAfter = (await findByType(page, 'wxDialog'))[0];
const afterStats = await sampleModalCanvas(page);
const moved = dlgAfter
? Math.abs(dlgAfter.screenX - dlgBefore.screenX) + Math.abs(dlgAfter.screenY - dlgBefore.screenY)
: 0;
testLogger.consoleLogs.push(
`[MODAL_DRAG] minOpaque=${minOpaque} lowFrames=${lowFrames}/${STEPS} after=${JSON.stringify(afterStats)} moved=${moved} dlgAfter=${JSON.stringify(dlgAfter)}`
);
await stableShot(page, 'modal-03-after-drag.png', { fullPage: true });
// Sanity: the drag must actually have moved the modal, otherwise the
// black-background assertion below is meaningless (the bug only triggers on
// a real move).
expect(moved, 'drag did not move the modal — title-bar grab failed').toBeGreaterThan(10);
// With the bug: the canvas is cleared on each move and shows black before any
// repaint → minOpaque drops toward 0. After the fix: the canvas is never
// cleared on a position-only move, so it stays painted throughout.
expect(
minOpaque,
`modal canvas went transparent/black during drag (minOpaque=${minOpaque}, lowFrames=${lowFrames})`
).toBeGreaterThan(0.8);
});
test('modal background stays painted (not black) after resize', async ({ page, testLogger }) => {
await page.goto(DIALOG_APP);
await waitForWxApp(page);
// As above: the bare shell lays out #window-container below the fold, so overlay
// it at the origin to make the modal's DOM resize handles reachable by the pointer.
await page.evaluate(() => {
const wc = document.getElementById('window-container');
if (wc) {
wc.style.position = 'absolute';
wc.style.top = '0';
wc.style.left = '0';
}
});
await clickByLabel(page, 'Custom Dialog');
await waitForModalRect(page);
// The Custom dialog now carries wxRESIZE_BORDER, so it has DOM resize handles.
const handle = page.locator(`${MODAL_SEL} .window-resize-se`);
const hbox = await handle.boundingBox();
expect(hbox, 'resizable modal should have a se resize handle').not.toBeNull();
const beforeStats = await sampleModalCanvas(page);
testLogger.consoleLogs.push(`[MODAL_RESIZE] before=${JSON.stringify(beforeStats)}`);
await stableShot(page, 'modal-04-before-resize.png', { fullPage: true });
// Grab the bottom-right corner and grow the dialog in small steps, sampling the
// modal canvas immediately after each move. A resize legitimately reassigns
// canvas.width/height (clears it); inside the modal's suspended pump the repaint
// that should refill it is deferred until the next input event, so with the bug
// the canvas stays transparent and the black .window div shows through.
const startX = hbox!.x + hbox!.width / 2;
const startY = hbox!.y + hbox!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell (press commit before the resize drag begins)
let minOpaque = 1;
let lowFrames = 0;
const STEPS = 16;
for (let i = 1; i <= STEPS; i++) {
await page.mouse.move(startX + i * 5, startY + i * 4);
const s = await sampleModalCanvas(page);
if (s) {
if (s.opaqueFrac < minOpaque) minOpaque = s.opaqueFrac;
if (s.opaqueFrac < 0.5) lowFrames++;
}
}
await page.mouse.up();
const afterStats = await sampleModalCanvas(page);
testLogger.consoleLogs.push(
`[MODAL_RESIZE] minOpaque=${minOpaque} lowFrames=${lowFrames}/${STEPS} after=${JSON.stringify(afterStats)}`
);
await stableShot(page, 'modal-05-after-resize.png', { fullPage: true });
// Sanity: the resize actually grew the modal canvas (otherwise the assertion
// below is meaningless — the corner grab must have taken effect).
expect(
afterStats && beforeStats && afterStats.canvasW > beforeStats.canvasW,
`resize did not grow the modal canvas (before=${beforeStats?.canvasW}, after=${afterStats?.canvasW})`
).toBe(true);
// With the bug: the canvas is cleared on each resize and shows black until the
// deferred modal repaint flushes (only on the next click) → minOpaque ≈ 0.
// After the fix (wx_window_resize forces a synchronous repaint): it stays painted.
expect(
minOpaque,
`modal canvas went transparent/black during resize (minOpaque=${minOpaque}, lowFrames=${lowFrames})`
).toBeGreaterThan(0.8);
});
});