pcbjam/tests/kicad/3d-viewer.spec.ts
Viktor Vaczi 13551f4f22 feat(tests): screenshot regression + Discord review, perf-tracked
New tooling in tests/tools/screenshots/ (TypeScript via tsx):
- compare.ts: one pixelmatch engine (AA-excluded), connected-component
  "where to look" boxes, old|new+boxes|heatmap triptych, per-engine floors.
- promote.ts: churn-free updater — overwrite a baseline only when decoded
  pixels differ beyond the floor, copying CI bytes verbatim (no re-encode
  churn); pulls a CI run via `gh run download` or a local --from dir.
- post-discord.ts: always-on CI-on-main report (SHA + e2e status + the
  track-only runtime-perf table), then screenshot triptychs, batched +
  size-capped + flood-collapsed + 429-aware.
- perf-report.ts: perf table with Δ vs the previous main run (via gh).
- changelog.ts: no-build git-history baseline differ (Discord trigger B).
- noise.ts / gen-manifest.ts: calibration + manifest generation.

CI wiring:
- wasm-build.yml: post-test step runs the gate + report on the already-
  produced test-results (no extra build); report-only (continue-on-error),
  posts only on push to main, inert without DISCORD_WEBHOOK_URL.
- ci-ubicloud.yml: secrets: inherit (pass the webhook through).
- screenshot-changelog.yml: ~30s no-build changelog on baseline changes.

screenshot-manifest.json: canonical 354-name set + best-effort engine tags
(313 chromium-swiftshader / 41 firefox-llvmpipe).

Normalize scale:'device'->'css' across 18 spec files (no-op at CI DSF=1)
so committed baselines are uniformly css-scaled.

Design: CI's Linux render is the single source of truth; no pinned
container (accept rare env drift -> re-promote); dev commits via promote.
Replaces the byte-cmp compare-screenshots.sh + file-size-proxy
update-baseline-screenshots.sh (kept for now until the first re-baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:17:20 +02:00

434 lines
23 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 type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { clickMenuBarItem, clickMenuItem } from '../e2e/utils/element-tracker';
import { injectFromSubmodule } from './utils/fs-inject';
import { waitForBoardLoaded } from './utils/board-ready';
import { waitForPcbnew } from './utils/pcbnew-ready';
/**
* 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.
*/
// KiCad 10 stores projects under /home/kicad/documents/kicad/10.0/projects.
const KICAD_VERSION_DIR = '10.0';
const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`;
// pic_programmer frames correctly in the default 3D camera (the microwave demo
// has a known board-bounding-box scale bug that projects it off-screen — a
// separate follow-up). Loads cleanly in this harness (see 2D load tests).
const DEMO = { name: 'pic_programmer', dir: 'pic_programmer', stem: 'pic_programmer' } as const;
async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors: string[] }): Promise<void> {
const pcbFilename = `${DEMO.stem}.kicad_pcb`;
const proFilename = `${DEMO.stem}.kicad_pro`;
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${pcbFilename}`,
`${PROJECT_DIR_MEMFS}/${pcbFilename}`);
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${proFilename}`,
`${PROJECT_DIR_MEMFS}/${proFilename}`);
expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true);
await page.waitForTimeout(400);
expect(await clickMenuItem(page, 'Open...'), 'Open… menu item should be findable').toBe(true);
await page.waitForFunction(() => {
const registry = window.wxElementRegistry;
return !!registry && registry.findAll({ visible: true })
.some((el) => el.typeName === 'wxFileDialog');
}, null, { timeout: 15000 });
await page.waitForTimeout(1000);
const filenameInput = await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry) return null;
const text = registry.findAll({ visible: true })
.find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
return text ? { x: text.centerX, y: text.centerY } : null;
});
expect(filenameInput, 'filename text input should be visible').not.toBeNull();
if (!filenameInput) throw new Error('filename text input not found');
await page.mouse.click(filenameInput.x, filenameInput.y);
await page.waitForTimeout(200);
await page.keyboard.type(pcbFilename);
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
const result = await waitForBoardLoaded(page, testLogger, 60000);
console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`);
}
function countGlCanvases(page: Page): Promise<number> {
return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length);
}
// Open the 3D viewer (View → 3D Viewer, with an Alt+3 fallback) and wait for the
// secondary frame + its NEW `glcanvas-*` to appear. The main pcbnew board view is
// itself a wxGLCanvas, so the viewer is detected by the GL-canvas COUNT increasing.
// Returns the glcanvas count after opening. `glBefore` is the count beforehand.
async function openThreeDViewer(page: Page, glBefore: number): Promise<number> {
let opened = false;
if (await clickMenuBarItem(page, 'View')) {
await page.waitForTimeout(400);
opened = await clickMenuItem(page, '3D Viewer');
}
if (!opened) {
console.log('[TEST] View → 3D Viewer not found via menu; trying Alt+3');
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
await page.keyboard.press('Alt+3');
}
await page.waitForFunction(() => {
// A new top-level window div beyond the main pcbnew frame.
return !!document.querySelector('#window-container [id^="window-"]')
|| document.querySelectorAll('canvas[id^="glcanvas-"]').length > 0;
}, null, { timeout: 60000 });
await page.waitForFunction((before: number) =>
document.querySelectorAll('canvas[id^="glcanvas-"]').length > before,
glBefore, { timeout: 60000 });
const glAfter = await countGlCanvases(page);
console.log(`[TEST] glcanvas count after opening 3D viewer: ${glAfter}`);
expect(glAfter, 'a new WebGL canvas should appear for the 3D viewer').toBeGreaterThan(glBefore);
return glAfter;
}
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);
// The 3D reload + raytrace run through asyncify; give them time to build
// the scene and render a few progressive passes.
await page.waitForTimeout(5000);
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;
const ctx = tmp.getContext('2d')!;
ctx.drawImage(el, 0, 0);
const colors = new Set<string>();
for (let i = 0; i < 16; i++) {
for (let j = 0; j < 16; j++) {
const d = ctx.getImageData(Math.floor(el.width * i / 16),
Math.floor(el.height * j / 16), 1, 1).data;
colors.add(`${d[0]},${d[1]},${d[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);
// 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([]);
});
});