pcbjam/tests/kicad/3d-viewer.spec.ts
Istvan Matejcsok 561d0500a6 fix(e2e): rescope the 3D fixes for the webgl-era viewer (rebased onto main)
The 3d-webgl merge (kicad eb13ff3bdc: the viewer now defaults to the real
OpenGL renderer via wasm/gl1, and occ-split moves STEP parsing into the
occ_service worker) made the raytracer-era orchestration on this branch moot —
main's chromium-ci phase is green at 15-way parallelism (28666407570 /
28698861536). Drop what no longer earns its complexity, keep the diagnostics,
fix main's live flake, and make the deadlock spec test what it was written for.

- REVERT the chromium-ci-3d serial project, the two-phase test:kicad:ci, the
  SwiftShader GPU-process flags, and the resize-drag/models skips: config and
  package.json are byte-for-byte back to main's shape. The raytracer contention
  they guarded is no longer on the CI path.

- FIX main's live flake: run 28698861536 is green only via retry
  (3d-viewer.spec:26 flaky) and 28666407570's deadlock red sampled an ALL-ZERO
  pixel signature — the viewer's first frame lags the canvas's creation on
  software WebGL under parallel load, and sampling too early reads an all-black
  backbuffer. New waitForThreeDRender() gates render assertions on actual
  pixels (1s-interval full-frame CPU reads) instead of fixed sleeps, used by
  3d-viewer.spec:26 and the models render tail.

- KEEP the storm-proofed samplers (one full-frame getImageData on a
  willReadFrequently canvas replacing 256 per-pixel GPU round-trips per sample
  — the "GPU stall due to ReadPixels" trigger) and the logThreeDDiag
  instrumentation: engine-independent, and they de-risk every remaining
  software-GL pixel read.

- models spec: bridge assertions stay front-loaded (the protocol regression
  signal is independent of the render); the occ_service parse verdict is now
  POLLED — it lands async relative to the bridge ensures, so asserting it
  immediately raced the worker; the render tail runs again everywhere. (The
  pre-webgl raytracer+models renderer-death documented in a17f3be does not
  affect the OpenGL default path — the raytracer-toggle+models combination
  remains untested product surface, tracked outside this branch.)

- deadlock spec: the deadlock it guards is raytracer-specific and the viewer
  now defaults to OpenGL — on the GL engine it either passes vacuously (fast
  renders make every liveness assertion trivial, 28698861536) or fails on the
  black first frame (28666407570). It now flips the engine via the "Use
  raytracing" toolbar toggle (loud assert if the toggle moved) and
  cross-checks engagement by requiring the canvas pixels to CHANGE after the
  flip with no input in between (the raytraced frame is lit differently; a GL
  re-render reproduces identical pixels; heap growth is unusable — mimalloc
  satisfies the raytracer from freed arena pages). That guard immediately
  caught a REAL defect: on the webgl-era wasm build the toggle is INERT (the
  click lands and "Reload time" updates, but the canvas never changes —
  suspects: DoRePaint's silent catch(runtime_error) freezing the canvas after
  a raytracer Redraw throw, or ToggleRaytracing writing m_boardAdapter.m_Cfg
  while RenderEngineChanged() reads GetAppSettings<…>(), possibly different
  instances in the merged bundle). The spec is therefore test.skip-annotated
  as a KNOWN ISSUE with the full engine-force machinery in place — unskipping
  it self-validates the product fix. The CI-skip also stays (raytracer
  liveness needs real-GPU pacing; the Worker-boot deadlock mechanism is
  covered on CI by the standalone wx harnesses).

- 180s viewer-open waits kept as pure CI headroom (never slow a passing run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:21:04 +02:00

353 lines
19 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 { 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: `test-results/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: `test-results/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(`test-results/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 asyncifySignatures = [
'index out of bounds', 'indirect call to null', 'uncaught exception: unwind',
'invalid state', 'is not a function',
];
const asyncifyErrors = allLines.filter((l) =>
asyncifySignatures.some((sig) => l.toLowerCase().includes(sig)));
expect(asyncifyErrors,
`Asyncify corruption surfaced opening the 3D viewer:\n${asyncifyErrors.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);
await page.waitForTimeout(1500);
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: 'test-results/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();
await page.waitForTimeout(300);
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);
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);
await page.waitForTimeout(1500);
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();
await page.waitForTimeout(500);
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([]);
});
});