The headed Mesa-llvmpipe switch (ce44636) was built on a misdiagnosis. With
--use-gl=angle --use-angle=gl under Xvfb, Chromium gets NO WebGL AT ALL on the
GPU-less CI VM: runs 28652367347 + 28664038296 both log
`glcanvas count before opening 3D viewer: 0` (even the pcbnew board's own GAL
canvas fails to create) and every failure is the FIRST viewer-open wait timing
out. "Timeouts instead of crashes" meant no GL context existed to crash, not
that llvmpipe was stable-but-slow. The Mesa/GLX path works for the Firefox
project, but not for Chromium on this image.
Headless SwiftShader is the environment where GL provably works — the
serialized run 28649537489 opened the viewer and rendered/sampled 56 colours
(viewer:26/130/209 all passed single-tab). Its remaining failures split into
two families, each addressed on its own evidence:
1) SwiftShader GPU-process kills (deadlock spec's lost canvas/black frame):
- chromium-ci-3d back to headless + --enable-unsafe-swiftshader, plus
--disable-gpu-watchdog and --disable-gpu-process-crash-limit: heavy churn
stalls the software-GL GPU process until the watchdog shoots it
(CONTEXT_LOST -> removed GL canvas -> "Target crashed"); with the watchdog
off a slow op completes, and without the crash limit a lost GPU process
keeps restarting for later tests. Same args locally (no-ops on a healthy
real GPU).
- Storm-proof every canvas sampler (3d-viewer, models, deadlock specs): one
full-frame getImageData on a willReadFrequently (CPU-backed) 2D canvas +
JS grid sampling, replacing 256 per-pixel getImageData GPU round-trips per
sample — the "GPU stall due to ReadPixels" trigger, worst in the deadlock
spec's 1.5s-interval settle polling. Sampled pixels and sig order are
unchanged.
- edge-resize spec: keep the open + 5-resize-handles assertions on CI, then
test.skip the drag — each resize step re-raytraces synchronously on the
wasm main thread and the 12-step drag blocked mouse.move past the 240s
budget even single-tab. Real-GPU-paced; still runs locally (deadlock
spec's CI-skip from d9900fe unchanged, same rationale).
2) KNOWN PRODUCT BUG uncovered in the models spec (NOT SwiftShader, NOT CI):
raytracing a scene WITH component models kills the Chromium renderer
process outright ~6s after scene build — silent process death with a FLAT
wasm heap (531-637 MB watched at 2s intervals; not OOM), no console error,
no wasm abort, no crash report. Deterministic on a real GPU (5/5),
fixture-independent (700 KB USB-C and 61 KB USON-8 STEP crash identically);
the same death hits CI SwiftShader at ~66s. Board-only raytraces complete
and stay alive, so the bug is specific to the model path. The spec's one
historical green ended at raytrace-age ~6s — inside the death window by
luck; every CI run of it ever died. Fix here: front-load the bridge
assertions (ensure requests fire during scene BUILD, before the raytrace),
so protocol regressions still fail the suite everywhere, and test.skip the
render tail unconditionally with the bug documented at the skip site. The
raytrace->blit pipeline stays gated on CI by 3d-viewer.spec's board-only
render. Bug tracked for a dedicated fix session.
Correct the now-wrong llvmpipe comments (config, open-wait rationale, deadlock
skip reason). Expected CI matrix: viewer open/render/stacking/titlebar +
models-bridge on SwiftShader serial; edge-resize drag + camera-move deadlock
on real GPUs locally; the Worker-boot deadlock mechanism stays covered on CI
by the standalone wx harnesses.
Locally validated on a real GPU (--project=chromium): 5 passed + 1 skipped
(the documented models render tail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
6.8 KiB
TypeScript
132 lines
6.8 KiB
TypeScript
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);
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
|
|
// 180s (not 60s): opening the viewer kicks the scene build + FIRST full board raytrace.
|
|
// On CI (headless SwiftShader software WebGL, 30 contended vCPUs) 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;
|
|
}
|