pcbjam/tests/kicad/load-pcb.spec.ts

239 lines
11 KiB
TypeScript
Raw Normal View History

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/10.0/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.
*/
// KiCad 10 stores projects under /home/kicad/documents/kicad/10.0/projects
// (GetMajorMinorVersion() = "10.0"); the old WASM port used "9.99".
const KICAD_VERSION_DIR = '10.0';
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 });
fix(e2e): make the firefox kicad suite pass on GPU-less CI runners CI runs (Hetzner 27329612719, Ubicloud 27330989479) failed 22/41 identically, in two buckets, both firefox-environment issues (the suite is firefox-only: test:kicad -> --project=firefox): 1. No WebGL: headless Firefox can't create ANY GL context on a GPU-less VM (blocklist bypass still dies with FEATURE_FAILURE_WEBGL_EXHAUSTED_DRIVERS), so the GAL canvas failed and an error dialog blocked every UI flow. Fix: run headed under xvfb (GLX + Mesa llvmpipe software GL) with webgl.force-enabled — workflows now wrap the e2e step in xvfb-run. 2. pcbnew wasm OOM: the ~190M module OOMs Firefox's optimizing wasm JIT at compile ("InternalError: out of memory") and the app never boots. Fix: javascript.options.wasm_optimizingjit=false (baseline-only compile). Both prefs are CI-gated in playwright-kicad.config.ts; local runs keep stock behavior. Boot got slower (baseline JIT), so the wizard helpers now wait for the registry to have actual UI ENTRIES (the registry OBJECT exists pre-boot) instead of clicking into a fixed 10x500ms window. Validated in a local docker repro of the CI env (playwright:v1.57.0 image, no GPU): pl_editor canvas, gerbview wizard+canvas, and pcbnew wizard (on a properly asyncified 187M module) all pass; before the fix they reproduced the exact CI signatures. Known-fail left on CI: zoom-cursor — the zoom-in anchors at ~ -P in headed Firefox (screen-vs-client coordinate mix-up in the wheel->GAL path, headed mode only); marked test.fixme(CI) pending a wx-wasm-layer investigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:22:29 +02:00
// Registry object ≠ app booted: wait for real UI entries (wizard or main
// frame) before dismissing — CI boots slower (baseline-JIT wasm + software
// GL under xvfb) and the dismiss loop below is bounded.
await page.waitForFunction(() => {
const registry = window.wxElementRegistry;
return !!registry && registry.findAll({}).length > 0;
}, null, { timeout: 150000 });
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`,
feat(tests): screenshot regression + Discord review, perf-tracked New tooling in tests/tools/screenshots/ (TypeScript via tsx): - compare.ts: one pixelmatch engine (AA-excluded), connected-component "where to look" boxes, old|new+boxes|heatmap triptych, per-engine floors. - promote.ts: churn-free updater — overwrite a baseline only when decoded pixels differ beyond the floor, copying CI bytes verbatim (no re-encode churn); pulls a CI run via `gh run download` or a local --from dir. - post-discord.ts: always-on CI-on-main report (SHA + e2e status + the track-only runtime-perf table), then screenshot triptychs, batched + size-capped + flood-collapsed + 429-aware. - perf-report.ts: perf table with Δ vs the previous main run (via gh). - changelog.ts: no-build git-history baseline differ (Discord trigger B). - noise.ts / gen-manifest.ts: calibration + manifest generation. CI wiring: - wasm-build.yml: post-test step runs the gate + report on the already- produced test-results (no extra build); report-only (continue-on-error), posts only on push to main, inert without DISCORD_WEBHOOK_URL. - ci-ubicloud.yml: secrets: inherit (pass the webhook through). - screenshot-changelog.yml: ~30s no-build changelog on baseline changes. screenshot-manifest.json: canonical 354-name set + best-effort engine tags (313 chromium-swiftshader / 41 firefox-llvmpipe). Normalize scale:'device'->'css' across 18 spec files (no-op at CI DSF=1) so committed baselines are uniformly css-scaled. Design: CI's Linux render is the single source of truth; no pinned container (accept rare env drift -> re-promote); dev commits via promote. Replaces the byte-cmp compare-screenshots.sh + file-size-proxy update-baseline-screenshots.sh (kept for now until the first re-baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:01:15 +02:00
scale: 'css',
});
// ── 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`,
feat(tests): screenshot regression + Discord review, perf-tracked New tooling in tests/tools/screenshots/ (TypeScript via tsx): - compare.ts: one pixelmatch engine (AA-excluded), connected-component "where to look" boxes, old|new+boxes|heatmap triptych, per-engine floors. - promote.ts: churn-free updater — overwrite a baseline only when decoded pixels differ beyond the floor, copying CI bytes verbatim (no re-encode churn); pulls a CI run via `gh run download` or a local --from dir. - post-discord.ts: always-on CI-on-main report (SHA + e2e status + the track-only runtime-perf table), then screenshot triptychs, batched + size-capped + flood-collapsed + 429-aware. - perf-report.ts: perf table with Δ vs the previous main run (via gh). - changelog.ts: no-build git-history baseline differ (Discord trigger B). - noise.ts / gen-manifest.ts: calibration + manifest generation. CI wiring: - wasm-build.yml: post-test step runs the gate + report on the already- produced test-results (no extra build); report-only (continue-on-error), posts only on push to main, inert without DISCORD_WEBHOOK_URL. - ci-ubicloud.yml: secrets: inherit (pass the webhook through). - screenshot-changelog.yml: ~30s no-build changelog on baseline changes. screenshot-manifest.json: canonical 354-name set + best-effort engine tags (313 chromium-swiftshader / 41 firefox-llvmpipe). Normalize scale:'device'->'css' across 18 spec files (no-op at CI DSF=1) so committed baselines are uniformly css-scaled. Design: CI's Linux render is the single source of truth; no pinned container (accept rare env drift -> re-promote); dev commits via promote. Replaces the byte-cmp compare-screenshots.sh + file-size-proxy update-baseline-screenshots.sh (kept for now until the first re-baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:01:15 +02:00
scale: 'css',
});
2026-06-04 13:20:55 +02:00
// ── Focus the filename text field, type the name, accept. ──────
// The Open dialog gives default keyboard focus to the file LIST,
// where keystrokes act as type-ahead (and Enter on the highlighted
// ".." row navigates up) rather than filename entry. So we must click
// the wxTextCtrl to focus it first, located via the element registry
// rather than a fixed pixel offset. No filelist row click is needed —
// the earlier version's extra row + offset clicks were what left focus
// off the field, so the typed name never registered, the field stayed
// empty, and Enter was a no-op (the board never loaded).
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);
2026-06-04 13:20:55 +02:00
await page.waitForTimeout(200);
await page.keyboard.type(pcbFilename);
2026-06-04 13:20:55 +02:00
await page.waitForTimeout(300);
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`,
feat(tests): screenshot regression + Discord review, perf-tracked New tooling in tests/tools/screenshots/ (TypeScript via tsx): - compare.ts: one pixelmatch engine (AA-excluded), connected-component "where to look" boxes, old|new+boxes|heatmap triptych, per-engine floors. - promote.ts: churn-free updater — overwrite a baseline only when decoded pixels differ beyond the floor, copying CI bytes verbatim (no re-encode churn); pulls a CI run via `gh run download` or a local --from dir. - post-discord.ts: always-on CI-on-main report (SHA + e2e status + the track-only runtime-perf table), then screenshot triptychs, batched + size-capped + flood-collapsed + 429-aware. - perf-report.ts: perf table with Δ vs the previous main run (via gh). - changelog.ts: no-build git-history baseline differ (Discord trigger B). - noise.ts / gen-manifest.ts: calibration + manifest generation. CI wiring: - wasm-build.yml: post-test step runs the gate + report on the already- produced test-results (no extra build); report-only (continue-on-error), posts only on push to main, inert without DISCORD_WEBHOOK_URL. - ci-ubicloud.yml: secrets: inherit (pass the webhook through). - screenshot-changelog.yml: ~30s no-build changelog on baseline changes. screenshot-manifest.json: canonical 354-name set + best-effort engine tags (313 chromium-swiftshader / 41 firefox-llvmpipe). Normalize scale:'device'->'css' across 18 spec files (no-op at CI DSF=1) so committed baselines are uniformly css-scaled. Design: CI's Linux render is the single source of truth; no pinned container (accept rare env drift -> re-promote); dev commits via promote. Replaces the byte-cmp compare-screenshots.sh + file-size-proxy update-baseline-screenshots.sh (kept for now until the first re-baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:01:15 +02:00
scale: 'css',
});
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(asyncify): red-green race harness + ablation flags, unwind-catch shim, spec tightening, decisions docs The asyncify single-slot work, executed red-green (full ledger: docs/features/asyncify-arbiter/redgreen.md; decisions record: docs/features/async/07-decisions-and-outcome.md): - tests/apps/standalone/asyncify-races/ + tests/asyncify/ + dedicated playwright config: 8 scenarios reproducing the KiCad asyncify failure family with the kicad-faithful startup topology (pre-park fiber swap → park throw through the live trampoline). Built in 3 variants; the SHIM_DISABLE_TRAMPOLINE_HEAL / SHIM_DISABLE_HANDLESLEEP ablation builds keep the historical hang and index-out-of-bounds crash reproducible forever (mutation-style pins for the existing shims). - scripts/common/shims/handlesleep.js: catch the "unwind" park sentinel in the wakeUp path — when main's last pre-park suspension was a sleep, the main-loop park throw escaped through that sleep's promise reaction as an uncaught rejection (the calculator/gerbview console errors). - scripts/common/inject-dyncall-shims.sh: SHIM_DISABLE_* ablation knobs. - Spec tightening (the acceptance bar): 'uncaught exception: unwind' tolerance DELETED from pcbnew/eeschema specs; load-pcb gained a hard clean-console gate over 5 asyncify corruption signatures. - wxwidgets pointer bump: modal LIFO resolvers, pump resolve-on-error, sync clipboard IsSupported (014f67e6c1). Final state: asyncify suite 7/7, wx e2e 291/292 (1 skip), KiCad e2e 40 passed / 2 skipped with ZERO corruption signatures in any log across all six apps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:59:07 +02:00
// ── Clean-console gate: NO asyncify corruption may surface anywhere in
// the load — not before, not after the board renders. The formerly
// tolerated post-load clipboard/unwind RuntimeErrors are fixed
// (sync clipboard IsSupported in wx; "unwind" sentinel handling in
// scripts/common/shims/handlesleep.js; see docs/features/asyncify-arbiter/).
const asyncifySignatures = [
'index out of bounds',
'indirect call to null',
'uncaught exception: unwind',
'invalid state',
'is not a function',
];
const asyncifyErrors = allLines.filter((l) =>
asyncifySignatures.some((sig) => l.toLowerCase().includes(sig)),
);
expect(
asyncifyErrors,
`Asyncify corruption surfaced during ${demo.name} load:\n${asyncifyErrors.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);
}
});