pcbjam/tests/kicad/3d-viewer.spec.ts
Istvan Matejcsok c421d724b0 findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work
A review of the group-E fixes found 13 further defects; ten were introduced by
those fixes, two pre-existed and were merely relocated, one is deferred.

Services / transport
  E-10  retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning
        mirror stayed latched true after a mid-run worker death: Run stayed
        disabled and the promised fresh-worker restart was unreachable for the
        whole session. Retirement now dispatches a synthetic controlled-exit
        straight to the installed handler (never through dispatchEvt — a
        fabricated frame must not touch the credit ledger). Driving the repro
        exposed two further defects, both fixed here: a replacement worker
        trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit
        that uninitialized engine before KiCad's validate() re-init (the native
        flow assumes a crashed engine survives in-process — true for the dll,
        false for a dead worker). Reads now answer their empty shapes pre-init,
        writes lazy-init, and init is idempotent per worker engine.
  E-19  dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice
        client deliberately rethrows non-trap errors — so each throw leaked one
        unit of the 64-frame credit window until the stream died with a
        misattributed "transport exceeded". The ack moves to a finally in both
        service copies; the throw still propagates (the trap machinery needs it).
  E-20  the oversize-line path promises to transfer the accepted prefix, but
        with the window full that flush only DEFERS, and stopEventStream wiped
        the deferred queue — losing the diagnostics that explain the failure.
        The terminal notice now carries them as pendingEvents; both hosts
        deliver them in order, unacked (the fatal frame is outside the credit
        protocol).
  E-21  the 30s prefetch deadline discarded every model already collected and
        reported nothing. A caller-owned progress sink ships the partials and
        the omission reaches the export report. (Awaiting the aborted collection
        was rejected: an in-flight source fetch is not abortable — E-4's
        original disease.) Plus a serving-candidate memo, so a .wrl ref served
        by its .step fallback stops re-probing the miss on every export.

Scheduler
  E-14  _terminalizeNativeTrap classified by message substring, so any plain JS
        error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a
        healthy instance. Now structural only: instanceof RuntimeError plus a
        duck-typed name check (verified in this build's glue that abort() throws
        a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort
        now latches the gate — the authoritative notification, previously
        ignored.
  E-15  the shim half: _pumpResume gates on terminal (catching wakes already
        queued at latch time) and resolveWait refuses on terminal WITHOUT
        consuming the entry, so a frame stays visibly parked rather than
        resuming inside a trapped module.
  E-16  the E-5 handler read the realm-global scheduler at dispatch instead of
        its installing module's; also frees the per-line buffer on the non-trap
        rethrow path.
  E-11  get_vec trusted the worker's res.length over the transferred arrays.
        Observed death shape: a 4 GiB std::vector threw an unhandled
        std::length_error that exited the editor's main loop. Now clamped, with
        the buffers freed on every failure path.

Guardrails (replacing two deferred refactors: e2e→production-code injection and
collapsing the four copies of the worker-lifecycle machinery)
  E-18  the source contract asserted comment-string counts — rewording failed
        CI while moving a guard outside its #ifdef passed. It now parses the
        #ifdef regions and asserts on code.
        service-stub-parity.ts pins what the four lifecycle copies must share:
        credit-window equality parsed from source, the finally-ack, boot
        deadlines, terminal-notice consumption. The transport numbers are now
        single-sourced from the worker.
        CI actually runs the gates: the web/standalone vitest suites (which had
        NEVER run in CI), the reducer, the source contract and the parity tool —
        with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint.
  E-22  the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in
        the ngspice-stub shape with a wedgeNextBoot() repro hook.

Every behavioral fix has red-then-green evidence (the reds were captured first).
E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's
transport deferral) is DEFERRED with its analysis recorded — a real fix needs
run identity on the bg frames.

Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare
markers carry their reason; three export-report dwells became modal-lease polls;
exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch,
an unused fault hook and unused receipt plumbing are gone; abort scans, wx
dialog drivers, the sim harness and the vitest FakeWorker are each one copy now.

Bumps kicad and wxwidgets to their findings-group-e tips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 18:19:16 +02:00

367 lines
21 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.

import { test, expect } from './fixtures';
import { shotPath } from '../e2e/utils/element-tracker';
import { waitForPcbnew } from './utils/pcbnew-ready';
import { DEMO, loadBoard, countGlCanvases, logThreeDDiag, openThreeDViewer, waitForThreeDRender }
from './utils/threed-viewer';
/**
* 3D viewer e2e: load a real board in pcbnew, open the native 3D viewer
* (View → 3D Viewer / Alt+3), and verify a second top-level frame with its own
* WebGL canvas appears and renders the board.
*
* The 3D viewer (EDA_3D_VIEWER_FRAME) is a separate modeless KIWAY_PLAYER frame.
* In the wasm DOM port that surfaces as a new `#window-1` div in
* `#window-container` plus a dedicated `<canvas id="glcanvas-N">` (the main
* pcbnew board view is itself a wxGLCanvas, so we detect the viewer by the
* GL-canvas COUNT increasing, not by mere presence).
*
* Enabled by the WASM 3D-viewer build (BUILD_3D_VIEWER=ON →
* KICAD_BUILD_3D_VIEWER_WASM). With the bare board (no component STEP/WRL
* models — deferred), the viewer shows copper/silk/mask/edge geometry in 3D.
*/
test.describe('3D viewer from pcbnew', () => {
// One 187 MB wasm runtime is already heavy; keep this serial and generous.
test.describe.configure({ mode: 'serial' });
test.setTimeout(240000);
test('opens the 3D viewer over a loaded board and renders it', async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForPcbnew(page);
await loadBoard(page, testLogger);
await page.screenshot({ path: shotPath(page, `3d-viewer-00-board-loaded.png`), scale: 'css' });
const glBefore = await countGlCanvases(page);
console.log(`[TEST] glcanvas count before opening 3D viewer: ${glBefore}`);
await openThreeDViewer(page, glBefore);
// Gate on the scene actually being ON the canvas, not a fixed sleep: the first frame
// can lag the canvas creation (CI software WebGL under parallel load), and sampling
// too early reads an all-black backbuffer — main's live 3D flake (see
// waitForThreeDRender).
await waitForThreeDRender(page);
await page.screenshot({ path: shotPath(page, `3d-viewer-${DEMO.name}.png`), scale: 'css' });
// Read the 3D viewer canvas (the newest glcanvas) directly from its backing
// store: copy it onto a 2D canvas with drawImage and sample pixels. This is
// reliable thanks to preserveDrawingBuffer=true, whereas Playwright's CDP
// screenshot of a WebGL canvas on swiftshader comes back blank. We save the
// copy as a PNG (the real visual artifact) and assert the board rendered by
// checking the canvas is not a single uniform colour.
const render = await page.evaluate(() => {
const list = document.querySelectorAll('canvas[id^="glcanvas-"]');
const el = list[list.length - 1] as HTMLCanvasElement;
const tmp = document.createElement('canvas');
tmp.width = el.width;
tmp.height = el.height;
// willReadFrequently → CPU-backed 2D canvas: drawImage is the single GPU readback;
// the sampling below never touches the GPU process again. (The previous 16×16 loop
// of 1×1 getImageData calls = 256 GPU syncs per sample — the "GPU stall due to
// ReadPixels" storm that got SwiftShader's GPU process watchdog-killed on CI.)
const ctx = tmp.getContext('2d', { willReadFrequently: true })!;
ctx.drawImage(el, 0, 0);
const img = ctx.getImageData(0, 0, el.width, el.height).data;
const colors = new Set<string>();
for (let i = 0; i < 16; i++) {
for (let j = 0; j < 16; j++) {
const p = (Math.floor(el.height * j / 16) * el.width
+ Math.floor(el.width * i / 16)) * 4;
colors.add(`${img[p]},${img[p + 1]},${img[p + 2]}`);
}
}
return { id: el.id, w: el.width, h: el.height, distinctColors: colors.size,
dataUrl: tmp.toDataURL('image/png') };
});
console.log(`[TEST] 3D canvas ${render.id} ${render.w}x${render.h}, distinct colours: ${render.distinctColors}`);
const b64 = render.dataUrl.replace(/^data:image\/png;base64,/, '');
require('fs').writeFileSync(shotPath(page, `3d-viewer-${DEMO.name}-render.png`),
Buffer.from(b64, 'base64'));
// A blank/uniform canvas yields ~1 colour; the rendered board has many.
expect(render.distinctColors,
'the 3D viewer canvas should render the board (many colours), not a blank fill')
.toBeGreaterThan(8);
// ── Console-clean gates (same signatures load-pcb.spec.ts guards). ──
const allLines = [...testLogger.consoleLogs, ...testLogger.errors];
const aborts = allLines.filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted while opening the 3D viewer:\n${aborts.join('\n\n')}`).toEqual([]);
const wasmTrapSignatures = [
'index out of bounds', 'indirect call to null', 'uncaught exception: unwind',
'invalid state', 'is not a function',
];
const wasmTrapErrors = allLines.filter((l) =>
wasmTrapSignatures.some((sig) => l.toLowerCase().includes(sig)));
expect(wasmTrapErrors,
`wasm trap surfaced opening the 3D viewer:\n${wasmTrapErrors.join('\n\n')}`)
.toEqual([]);
// The 3D viewer stub logs this when the real viewer is NOT compiled in —
// its presence means BUILD_3D_VIEWER=ON didn't take effect.
const stubbed = allLines.filter((l) => l.includes('3D Viewer is not available'));
expect(stubbed,
`3D viewer is still stubbed (build not enabled?):\n${stubbed.join('\n')}`).toEqual([]);
});
/**
* Regression for the "two canvases over each other" bug.
*
* The wasm DOM port draws each window's chrome onto 2D canvases and reveals a
* wxGLCanvas through it. The CPU raytracer rendered the board correctly into the
* 3D viewer's own `glcanvas-*`, but on screen the user saw grey: the 3D viewer is
* a SECONDARY top-level frame, and when it is shown wx.js `raiseWindow()` lifts its
* opaque `#window-N` chrome div to z-index 101 (= the main GAL canvas's 100, + 1).
* Every `glcanvas-*` was hard-coded to z-index 100, so the frame's own window
* background painted OVER the GL canvas behind it — two canvases stacked, the
* opaque one on top. (The main editor escapes this because its window keeps
* `#canvas` transparent over the GAL region; a secondary frame's region is opaque.)
*
* The fix (wx.js `createGLCanvas`): a GL canvas created while another GL canvas is
* already visible belongs to a secondary window → lift it to z-index 2147483647 so
* it stacks above the chrome.
*
* This asserts the stacking straight from the DOM/computed-style — no pixels or
* screenshots — so it is independent of the slow raytrace and CI-safe on swiftshader
* (where WebGL-canvas screenshots come back blank). Note `glcanvas-*`, `#window-N`
* and `.window-canvas` all have `pointer-events:none`, so `elementsFromPoint` can't
* see them; computed z-index is the right tool.
*
* Before the fix: every `glcanvas-*` is z-index 100, so the viewer canvas is not
* strictly above the main GAL canvas (100 > 100 is false) → FAILS.
* After the fix: the viewer canvas is 2147483647 → PASSES.
*/
test('reveals the 3D viewer GL canvas above the window chrome (regression: occluded board)',
async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForPcbnew(page);
await loadBoard(page, testLogger);
const glBefore = await countGlCanvases(page);
await openThreeDViewer(page, glBefore);
// The z-index is assigned synchronously when the canvas is created, but wait
// until the newest glcanvas is actually on-screen (setGLCanvasRect ran →
// display:block, non-zero box) so its secondary frame's window-N div has been
// raised by raiseWindow() and the DOM stacking has settled.
await page.waitForFunction((before: number) => {
const list = document.querySelectorAll<HTMLCanvasElement>('canvas[id^="glcanvas-"]');
if (list.length <= before) return false;
const viewer = list[list.length - 1];
return getComputedStyle(viewer).display !== 'none'
&& viewer.getBoundingClientRect().width > 0;
}, glBefore, { timeout: 60000 });
// Stacking order inside #window-container (a single z-index:1 stacking context).
// The 3D viewer canvas is the newest glcanvas-*; it must out-stack every other
// GL canvas and every secondary window-N chrome div, or the opaque chrome occludes it.
const stacking = await page.evaluate(() => {
const z = (el: Element) => parseInt(getComputedStyle(el).zIndex, 10) || 0;
const gls = Array.from(document.querySelectorAll('#window-container canvas[id^="glcanvas-"]'));
const windows = Array.from(document.querySelectorAll('#window-container [id^="window-"]'));
const viewer = gls[gls.length - 1];
return {
glCount: gls.length,
viewerId: viewer ? viewer.id : null,
viewerZ: viewer ? z(viewer) : 0,
maxOtherGlZ: Math.max(0, ...gls.slice(0, -1).map(z)),
maxWindowZ: Math.max(0, ...windows.map(z)),
glZ: gls.map((c) => ({ id: c.id, z: z(c) })),
windowZ: windows.map((w) => ({ id: w.id, z: z(w) })),
};
});
console.log(`[TEST] stacking: ${JSON.stringify(stacking)}`);
// Precondition: the viewer actually opened (main GAL canvas + viewer canvas).
expect(stacking.glCount,
'the 3D viewer should add a second WebGL canvas').toBeGreaterThanOrEqual(2);
// THE regression assertion. Pre-fix every glcanvas-* shares z-index 100, so the
// viewer canvas is not strictly above the main GAL canvas and the chrome occludes it.
expect(stacking.viewerZ,
`3D viewer canvas ${stacking.viewerId} (z=${stacking.viewerZ}) must stack strictly above `
+ `the other GL canvases (max z=${stacking.maxOtherGlZ}); an equal z-index means the `
+ `window chrome can occlude it. all=${JSON.stringify(stacking.glZ)}`)
.toBeGreaterThan(stacking.maxOtherGlZ);
// The user-visible symptom: the GL canvas must paint at or above every secondary
// window's opaque 2D chrome. (>= not > to tolerate a window clamped to the same
// 2147483647 CSS z-index ceiling by raiseWindow.)
expect(stacking.viewerZ,
`3D viewer canvas (z=${stacking.viewerZ}) must not sit below any window chrome `
+ `(max window z=${stacking.maxWindowZ}). windows=${JSON.stringify(stacking.windowZ)}`)
.toBeGreaterThanOrEqual(stacking.maxWindowZ);
// Sanity: opening the viewer didn't blow up the runtime.
const aborts = [...testLogger.consoleLogs, ...testLogger.errors]
.filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted while opening the 3D viewer:\n${aborts.join('\n\n')}`).toEqual([]);
});
/**
* Regression gate for "the 3D viewer can't be dragged / can't be X-closed".
*
* The viewer is a non-main wxFrame; it now gets a real DOM `.window-titlebar`
* (drag + `.window-titlebar-close`) instead of a canvas-painted, pointer-
* events:none bar whose clicks an overlapping main-frame DOM control stole.
* This drives the REAL viewer: it must have a DOM title bar, that bar must be
* the top hit-test element at its own location (even with the main editor's
* pointer-events:auto controls present), dragging it must move the frame, and
* the × must close it.
*/
test('real 3D viewer has a draggable, X-closable DOM title bar', async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForPcbnew(page);
await loadBoard(page, testLogger);
const winsBefore = await page.evaluate(() =>
Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id));
const glBefore = await countGlCanvases(page);
await openThreeDViewer(page, glBefore);
// Wait for the new top-level window div to appear (replaces a fixed 1500ms).
await expect.poll(async () => page.evaluate((before: string[]) => {
const all = Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id);
return all.find((id) => !before.includes(id)) ?? null;
}, winsBefore), { timeout: 60000, intervals: [300] }).not.toBeNull();
const winId = await page.evaluate((before: string[]) => {
const all = Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id);
return all.find((id) => !before.includes(id)) ?? all[all.length - 1] ?? null;
}, winsBefore);
expect(winId, 'the 3D viewer should open a new top-level window').toBeTruthy();
await page.screenshot({ path: shotPath(page, '3d-viewer-titlebar.png'), scale: 'css' });
// It must have a real DOM title bar (the frames-only fix covers the viewer).
const bar = page.locator(`#${winId} .window-titlebar`);
expect(await bar.count(), 'the 3D viewer (a wxFrame) should have a DOM title bar').toBe(1);
// Root-cause check: the title bar is the top hit-test element at its own
// location, despite the main pcbnew frame's pointer-events:auto controls
// (which used to intercept and break drag/close).
const box = await bar.boundingBox();
expect(box, 'the title bar should have a layout box').not.toBeNull();
const cx = box!.x + box!.width / 2;
const cy = box!.y + box!.height / 2;
const topEl = await page.evaluate(([x, y]) => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
return el ? el.className.toString() : 'null';
}, [cx, cy]);
expect(topEl, `the top element at the title bar must be the title bar (was "${topEl}")`)
.toContain('window-titlebar');
// Drag the title bar → the frame moves (wx_window_move → wxWindow::Move).
const styleTop = (wid: string) =>
page.evaluate((id) => {
const el = document.getElementById(id) as HTMLElement | null;
return el ? (parseInt(el.style.top || '0', 10) || 0) : null;
}, wid);
const beforeTop = await styleTop(winId as string);
await page.mouse.move(cx, cy);
await page.mouse.down();
await page.mouse.move(cx, cy + 80, { steps: 10 });
await page.mouse.up();
// Let the frame-move op (wx_window_move → wxWindow::Move) fully settle before the
// next interaction: the DOM style.top updates before the wx-side op completes, so
// polling the outcome races the following close click (documented interaction dwell).
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: frame-move op settle; polling races the close click
const afterTop = await styleTop(winId as string);
expect(afterTop, 'dragging the title bar should move the 3D viewer frame').not.toBe(beforeTop);
// Close via the × (wx_window_close → wx Close() → OnCloseWindow).
await page.locator(`#${winId} .window-titlebar-close`).click();
await page.waitForTimeout(600); // eslint-disable-line -- documented interaction dwell: wx Close() commit before checking the frame is gone
const gone = await page.evaluate((wid) => {
const el = document.getElementById(wid);
return !el || getComputedStyle(el).display === 'none';
}, winId);
expect(gone, 'the × should close the 3D viewer').toBe(true);
// Opening/dragging/closing didn't blow up the runtime.
const aborts = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted during the title-bar test:\n${aborts.join('\n\n')}`).toEqual([]);
});
/*
* Edge-resize gate for the real 3D viewer.
*
* The viewer (a wxFrame with wxRESIZE_BORDER) now gets DOM edge-resize handles.
* Dragging an edge calls wx_window_resize → wxWindow::SetSize, whose wxSizeEvent
* relays out the frame and resizes the embedded EDA_3D_CANVAS (wxGLCanvas →
* setGLCanvasRect). We drag the RIGHT edge (full-screen frame: its se corner sits
* at/over the viewport edge and is unreliable to grab; the right edge is not).
* Assert the frame AND its GL canvas both shrink in width.
*/
test('real 3D viewer can be edge-resized (frame + GL canvas track)', async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForPcbnew(page);
await loadBoard(page, testLogger);
const winsBefore = await page.evaluate(() =>
Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id));
const glBefore = await countGlCanvases(page);
await openThreeDViewer(page, glBefore);
// Wait for the new top-level window div to appear (replaces a fixed 1500ms).
await expect.poll(async () => page.evaluate((before: string[]) => {
const all = Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id);
return all.find((id) => !before.includes(id)) ?? null;
}, winsBefore), { timeout: 60000, intervals: [300] }).not.toBeNull();
const winId = await page.evaluate((before: string[]) => {
const all = Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id);
return all.find((id) => !before.includes(id)) ?? all[all.length - 1] ?? null;
}, winsBefore);
expect(winId, 'the 3D viewer should open a new top-level window').toBeTruthy();
// It is wxRESIZE_BORDER → exactly the 5 edge/corner handles.
const handles = await page.locator(`#${winId} .window-resize-handle`).count();
expect(handles, 'the 3D viewer (wxRESIZE_BORDER) should have edge-resize handles').toBe(5);
// Frame width from its style; GL canvas width from the newest glcanvas-*.
const frameWidth = (wid: string) =>
page.evaluate((id) => {
const el = document.getElementById(id) as HTMLElement | null;
return el ? (parseInt(el.style.width || '0', 10) || 0) : 0;
}, wid);
const glWidth = () =>
page.evaluate(() => {
const all = Array.from(document.querySelectorAll('canvas[id^="glcanvas-"]')) as HTMLCanvasElement[];
const c = all[all.length - 1];
return c ? (parseInt(c.style.width || '0', 10) || 0) : 0;
});
const beforeFrame = await frameWidth(winId as string);
const beforeGl = await glWidth();
expect(beforeFrame, 'frame should have a width').toBeGreaterThan(0);
await logThreeDDiag(page, 'resize: before edge drag');
// Drag the right edge inward (left) to shrink the frame width.
const edge = page.locator(`#${winId} .window-resize-e`);
const box = await edge.boundingBox();
expect(box, 'the 3D viewer should have a right-edge resize handle with a layout box').not.toBeNull();
const sx = box!.x + box!.width / 2;
const sy = box!.y + box!.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
await page.mouse.move(sx - 220, sy, { steps: 12 });
await page.mouse.up();
// Let the resize op (wx_window_resize → SetSize → relayout + GL canvas resize)
// settle before reading widths (documented interaction dwell).
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: resize + GL canvas relayout settle before reading widths
const afterFrame = await frameWidth(winId as string);
const afterGl = await glWidth();
expect(afterFrame, `frame width should shrink (was ${beforeFrame}, now ${afterFrame})`)
.toBeLessThan(beforeFrame - 100);
expect(afterGl, `3D viewer GL canvas should shrink with the frame (was ${beforeGl}, now ${afterGl})`)
.toBeLessThan(beforeGl);
const aborts = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted during the resize test:\n${aborts.join('\n\n')}`).toEqual([]);
});
});