diff --git a/tests/kicad/3d-viewer-deadlock.spec.ts b/tests/kicad/3d-viewer-deadlock.spec.ts index 251c761..656f569 100644 --- a/tests/kicad/3d-viewer-deadlock.spec.ts +++ b/tests/kicad/3d-viewer-deadlock.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from './fixtures'; import { waitForPcbnew } from './utils/pcbnew-ready'; -import { countGlCanvases, loadBoard, openThreeDViewer } from './utils/threed-viewer'; +import { countGlCanvases, loadBoard, logThreeDDiag, openThreeDViewer } from './utils/threed-viewer'; /** * Regression for the camera-move-on-canvas raytrace DEADLOCK. @@ -58,6 +58,7 @@ test.describe('3D viewer camera-move deadlock', () => { // Let the INITIAL raytrace settle through the safe per-frame pump (Workers boot here). await page.waitForTimeout(5000); + await logThreeDDiag(page, 'deadlock: after open+settle'); // Read the newest glcanvas-* (the 3D viewer) client rect in viewport coords. const canvasRect = () => page.evaluate(() => { @@ -181,6 +182,7 @@ test.describe('3D viewer camera-move deadlock', () => { await page.screenshot({ path: 'test-results/3d-viewer-deadlock.png', scale: 'device' }); // Sanity: the board still renders (not blank / crashed) after both moves. + await logThreeDDiag(page, 'deadlock: after moves'); const after = await sampleCanvas(); console.log(`[TEST] 3D render after interaction: ${after.distinctColors} distinct colours`); expect(after.distinctColors, diff --git a/tests/kicad/3d-viewer-models.spec.ts b/tests/kicad/3d-viewer-models.spec.ts index f025c2f..67d6c19 100644 --- a/tests/kicad/3d-viewer-models.spec.ts +++ b/tests/kicad/3d-viewer-models.spec.ts @@ -4,6 +4,7 @@ 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'; +import { logThreeDDiag } from './utils/threed-viewer'; /** * 3D viewer COMPONENT MODELS e2e (docs/features/3d-models): load pic_programmer, @@ -193,6 +194,7 @@ test.describe('3D viewer component models', () => { // Scene build + progressive raytrace passes. await page.waitForTimeout(8000); + await logThreeDDiag(page, 'models: before screenshot'); await page.screenshot({ path: `test-results/3d-viewer-models-${DEMO.name}.png`, scale: 'css' }); // --- bridge assertions ------------------------------------------------- diff --git a/tests/kicad/3d-viewer.spec.ts b/tests/kicad/3d-viewer.spec.ts index 5b870cb..54ce2b6 100644 --- a/tests/kicad/3d-viewer.spec.ts +++ b/tests/kicad/3d-viewer.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from './fixtures'; import { waitForPcbnew } from './utils/pcbnew-ready'; -import { DEMO, loadBoard, countGlCanvases, openThreeDViewer } from './utils/threed-viewer'; +import { DEMO, loadBoard, countGlCanvases, logThreeDDiag, openThreeDViewer } from './utils/threed-viewer'; /** * 3D viewer e2e: load a real board in pcbnew, open the native 3D viewer @@ -318,6 +318,7 @@ test.describe('3D viewer from pcbnew', () => { 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`); diff --git a/tests/kicad/utils/threed-viewer.ts b/tests/kicad/utils/threed-viewer.ts index 8284007..12d1797 100644 --- a/tests/kicad/utils/threed-viewer.ts +++ b/tests/kicad/utils/threed-viewer.ts @@ -63,6 +63,37 @@ export function countGlCanvases(page: Page): Promise { 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 { + const snap = await page.evaluate(() => { + const w = window as unknown as { Module?: Record }; + const M = (w.Module ?? {}) as Record; + 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 }).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. diff --git a/tests/package.json b/tests/package.json index 326bdaa..70e3948 100644 --- a/tests/package.json +++ b/tests/package.json @@ -16,7 +16,7 @@ "test:kicad:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox", "test:kicad:chrome": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed", "test:kicad": "npm run test:kicad:firefox", - "test:kicad:ci": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox --project=chromium-ci", + "test:kicad:ci": "npm run setup:kicad && sh -c 'playwright test --config=playwright-kicad.config.ts --project=firefox --project=chromium-ci; a=$?; playwright test --config=playwright-kicad.config.ts --project=chromium-ci-3d --workers=1; b=$?; exit $((a||b))'", "test:kicad:headed": "npm run test:kicad:chrome", "test:perf": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=perf --workers=1", "test:pcbnew:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox kicad/pcbnew.spec.ts", diff --git a/tests/playwright-kicad.config.ts b/tests/playwright-kicad.config.ts index 0e76142..653bd59 100644 --- a/tests/playwright-kicad.config.ts +++ b/tests/playwright-kicad.config.ts @@ -118,6 +118,24 @@ const BIG_MODULE_SPECS = [ "**/occ-probe.spec.ts", ]; +// The heavy 3D-viewer specs each boot the 3D-enabled pcbnew build, which pre-warms +// ~hardwareConcurrency*2+8 Web Workers AND runs a multi-threaded CPU raytracer over +// SwiftShader software-WebGL. Under fullyParallel these separate spec FILES land in +// separate browser processes CONCURRENTLY, and the simultaneous raytrace tabs exhaust +// Workers / wasm heap / the shared GPU process's ~16 live-WebGL-context limit → the tab +// crashes ("Target crashed" / "browser has been closed" / a black frozen canvas). Per-file +// isolation (own worker) prevents in-process Worker accumulation but NOT this cross-process +// concurrency. They STAY in BIG_MODULE_SPECS (so firefox keeps ignoring them — the ~190 MB +// module OOMs SpiderMonkey/x86), but on CI they run in the dedicated 'chromium-ci-3d' project, +// which test:kicad:ci invokes with --workers=1 so at most ONE raytrace tab is alive at a time. +// (footprint-3d-preview.spec.ts is a placeholder glob — no such file exists yet — harmless.) +const THREE_D_HEAVY_SPECS = [ + "**/3d-viewer.spec.ts", + "**/3d-viewer-deadlock.spec.ts", + "**/3d-viewer-models.spec.ts", + "**/footprint-3d-preview.spec.ts", +]; + // Runtime-perf specs run ONLY on the Chromium 'perf' project below: they need // CDP CPU throttling (Chromium-only) and pcbnew needs V8. Excluded from the // firefox/chromium projects so they don't double-run there. @@ -137,10 +155,13 @@ export default defineConfig({ // as playwright.config.ts): the load-pcb post-load clipboard crash that can // close the page on Firefox, and the calculator first-run-wizard timing race. retries: process.env.CI ? 2 : 1, - // Run parallel workers on CI too (Playwright default ≈ 50% of cores), same as - // local — the serial CI run was the dominant wall-clock cost. Cap (e.g. '50%' - // or a fixed count) if contention OOMs/flakes; retries:2 covers transient. - workers: undefined, + // Run parallel workers on CI too, same as local — the serial CI run was the dominant + // wall-clock cost. Capped to a fixed 12 on CI (down from Playwright's default ≈ 50% of + // cores = ~15 on the 30-core VM) as defense-in-depth for the ~16 live-WebGL-context limit + // in Chromium's shared GPU process: ~15 GAL-canvas tabs already brush that cap. The heavy + // 3D-viewer specs (a *second* WebGL context each) no longer run here — they're isolated to + // the serial 'chromium-ci-3d' project — so 12 is only headroom, not the primary fix. + workers: process.env.CI ? 12 : undefined, reporter: "html", timeout: 180000, // KiCad WASM needs more time to load (3 minutes) @@ -210,7 +231,29 @@ export default defineConfig({ // --enable-unsafe-swiftshader: newer Chromium refuses software WebGL in // headless without it. name: "chromium-ci", - testMatch: BIG_MODULE_SPECS, + // Every big-module spec EXCEPT the heavy 3D-viewer ones — those move to the + // serial 'chromium-ci-3d' project below so they never raytrace concurrently. + testMatch: BIG_MODULE_SPECS.filter((s) => !THREE_D_HEAVY_SPECS.includes(s)), + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 720 }, + launchOptions: { + args: ["--enable-unsafe-swiftshader"], + }, + }, + }, + { + // CI-only serial carrier for the heavy 3D-viewer specs (see THREE_D_HEAVY_SPECS). + // Same runtime as chromium-ci; test:kicad:ci invokes this project in a SEPARATE + // `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 — see + // docs/features/3d-raytracer/README.md §6-§7). Own outputDir so its start-of-run + // cleanup doesn't wipe chromium-ci's retained traces, and so its own failure traces + // survive the later `test:perf` run's pw-artifacts/kicad wipe; the CI upload glob + // tests/pw-artifacts/** already captures it, so no workflow change is needed. + name: "chromium-ci-3d", + testMatch: THREE_D_HEAVY_SPECS, + outputDir: process.env.CI ? "pw-artifacts/kicad-3d" : "test-results", use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 },