2026-07-02 12:59:42 +02:00
|
|
|
|
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)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 12:59:42 +02:00
|
|
|
|
// 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.
|
2026-07-02 12:59:42 +02:00
|
|
|
|
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;
|
2026-07-03 15:35:17 +02:00
|
|
|
|
}, null, { timeout: 180000 });
|
2026-07-02 12:59:42 +02:00
|
|
|
|
|
|
|
|
|
|
await page.waitForFunction((before: number) =>
|
|
|
|
|
|
document.querySelectorAll('canvas[id^="glcanvas-"]').length > before,
|
2026-07-03 15:35:17 +02:00
|
|
|
|
glBefore, { timeout: 180000 });
|
2026-07-02 12:59:42 +02:00
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
}
|