tests: load-pcb e2e for microwave + pic_programmer demos

Adds an end-to-end test that drives File→Open in pcbnew, injects
the .kicad_pcb and .kicad_pro files into MEMFS at the dialog's
default starting directory, drives the menu + filename text input
+ Enter accept path, and screenshots the loaded board. Parametrized
for both kicad/demos/microwave (RF polygon footprints) and
kicad/demos/pic_programmer (full multi-IC layout).

Without the rtree fix bumped in via the kicad submodule, the load
would abort on every PCB at rtree.h:1771 Classify; the test asserts
no [RTREE-DIAG] line and no Aborted(. The post-load clipboard
RuntimeError in __asyncjs__js_clipboardHasText is a separate,
pre-existing wasm-port limitation that we explicitly do not regress
on here.

- tests/kicad/load-pcb.spec.ts: serial-mode parametrized spec
- tests/kicad/load-pcb-probe.spec.ts: one-shot diagnostic probe
  for inspecting wxFileDialog state on the canvas
- tests/kicad/utils/fs-inject.ts: FS.writeFile bridge from Node fs
- tests/kicad/utils/board-ready.ts: poll-for-no-dialogs readiness
- tests/baseline-screenshots/load-pcb-*.png: 6 baselines covering
  both demos at pcbnew-ready / dialog-open / loaded states
- features/.../rtree-debug-findings.md: full diagnosis trail with
  an upstream-reportable summary the maintainer can lift verbatim
- kicad submodule bumped to 07d8130d44 (shape_poly_set rtree fix)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-05-29 09:51:18 +02:00
commit 31ff88ee9e
12 changed files with 768 additions and 1 deletions

View file

@ -0,0 +1,172 @@
# RTree `Classify` duplicate-index crash on PCB load (wasm-only)
## TL;DR
A debug-build pcbnew in the browser used to abort on every `.kicad_pcb` open with
```
Aborted(Assertion failed: !a_parVars->m_taken[a_index],
at: kicad/thirdparty/rtree/geometry/rtree.h:1771, Classify)
```
Native debug builds never hit it. Root cause was a **wasm-only integer overflow** triggered by a single bad template argument in upstream KiCad:
```cpp
// kicad/libs/kimath/src/geometry/shape_poly_set.cpp:1927 ← before
RTree<intptr_t, intptr_t, 2, intptr_t> rtree;
// ^^^^^^^^ ELEMTYPEREAL = intptr_t
```
Every other RTree instantiation in KiCad uses `double` for the 4th template parameter (the volume/area type). This one used `intptr_t`, which on wasm32 is `int32_t` — too narrow to hold `RectSphericalVolume`'s `sumOfSquares` for typical KiCad nanometer extents (`~10⁸ per axis → ~10¹⁴ area`). On native targets `intptr_t = int64_t` happens to absorb the result, so the bug stays latent.
Fix: change `intptr_t` to `double` on that one line. After the fix, both `kicad/demos/microwave/microwave.kicad_pcb` and `kicad/demos/pic_programmer/pic_programmer.kicad_pcb` load and render cleanly through pcbnew's File → Open in the wasm debug build — see `tests/kicad/load-pcb.spec.ts` and the baselines under `tests/baseline-screenshots/load-pcb-*.png`.
---
## Investigation trail
### 1. Reproducing and isolating
The crash reproduced deterministically on every non-empty board (`microwave`, `flat_hierarchy`, `pic_programmer`). The first idea — that the bug had something to do with the file-open path itself — was disproven by the spike test at `tests/kicad/load-pcb.spec.ts`: the menu and dialog drive end-to-end, the abort fires after the file is selected and parsing has begun, well inside `PCB_IO_KICAD_SEXPR::LoadBoard()`.
The static read of `rtree.h` left two plausible mechanisms:
1. `seed0 == seed1` in `PickSeeds` because `if (waste >= worst)` never fires, which only happens if `worst` (= `-coverSplitArea - 1`) goes NaN/`-inf`. That points at integer-overflow / UB inside `RectVolume` / `RectSphericalVolume`.
2. Out-of-band corruption of `m_taken[]` — re-entrant Insert during iteration, stack/heap overrun, etc.
Neither could be decided from code alone.
### 2. Targeted instrumentation
Phase A added a temporary `KICAD_RTREE_DIAG`-gated block in `kicad/thirdparty/rtree/geometry/rtree.h` (since reverted) that, on the assertion's would-fire condition, dumped:
- the call-site tag (`PickSeeds-0` / `PickSeeds-1` / `ChoosePartition`, derived from current `m_count[]`),
- `a_index`, `a_group`, `m_total`, `m_minFill`, `m_count[]`,
- `m_taken[]`, `m_partition[]`,
- `m_coverSplitArea`, `m_area[0..1]`,
- every branch's `m_min/m_max/span` per dimension,
and `return`-ed without aborting. Wired up via `./docker/build.sh --diag rtree` (gated through the existing `--diag=<flags>` plumbing in `scripts/kicad/build-pcbnew.sh`).
### 3. The dump that nailed it
Two consecutive runs from the diagnostic build (excerpt — first run shown):
```
[RTREE-DIAG] Classify duplicate src=PickSeeds-1 idx=0 grp=1
total=9 minFill=4 count=[1,0]
coverSplitArea=-2099823776 area=[717833776,0]
[RTREE-DIAG] m_taken=100000000 m_partition=0 -1 -1 -1 -1 -1 -1 -1 -1
[RTREE-DIAG] branchBuf[0].rect=[67429380..67932300 span=502920]
[117822980..117822980 span=0]
[RTREE-DIAG] branchBuf[1].rect=[69441060..69943980 span=502920]
[117805200..117810280 span=5080]
...
[RTREE-DIAG] branchBuf[8].rect=[82008980..82511900 span=502920]
[117955060..117962680 span=7620]
```
Key observations:
- `src=PickSeeds-1` plus `m_taken[0]=1` and `idx=0``seed0 == seed1 == 0`. Exactly the smoking-gun for hypothesis (1).
- Every per-dim `span` is small and positive (`5e5`, `5e3`, etc.). No leaf overflow.
- But `coverSplitArea = -2099823776`**negative**, which `RectSphericalVolume` cannot algebraically produce (`sumOfSquares * unitSphereVolume` is non-negative).
- The magnitude is interesting too: `-2099823776 ≈ -INT_MAX × 0.98`. That's not a noisy double; that's an exact 32-bit signed-integer value sitting in a double slot.
- `area[0] = 717833776` (run 1) and `-919295316` (run 2) — also "round int32" magnitudes, also wildly wrong vs. the expected `~1.99e11` for the seed-0 branch.
### 4. The actual bug
Every value the diag pulled out of a "double" field had the shape of a 32-bit signed integer. That means the field is being computed and stored as `int32_t` somewhere, not as `double`. The `ELEMTYPEREAL` template parameter is supposed to be the wide floating-point type for volume math.
Grep across the kicad submodule:
```
kicad/include/view/view_rtree.h:36 RTree<VIEW_ITEM*, int, 2, double>
kicad/pcbnew/drc/drc_rtree.h:77 RTree<ITEM_WITH_SHAPE*, int, 2, double>
kicad/pcbnew/connectivity/connectivity_rtree.h:45 RTree<T, int, 3, double>
kicad/pcbnew/connectivity/connectivity_items.h:395 RTree<const SHAPE*, int, 2, double>
kicad/eeschema/sch_rtree.h:42 RTree<SCH_ITEM*, int, 3, double>
kicad/libs/kimath/include/geometry/shape_index.h RTree<T, int, 2, double>
kicad/libs/kimath/src/geometry/shape_poly_set.cpp:1927 RTree<intptr_t, intptr_t, 2, intptr_t> ← OUTLIER
```
Every other instantiation passes `double` for `ELEMTYPEREAL`. `splitCollinearOutlines` (called from `SHAPE_POLY_SET::Simplify`, which fires during any board load that has polygon-bearing items like the microwave demo's RF "footprints" or pic_programmer's pads) passes `intptr_t`.
On wasm32, `intptr_t` is `int32_t` because pointers are 32-bit. `RectSphericalVolume`'s loop:
```cpp
ELEMTYPEREAL sumOfSquares = 0;
for (int index = 0; index < NUMDIMS; ++index) {
ELEMTYPEREAL halfExtent =
((ELEMTYPEREAL) max[index] - (ELEMTYPEREAL) min[index]) * 0.5f;
sumOfSquares += halfExtent * halfExtent;
}
return sumOfSquares * m_unitSphereVolume;
```
…becomes int32 arithmetic. For span_x = 1.5×10⁷, halfExtent² = 5.7×10¹³ — far past `INT_MAX = 2.15×10⁹`. The multiplication wraps, `sumOfSquares` ends up as garbage (the `-2099823776` we observed), `m_coverSplitArea` follows, `worst = -coverSplitArea - 1` ends up hugely positive, and PickSeeds' `if (waste >= worst)` never fires for any pair. `seed0 = seed1 = 0` (their default), `Classify(0, 0)` succeeds, `Classify(0, 1)` trips the assertion.
On native (x86_64, arm64), `intptr_t = int64_t` so the same math succeeds even with the wrong template arg. KiCad's CI has therefore never seen the assertion.
### 5. The fix
```diff
- RTree<intptr_t, intptr_t, 2, intptr_t> rtree;
+ // ELEMTYPEREAL must be a wide floating-point type: RectSphericalVolume's
+ // sumOfSquares grows quadratically with extents, easily exceeding 2^31 for
+ // KiCad nanometer coordinates (~10^8 per axis -> ~10^14 area). All other
+ // RTree instantiations in KiCad use `double` for that fourth argument; this
+ // one was using `intptr_t`, which silently overflowed on wasm32 (where
+ // intptr_t is 32-bit) and tripped an assertion deep in PickSeeds during
+ // PCB load. See features/fix-asyncify-O2-and-modal-promise-rejection/
+ // rtree-debug-findings.md for the full diagnosis trail.
+ RTree<intptr_t, intptr_t, 2, double> rtree;
```
`git -C kicad diff origin/master -- thirdparty/rtree/geometry/rtree.h` is empty — the rtree third-party code stays vanilla upstream. The only divergence from upstream is `libs/kimath/src/geometry/shape_poly_set.cpp:1927`.
### 6. Verification
After the fix:
- `npm run test:kicad:firefox -- load-pcb.spec` → both `microwave` and `pic_programmer` tests pass (~38s total).
- `tests/logs/kicad/load-pcb/*.log` contains the `[KICAD] Wrote …{microwave,pic_programmer}.kicad_pcb` injection lines and no `Aborted(` line, no `[RTREE-DIAG]` line.
- `tests/baseline-screenshots/load-pcb-microwave.png` shows the microwave's two distinctive RF polygon footprints as horizontal red bars on F.Cu, `Pads: 8` in the status bar.
- `tests/baseline-screenshots/load-pcb-pic_programmer.png` shows the pic_programmer's fully-routed multi-IC layout with traces visible across F.Cu.
- `pcbnew.spec.ts` (empty-board path) still passes — no regression.
There remains a separate, pre-existing wasm-port issue downstream: after the board has fully rendered, KiCad's clipboard polling path hits a `RuntimeError: index out of bounds` (and `indirect call to null` on Firefox) inside `__asyncjs__js_clipboardHasText``Asyncify.handleSleep`. That's not blocking the load (the screenshot is fully painted by then) and is out of scope for this fix; the load-pcb test explicitly filters its assertion to the two things it cares about — no `[RTREE-DIAG]` and no `Aborted(` — leaving downstream clipboard noise for a follow-up.
---
## Reportable summary for upstream KiCad
Below is a self-contained version suitable for an upstream bug report; pull it as-is.
> **Title:** RTree `ELEMTYPEREAL = intptr_t` in `SHAPE_POLY_SET::splitCollinearOutlines` overflows on 32-bit-pointer targets
>
> **File:** `libs/kimath/src/geometry/shape_poly_set.cpp:1927`
>
> ```cpp
> RTree<intptr_t, intptr_t, 2, intptr_t> rtree;
> ```
>
> The 4th template parameter is `ELEMTYPEREAL`, which `RTree::RectVolume` and `RectSphericalVolume` use for the accumulated volume / sum-of-squares math. Every other `RTree<>` instantiation in KiCad passes `double` for that slot (`view_rtree.h`, `drc_rtree.h`, `connectivity_rtree.h`, `connectivity_items.h`, `eeschema/sch_rtree.h`, `kimath/include/geometry/shape_index.h`). This one passes `intptr_t`.
>
> On 64-bit-pointer hosts `intptr_t == int64_t` and the math fits, so the bug is latent. On 32-bit-pointer targets (wasm32, 32-bit Linux, etc.) `intptr_t == int32_t`, so `RectSphericalVolume`'s `sumOfSquares` overflows for typical KiCad nanometer extents (`~10⁸ per axis → halfExtent² ~5×10¹³`, well past `INT_MAX = 2.15×10⁹`). The wrap turns `m_coverSplitArea` negative, makes `worst = -m_coverSplitArea - 1` huge positive in `PickSeeds`, so `if (waste >= worst)` never fires for any pair. `seed0 == seed1 == 0` survives the loop, `Classify(0, 0)` succeeds, `Classify(0, 1)` trips
>
> ```
> Assertion failed: !a_parVars->m_taken[a_index]
> (thirdparty/rtree/geometry/rtree.h, line 1771)
> ```
>
> in debug builds. Release builds silently store a corrupt tree.
>
> Fix is one character class:
>
> ```diff
> -RTree<intptr_t, intptr_t, 2, intptr_t> rtree;
> +RTree<intptr_t, intptr_t, 2, double> rtree;
> ```
>
> Reproduces on any non-empty board on a wasm32 debug build (we've hit it on the `microwave`, `flat_hierarchy`, and `pic_programmer` demos). Should also reproduce on 32-bit Linux debug builds.

2
kicad

@ -1 +1 @@
Subproject commit 0cc6362377080b06e7ef662ba25f1e8ccccfd8bc
Subproject commit 07d8130d44493fc949da3611d50e0c3b1652f930

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

View file

@ -0,0 +1,241 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { clickByLabel, clickMenuBarItem, clickMenuItem } from '../e2e/utils/element-tracker';
/**
* Probe spec: drives the wizard, opens File menu, clicks Open, then dumps
* everything we need to know about the wxFileDialog state what frame paints,
* what directory the dialog starts in, what list items are registered, what
* the OK/Cancel button labels are, and which MEMFS directories exist.
*
* Output:
* tests/logs/kicad/load-pcb-probe/<test-name>.log (everything via console)
* tests/test-results/probe-*.png (screenshots)
*
* This is a one-shot investigation, not a regression test. It always passes;
* the value is the captured state.
*/
async function completeWizard(page: Page): Promise<void> {
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForTimeout(2000);
for (let i = 1; i <= 10; i++) {
let clicked = await clickByLabel(page, 'Next >');
if (!clicked) {
clicked = await clickByLabel(page, 'Finish');
break;
}
await page.waitForTimeout(500);
}
await page.waitForTimeout(2000);
}
async function dumpRegistry(page: Page, label: string): Promise<void> {
const summary = await page.evaluate((tag: string) => {
const registry = window.wxElementRegistry;
if (!registry) {
return { tag, error: 'no registry' };
}
const stats = registry.getStats();
const renderedStats = registry.getRenderedStats?.();
const allElements = registry.findAll({ visible: true });
const frames = allElements
.filter((el) => /Frame|Dialog|Wizard/.test(el.typeName))
.slice(0, 30)
.map((el) => ({
id: el.id,
typeName: el.typeName,
label: el.label,
name: el.name,
visible: el.visible,
enabled: el.enabled,
screenX: el.screenX,
screenY: el.screenY,
width: el.width,
height: el.height,
}));
// Try to spot the file dialog specifically
const fileDialogs = allElements
.filter((el) =>
/FileDialog|FileCtrl|FileList|GenericDir/.test(el.typeName) ||
/file/i.test(el.label) ||
/file/i.test(el.name)
)
.slice(0, 50)
.map((el) => ({
id: el.id,
typeName: el.typeName,
label: el.label,
name: el.name,
visible: el.visible,
screenX: el.screenX,
screenY: el.screenY,
width: el.width,
height: el.height,
}));
const rendered = registry.findAllRendered ? registry.findAllRendered({}) : [];
const byType = rendered.reduce<Record<string, number>>((acc, item) => {
acc[item.elementType] = (acc[item.elementType] ?? 0) + 1;
return acc;
}, {});
const menuItems = rendered
.filter((r) => r.elementType === 'menuitem')
.slice(0, 60)
.map((r) => ({
id: r.id,
subType: r.subType,
label: r.label,
enabled: r.enabled,
screenX: r.screenX,
screenY: r.screenY,
}));
const listItems = rendered
.filter((r) => r.elementType === 'listitem')
.slice(0, 60)
.map((r) => ({
id: r.id,
label: r.label,
index: r.index,
screenX: r.screenX,
screenY: r.screenY,
}));
// Buttons (rendered as wxButton in elements, not in rendered registry)
const buttons = allElements
.filter((el) => /Button/.test(el.typeName))
.slice(0, 40)
.map((el) => ({
id: el.id,
typeName: el.typeName,
label: el.label,
name: el.name,
screenX: el.screenX,
screenY: el.screenY,
}));
// Text controls (path bar in dialog is usually a wxTextCtrl)
const textCtrls = allElements
.filter((el) => /TextCtrl|ComboCtrl|ComboBox|Choice/.test(el.typeName))
.slice(0, 30)
.map((el) => ({
id: el.id,
typeName: el.typeName,
label: el.label,
name: el.name,
screenX: el.screenX,
screenY: el.screenY,
width: el.width,
height: el.height,
}));
return {
tag,
stats,
renderedStats,
renderedByType: byType,
frames,
fileDialogs,
menuItems,
listItems,
buttons,
textCtrls,
};
}, label);
// Logged via console so it lands in tests/logs/kicad/load-pcb-probe/<test>.log
console.log(`[PROBE] ${label} :: ${JSON.stringify(summary)}`);
}
async function dumpMemfs(page: Page, candidates: string[]): Promise<void> {
const results = await page.evaluate((paths: string[]) => {
const out: Array<{ path: string; entries: string[] | string }> = [];
for (const p of paths) {
try {
// @ts-ignore — Emscripten FS is global on Module
const entries = (window as any).FS.readdir(p) as string[];
out.push({ path: p, entries });
} catch (e: any) {
out.push({ path: p, entries: `ERROR: ${e?.message ?? e}` });
}
}
return out;
}, candidates);
console.log(`[PROBE-FS] ${JSON.stringify(results)}`);
}
test.describe('PCB load probe', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/kicad/pcbnew.html');
});
test('inspect File→Open dialog state', async ({ page }) => {
await completeWizard(page);
await page.screenshot({ path: 'test-results/probe-00-after-wizard.png', scale: 'device' });
await dumpMemfs(page, [
'/',
'/home',
'/home/kicad',
'/home/kicad/documents',
'/home/kicad/documents/kicad',
'/home/kicad/documents/kicad/9.99',
'/home/kicad/documents/kicad/9.99/projects',
'/tmp',
'/workspace',
]);
await dumpRegistry(page, 'after-wizard');
// Click File menu
const fileClicked = await clickMenuBarItem(page, 'File');
console.log(`[PROBE] File menu clicked: ${fileClicked}`);
await page.waitForTimeout(500);
await page.screenshot({ path: 'test-results/probe-01-file-menu-open.png', scale: 'device' });
await dumpRegistry(page, 'file-menu-open');
// Click Open menu item (try common label variants)
let openClicked = await clickMenuItem(page, 'Open...');
if (!openClicked) openClicked = await clickMenuItem(page, 'Open…');
if (!openClicked) openClicked = await clickMenuItem(page, 'Open');
console.log(`[PROBE] Open menu item clicked: ${openClicked}`);
// Give the file dialog generous time to render — wxGenericFileDialog
// populates its file list by scanning the directory, which on MEMFS
// is fast but goes through the Asyncify loop.
await page.waitForTimeout(3000);
await page.screenshot({ path: 'test-results/probe-02-after-open-click.png', scale: 'device' });
await dumpRegistry(page, 'after-open-click');
// Wait a bit longer and dump again, in case the dialog paints late
await page.waitForTimeout(3000);
await page.screenshot({ path: 'test-results/probe-03-late.png', scale: 'device' });
await dumpRegistry(page, 'late');
// Final check: re-dump MEMFS so we can confirm nothing changed under us
await dumpMemfs(page, [
'/home/kicad/documents/kicad/9.99/projects',
'/tmp',
]);
// Probe is always green — the artifacts (logs + screenshots) are the result.
expect(true).toBe(true);
});
});

View file

@ -0,0 +1,227 @@
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import {
clickByLabel,
clickMenuBarItem,
clickMenuItem,
} from '../e2e/utils/element-tracker';
import { injectFromSubmodule } from './utils/fs-inject';
import { waitForBoardLoaded } from './utils/board-ready';
/**
* Spike test: load real .kicad_pcb demos through pcbnew's File Open.
*
* Flow (parametrized for each demo below):
* 1. Wait for pcbnew to come up (dismiss the first-run KiCad Setup wizard
* if it appears depends on whether MEMFS already has config).
* 2. Inject the demo's .kicad_pcb (+ .kicad_pro for completeness) into
* MEMFS at PATHS::GetDefaultUserProjectsPath() confirmed by
* load-pcb-probe.spec.ts to be /home/kicad/documents/kicad/9.99/projects/.
* 3. Drive File Open via menu helpers.
* 4. Click on the file row in wxFileListCtrl (wasm port doesn't register
* listctrl rows in wxElementRegistry, so we click by the filelist's
* bounding box), then focus the filename text input and press Enter to
* trigger wxGenericFileDialog's accept path. (A direct OK button click
* doesn't dismiss the dialog in the wasm port; the listctrl row click
* populates the text input but doesn't mark the row selected enough for
* OK to satisfy validation.)
* 5. Dismiss any post-load info dialogs (missing-libs etc. pic_programmer
* uses local footprint libs and may pop one of these).
* 6. Wait until the file dialog and the LoadBoard progress dialog both go
* away. Screenshot the loaded board, named per-demo.
*
* The board load itself was crashing in `Classify` inside `rtree.h` until we
* fixed the wasm-only `ELEMTYPEREAL = intptr_t` (32-bit on wasm32) typo at
* `kicad/libs/kimath/src/geometry/shape_poly_set.cpp:1927`. See
* `features/<branch>/rtree-debug-findings.md` for the full trail.
*/
const KICAD_VERSION_DIR = '9.99';
const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`;
type DemoCfg = {
name: string; // appears in test name & screenshot filename
dir: string; // folder under kicad/demos/
stem: string; // file stem (kicad_pcb / kicad_pro)
};
const DEMOS: DemoCfg[] = [
{
// RF-polygon-heavy board — small, no external libs, was the case that
// tripped the rtree overflow in the first place.
name: 'microwave',
dir: 'microwave',
stem: 'microwave',
},
{
// "Normal" through-hole/SMD design — larger (~698 KB), uses local
// footprint libs from its own project tree (may pop a missing-libs
// warning, which we dismiss before screenshotting).
name: 'pic_programmer',
dir: 'pic_programmer',
stem: 'pic_programmer',
},
];
async function dismissWizardIfPresent(page: Page): Promise<void> {
// KiCad first-run setup wizard. Click Next > until it's gone, then Finish.
// If no wizard, both clicks no-op immediately.
for (let i = 0; i < 12; i++) {
const advanced = await clickByLabel(page, 'Next >');
if (!advanced) break;
await page.waitForTimeout(400);
}
await clickByLabel(page, 'Finish');
await page.waitForTimeout(800);
}
async function waitForPcbnew(page: Page): Promise<void> {
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForTimeout(2500);
await dismissWizardIfPresent(page);
await page.waitForFunction(() => {
const registry = window.wxElementRegistry;
if (!registry) return false;
return registry.findAll({ visible: true })
.some((el) => el.name === 'PcbFrame');
}, null, { timeout: 90000 });
await page.waitForTimeout(1500);
}
function runLoadPcbTest(demo: DemoCfg): void {
const pcbFilename = `${demo.stem}.kicad_pcb`;
const proFilename = `${demo.stem}.kicad_pro`;
test(`opens ${demo.name} demo from MEMFS through the wxFileDialog`, async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForPcbnew(page);
await page.screenshot({
path: `test-results/load-pcb-${demo.name}-00-pcbnew-ready.png`,
scale: 'device',
});
// ── Inject .kicad_pcb + .kicad_pro into the dialog's start dir. ──
await injectFromSubmodule(
page,
`kicad/demos/${demo.dir}/${pcbFilename}`,
`${PROJECT_DIR_MEMFS}/${pcbFilename}`,
);
await injectFromSubmodule(
page,
`kicad/demos/${demo.dir}/${proFilename}`,
`${PROJECT_DIR_MEMFS}/${proFilename}`,
);
// ── Drive the menu. ────────────────────────────────────────────
const fileClicked = await clickMenuBarItem(page, 'File');
expect(fileClicked, 'File menu should be findable').toBe(true);
await page.waitForTimeout(400);
const openClicked = await clickMenuItem(page, 'Open...');
expect(openClicked, 'Open… menu item should be findable').toBe(true);
// ── Wait for the wxFileDialog to appear, file list to paint. ───
await page.waitForFunction(() => {
const registry = window.wxElementRegistry;
if (!registry) return false;
return registry.findAll({ visible: true })
.some((el) => el.typeName === 'wxFileDialog');
}, null, { timeout: 15000 });
await page.waitForTimeout(1000);
await page.screenshot({
path: `test-results/load-pcb-${demo.name}-01-dialog-open.png`,
scale: 'device',
});
// ── Click the file row in the filelist control. ────────────────
const filelistBox = await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry) return null;
const filelist = registry.findAll({ visible: true })
.find((el) => el.typeName === 'wxFileListCtrl' || el.name === 'filelist');
return filelist ? {
x: filelist.screenX,
y: filelist.screenY,
width: filelist.width,
height: filelist.height,
} : null;
});
expect(filelistBox, 'wxFileListCtrl should be visible').not.toBeNull();
if (!filelistBox) throw new Error('filelist not found');
await page.mouse.click(filelistBox.x + 24, filelistBox.y + 32);
await page.waitForTimeout(300);
// ── Focus the filename text input and press Enter to accept. ──
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(150);
await page.keyboard.press('Control+a');
await page.keyboard.type(pcbFilename);
await page.waitForTimeout(150);
await page.keyboard.press('Enter');
// ── Wait for the load to complete (no dialogs visible). The
// wxInfoBar that pcbnew shows for "older format" PCBs is not a
// wxDialog, so waitForBoardLoaded doesn't get blocked by it. ──
// If we ever need to dismiss post-load wxMessageDialogs (missing
// libs etc.), do it INSIDE waitForBoardLoaded so the dismiss
// side-effect lives with the polling loop — calling page.evaluate
// from the test driver hangs once the post-load asyncify clipboard
// runtime error breaks the wasm event loop. ───────────────────
await page.waitForTimeout(1000);
// ── Wait for the load to complete (no dialogs visible). ───────
const result = await waitForBoardLoaded(page, testLogger, 60000);
console.log(`[TEST] ${demo.name} board-ready result: ${result}`);
// Take the screenshot immediately. Don't wait — KiCad's post-load
// clipboard-polling path can hit a wasm RuntimeError that occasionally
// closes the page entirely on Firefox; if we sleep first we sometimes
// lose the page before page.screenshot runs. The canvas is already
// fully painted by the time waitForBoardLoaded returns.
await page.screenshot({
path: `test-results/load-pcb-${demo.name}.png`,
scale: 'device',
});
// ── The two things this spike actually asserts: no rtree assert,
// no WASM Aborted during the load. The clipboard-polling
// asyncify RuntimeErrors that fire AFTER the board is rendered
// are a separate, pre-existing wasm-port limitation that we
// do not regress on here. ────────────────────────────────────
const allLines = [...testLogger.consoleLogs, ...testLogger.errors];
const rtreeDiag = allLines.filter((l) => l.includes('[RTREE-DIAG]'));
expect(
rtreeDiag,
`RTree Classify duplicate-index reappeared for ${demo.name}; this means the wasm-layer fix at shape_poly_set.cpp:1927 has regressed:\n${rtreeDiag.join('\n\n')}`,
).toEqual([]);
const aborts = allLines.filter((l) => l.includes('Aborted('));
expect(
aborts,
`WASM aborted during ${demo.name} load:\n${aborts.join('\n\n')}`,
).toEqual([]);
});
}
test.describe('Load real PCB via File → Open', () => {
// Two 187 MB wasm runtimes loaded in parallel saturate Firefox's memory
// and slow each test enough that the 180s per-test budget runs out before
// the post-load canvas-settle wait completes. Run serially.
test.describe.configure({ mode: 'serial' });
test.setTimeout(240000);
for (const demo of DEMOS) {
runLoadPcbTest(demo);
}
});

View file

@ -0,0 +1,71 @@
import type { Page } from '@playwright/test';
/**
* Wait for pcbnew to finish opening a board.
*
* Indicators we can rely on with the current wxwidgets-wasm registry:
* 1. The wxFileDialog ("filedlg") that we used to pick the file disappears.
* 2. The wxProgressDialog that KiCad pops up during LoadBoard appears and
* then disappears this is the most reliable "load complete" signal
* because pcbnew's frame title is set via wxFrame::SetTitle, which the
* WASM registry currently does not capture.
*
* We accept two terminal states:
* - "loaded": progress dialog was seen and then went away while PcbFrame
* stays visible (the happy path).
* - "no-dialogs": no dialogs are visible after the open command covers
* tiny boards where LoadBoard finishes before the progress dialog paints.
*
* Returns a string describing which path completed, for the test log.
*/
export async function waitForBoardLoaded(
page: Page,
logger: { consoleLogs: string[]; errors: string[] },
timeoutMs = 60000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
let progressSeen = false;
while (Date.now() < deadline) {
// Surface a WASM abort fast — otherwise the progress dialog never
// goes away and we'd burn the full timeout. KiCad logs the abort
// line through Module.printErr, which our test logger captures as
// a console error. We re-read the live arrays each tick.
const allLines = [...logger.consoleLogs, ...logger.errors];
const abort = allLines.find((l) =>
l.includes('Aborted(') || l.includes('RuntimeError: unreachable')
);
if (abort) {
throw new Error(`WASM aborted during LoadBoard:\n${abort}`);
}
const state = await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry) return { ready: false, dialogs: [] as string[], hasProgress: false, hasFileDlg: false };
const dialogs = registry.findAll({ visible: true })
.filter((el) => /Dialog/.test(el.typeName))
.map((el) => el.typeName);
const hasFileDlg = dialogs.includes('wxFileDialog');
const hasProgress = dialogs.some((t) => /Progress/.test(t));
const hasPcbFrame = registry.findAll({ visible: true })
.some((el) => el.name === 'PcbFrame');
return {
ready: hasPcbFrame && !hasFileDlg && !hasProgress,
dialogs,
hasProgress,
hasFileDlg,
};
});
if (state.hasProgress) {
progressSeen = true;
}
if (state.ready) {
return progressSeen ? 'loaded (progress dialog observed)' : 'loaded (no progress dialog seen)';
}
await page.waitForTimeout(200);
}
throw new Error(`Timed out waiting for board to load after ${timeoutMs}ms`);
}

View file

@ -0,0 +1,56 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
/**
* Read a host-side file and write it into the page's Emscripten MEMFS.
*
* Mirrors the resource-write pattern already in tests/apps/kicad/pcbnew.html
* (FS.mkdirTree + FS.writeFile) and the DnD writer in tests/apps/kicad/wx.js.
* Emits a "[KICAD] Wrote …" console line so the per-test log records the
* injection.
*/
export async function injectFileIntoMemfs(
page: Page,
hostPath: string,
memfsPath: string,
): Promise<void> {
const bytes = fs.readFileSync(hostPath);
const memfsDir = memfsPath.replace(/\/[^/]+$/, '') || '/';
await page.evaluate(
({ memfsDir, memfsPath, dataBase64 }) => {
// Decode base64 → Uint8Array in the page so we don't have to ship
// megabytes through Playwright's JSON channel as a number array.
const binary = atob(dataBase64);
const data = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
data[i] = binary.charCodeAt(i);
}
// @ts-expect-error — Emscripten FS lives on window via Module
const FS = (window as any).FS;
FS.mkdirTree(memfsDir);
FS.writeFile(memfsPath, data);
console.log(`[KICAD] Wrote ${memfsPath} (${data.length} bytes)`);
},
{
memfsDir,
memfsPath,
dataBase64: bytes.toString('base64'),
},
);
}
/**
* Convenience: read a file from the kicad/ submodule and inject it.
*/
export async function injectFromSubmodule(
page: Page,
relativePath: string, // e.g. "kicad/demos/microwave/microwave.kicad_pcb"
memfsPath: string,
): Promise<void> {
const projectRoot = path.resolve(__dirname, '..', '..', '..');
const hostPath = path.join(projectRoot, relativePath);
await injectFileIntoMemfs(page, hostPath, memfsPath);
}