pcbjam/tests/kicad/utils/threed-viewer.ts

164 lines
8.6 KiB
TypeScript
Raw Normal View History

import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { clickMenuBarItem, clickMenuItem } from '../../e2e/utils/element-tracker';
import { injectFromSubmodule } from './fs-inject';
import { waitForBoardLoaded } from './board-ready';
// Shared helpers for the 3D-viewer specs (3d-viewer.spec.ts + 3d-viewer-deadlock.spec.ts).
// KiCad 10 stores projects under /home/kicad/documents/kicad/10.0/projects.
export const KICAD_VERSION_DIR = '10.0';
export 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).
export const DEMO = { name: 'pic_programmer', dir: 'pic_programmer', stem: 'pic_programmer' } as const;
export 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}`);
}
export function countGlCanvases(page: Page): Promise<number> {
return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length);
}
fix(ci): serialize the heavy 3D-viewer e2e specs (dedicated chromium-ci-3d project) The three heavy 3D-viewer specs (3d-viewer, 3d-viewer-deadlock, 3d-viewer-models) crashed on CI (run 28604015154): "Target crashed", "browser has been closed", and a black frozen canvas. Each boots the 3D-enabled pcbnew build, which pre-warms ~hardwareConcurrency*2+8 (~68 on the 30-core VM) Web Workers AND runs a multi-threaded CPU raytracer over SwiftShader software-WebGL. Under fullyParallel the three spec FILES run in separate browser processes concurrently, and the simultaneous raytrace tabs exhaust the shared GPU process's ~16 live-WebGL-context limit / renderer memory. The pool pre-warm (the deadlock fix, 7630c7e) is correct and present in the tested binary; per-file isolation prevented in-process Worker accumulation but NOT this cross-process concurrency — the gap these specs' first CI exposure hit. Fix (test-orchestration only; no wasm/submodule changes): - Route the three specs to a dedicated `chromium-ci-3d` Playwright project (own outputDir=pw-artifacts/kicad-3d so its failure traces survive the later perf run's wipe; the existing tests/pw-artifacts/** upload already captures it). - test:kicad:ci runs it as a SECOND `playwright test` pass with --workers=1, so at most one raytrace tab is alive at a time (the environment in which a single 3D load is reliably green — docs/features/3d-raytracer/README.md §7). An exit-code-aggregating sequencer runs both phases and fails if either fails (no silent skip of the 3D pass). - Cap phase-1 CI workers to 12 as headroom under the WebGL-context cap. - Log a resource-diagnostic snapshot (hardwareConcurrency, pthread pool, GL-canvas count, wasm/JS heap) before each 3D interaction so any recurrence is attributable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:49:23 +02:00
// Resource-diagnostic snapshot for the heavy 3D-viewer specs. On CI these can kill the tab
// (renderer OOM, a lost WebGL context when the shared GPU process passes its ~16-context cap,
// or a raytracer pthread deadlock) and the failure surfaces identically ("Target crashed" /
// black canvas). Logging hardwareConcurrency, the emscripten pthread-pool ledger, the live
// GL-canvas count, and the wasm/JS heap sizes right before an interaction makes any recurrence
// attributable (GL-context exhaustion vs heap-OOM vs deadlock) — and confirms, on the real CI
// VM, what navigator.hardwareConcurrency actually is (the whole 2N+8 pool sizing depends on it).
// Best-effort: never throws, so it can't itself fail a spec.
export async function logThreeDDiag(page: Page, label: string): Promise<void> {
const snap = await page.evaluate(() => {
const w = window as unknown as { Module?: Record<string, unknown> };
const M = (w.Module ?? {}) as Record<string, unknown>;
const P = (M.PThread ?? {}) as { unusedWorkers?: unknown[]; runningWorkers?: unknown[] };
const heap = M.HEAPU8 as { length?: number } | undefined;
const perf = (performance as unknown as { memory?: Record<string, number> }).memory ?? {};
const mb = (b?: number) => (typeof b === 'number' ? Math.round(b / 1048576) : null);
return {
hardwareConcurrency: navigator.hardwareConcurrency,
// emscripten pool ledger: pre-warmed-but-idle + currently-running Workers.
pthreadUnused: Array.isArray(P.unusedWorkers) ? P.unusedWorkers.length : null,
pthreadRunning: Array.isArray(P.runningWorkers) ? P.runningWorkers.length : null,
glCanvases: document.querySelectorAll('canvas[id^="glcanvas-"]').length,
canvases: document.querySelectorAll('canvas').length,
wasmHeapMB: mb(heap?.length),
jsHeapUsedMB: mb(perf.usedJSHeapSize),
jsHeapLimitMB: mb(perf.jsHeapSizeLimit),
};
}).catch((e: unknown) => ({ error: String(e) }));
console.log(`[DIAG ${label}] ${JSON.stringify(snap)}`);
}
// 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.
export 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');
}
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 11:37:27 +02:00
// 180s (not 60s): opening the viewer kicks the scene build + first render. On CI
// (headless SwiftShader software WebGL, 30 contended vCPUs) the raytracer-era run
// 28649537489 opened in <60s but with little margin; a real GPU returns in ~2s. The
// larger cap is CI headroom only — it never slows a passing run.
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: 180000 });
await page.waitForFunction((before: number) =>
document.querySelectorAll('canvas[id^="glcanvas-"]').length > before,
glBefore, { timeout: 180000 });
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;
}
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 11:37:27 +02:00
// Wait until the NEWEST glcanvas actually shows a rendered scene (> minColors distinct
// colours on a 16×16 grid) instead of sleeping a fixed interval. The viewer's first frame
// can lag the canvas's creation, especially on CI's software WebGL under parallel load —
// sampling too early reads an all-black backbuffer, which is exactly main's live 3D flake
// (run 28698861536: 3d-viewer.spec:26 flaky; run 28666407570: the deadlock spec red with an
// all-zero pixel signature). One full-frame read per 1s poll on a CPU-backed 2D canvas —
// NOT per-pixel getImageData calls, which are a GPU round-trip each and stall SwiftShader
// ("GPU stall due to ReadPixels").
export async function waitForThreeDRender(
page: Page, minColors = 8, timeoutMs = 90000,
): Promise<void> {
await page.waitForFunction((min: number) => {
const list = document.querySelectorAll('canvas[id^="glcanvas-"]');
const el = list[list.length - 1] as HTMLCanvasElement | undefined;
if (!el || !el.width || !el.height) return false;
const tmp = document.createElement('canvas');
tmp.width = el.width; tmp.height = el.height;
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 colors.size > min;
}, minColors, { timeout: timeoutMs, polling: 1000 });
}