feat(occ): ship board 3D model bodies with STEP/GLB exports (3d-models 0007)
The occ_service export worker has its own MEMFS — the editor's lazily-fetched
lib models were invisible there, so every export was a bare board (54
"Could not add 3D model" warnings on pic_programmer, 2 STEP products).
- models-bridge: collectBoardModelFiles(boardText) — scan refs, ensure via
the 0004 sparse source (IDB/R2, wrl->step fallback), read staged bytes
back, dedupe by real staged path.
- occ-service.ts: attach the collected models to every export request
(best-effort — prefetch failure still exports, misses reported by the
exporter); transfer the body buffers.
- occ-worker.js (shared app/harness): pass req.models through to occExport.
- occ_service_main.cpp: occExport(board, params, models) stages each entry
under PCBJAM_3D::MODELS_MEMFS_ROOT (path-sanitized) for the exporter's
staged-model probe (kicad 83645275ac), removed again after the export.
- tests: harness occ stub mirrors the prefetch against the page kicadLibs
provider + captures report/productCount; new occ-export-models.spec.ts
guards the delivery (green companion pins preconditions; guard asserts 0
missing lib models + component PRODUCTs). pic_programmer: 17/17 staged,
87 products @ 13.3 MB (was 2 @ 402 KB). models-bridge unit tests 13/13.
Known remainder (0007 step 4): project-local ${KIPRJMOD} refs still drop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UjpnviP3ZDxTM1Ap63Sqv
2026-07-10 08:44:55 +02:00
|
|
|
|
import * as fs from 'fs';
|
|
|
|
|
|
import * as path from 'path';
|
|
|
|
|
|
import type { Page } from '@playwright/test';
|
|
|
|
|
|
import { test, expect } from './fixtures';
|
e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate
Squash of experiment/ff-big-modules vs main.
Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.
Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.
One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.
SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.
Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.
CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.
Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
|
|
|
|
import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil } from '../e2e/utils/element-tracker';
|
feat(occ): ship board 3D model bodies with STEP/GLB exports (3d-models 0007)
The occ_service export worker has its own MEMFS — the editor's lazily-fetched
lib models were invisible there, so every export was a bare board (54
"Could not add 3D model" warnings on pic_programmer, 2 STEP products).
- models-bridge: collectBoardModelFiles(boardText) — scan refs, ensure via
the 0004 sparse source (IDB/R2, wrl->step fallback), read staged bytes
back, dedupe by real staged path.
- occ-service.ts: attach the collected models to every export request
(best-effort — prefetch failure still exports, misses reported by the
exporter); transfer the body buffers.
- occ-worker.js (shared app/harness): pass req.models through to occExport.
- occ_service_main.cpp: occExport(board, params, models) stages each entry
under PCBJAM_3D::MODELS_MEMFS_ROOT (path-sanitized) for the exporter's
staged-model probe (kicad 83645275ac), removed again after the export.
- tests: harness occ stub mirrors the prefetch against the page kicadLibs
provider + captures report/productCount; new occ-export-models.spec.ts
guards the delivery (green companion pins preconditions; guard asserts 0
missing lib models + component PRODUCTs). pic_programmer: 17/17 staged,
87 products @ 13.3 MB (was 2 @ 402 KB). models-bridge unit tests 13/13.
Known remainder (0007 step 4): project-local ${KIPRJMOD} refs still drop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UjpnviP3ZDxTM1Ap63Sqv
2026-07-10 08:44:55 +02:00
|
|
|
|
import { injectFromSubmodule } from './utils/fs-inject';
|
|
|
|
|
|
import { waitForBoardLoaded } from './utils/board-ready';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* STEP export × 3D model delivery (docs/features/3d-models, 0007): File →
|
|
|
|
|
|
* Export → STEP must include the board's lib component models.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The STEP export runs in the occ_service worker — its own wasm module with
|
|
|
|
|
|
* its own MEMFS, where the editor's model files are invisible. Delivery
|
|
|
|
|
|
* (0007): the export request ships the board's prefetched lib model bodies
|
|
|
|
|
|
* (`models` array — in the app collected via models-bridge from R2/IDB; here
|
|
|
|
|
|
* mirrored by the harness occ stub against the page's kicadLibs provider),
|
|
|
|
|
|
* the worker stages them under /pcbjam/3dmodels, and EXPORTER_STEP's
|
|
|
|
|
|
* staged-model probe (pcbjam_model_fetch.h FindStagedModel) resolves them on
|
|
|
|
|
|
* a resolver miss.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The first test pins the preconditions (board really references lib models,
|
|
|
|
|
|
* the export chain itself works, the model provider serves any ref) so a
|
|
|
|
|
|
* failure of the second can only come from the delivery, not the harness.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const KICAD_VERSION_DIR = '10.0';
|
|
|
|
|
|
const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`;
|
|
|
|
|
|
|
|
|
|
|
|
// Same JS-owned MEMFS model root as the standalone models-bridge
|
|
|
|
|
|
// (constants.ts MODELS_3D_ROOT) — where a delivery fix materializes bodies.
|
|
|
|
|
|
const MODELS_ROOT_MEMFS = '/pcbjam/3dmodels';
|
|
|
|
|
|
|
|
|
|
|
|
// Stand-in STEP bytes served for EVERY lib ref (geometry fidelity is
|
|
|
|
|
|
// irrelevant — the assertion is delivery, not looks).
|
|
|
|
|
|
const STEP_FIXTURE = 'kicad/demos/openair-max/Libraries/HRO_TYPE-C-31-M-12.step';
|
|
|
|
|
|
|
|
|
|
|
|
const DEMO = { name: 'pic_programmer', dir: 'pic_programmer', stem: 'pic_programmer' } as const;
|
|
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
|
interface Window {
|
|
|
|
|
|
__modelEnsures?: Array<{ op: string; arg: string; kind: string }>;
|
|
|
|
|
|
__stepFixtureB64?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ExportCapture {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
size: number;
|
|
|
|
|
|
magic: string;
|
|
|
|
|
|
report: string;
|
|
|
|
|
|
productCount: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Wait for a rendered popup menu to have its items (replaces a fixed post-menu-click sleep). */
|
|
|
|
|
|
async function waitForMenuItems(page: Page): Promise<void> {
|
|
|
|
|
|
await waitUntil(
|
|
|
|
|
|
page,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
const r = window.wxElementRegistry;
|
|
|
|
|
|
if (!r?.findAllRendered) return false;
|
|
|
|
|
|
return r.findAllRendered({ elementType: 'menuitem' }).length > 3;
|
|
|
|
|
|
},
|
|
|
|
|
|
'popup menu items rendered',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Record every model3d bridge request and serve ALL of them from the fixture —
|
|
|
|
|
|
* the delivery side is never the bottleneck in this spec (mirrors the serveAll
|
|
|
|
|
|
* stub in 3d-viewer-models.spec.ts).
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function installModelProviderStub(page: Page): Promise<void> {
|
|
|
|
|
|
const fixtureAbs = path.resolve(__dirname, '..', '..', STEP_FIXTURE);
|
|
|
|
|
|
await page.evaluate(
|
|
|
|
|
|
(b64: string) => { window.__stepFixtureB64 = b64; },
|
|
|
|
|
|
fs.readFileSync(fixtureAbs).toString('base64'),
|
|
|
|
|
|
);
|
|
|
|
|
|
await page.evaluate((stockDir: string) => {
|
|
|
|
|
|
window.__modelEnsures = [];
|
|
|
|
|
|
(globalThis as any).kicadLibs = {
|
|
|
|
|
|
request: async (op: string, _lib: string, arg: string, kind: string) => {
|
|
|
|
|
|
if (kind !== 'model3d') return null;
|
|
|
|
|
|
window.__modelEnsures!.push({ op, arg, kind });
|
|
|
|
|
|
console.log(`[TEST-OCC-MODELS] ensure request: ${op} ${arg}`);
|
|
|
|
|
|
if (op !== 'ensure') return null;
|
|
|
|
|
|
|
|
|
|
|
|
const b64 = window.__stepFixtureB64!;
|
|
|
|
|
|
const binary = atob(b64);
|
|
|
|
|
|
const data = new Uint8Array(binary.length);
|
|
|
|
|
|
for (let i = 0; i < binary.length; i++) data[i] = binary.charCodeAt(i);
|
|
|
|
|
|
|
|
|
|
|
|
// Mirror models-bridge.ts ensureModelInMemfs: write under the
|
|
|
|
|
|
// JS-owned model root, answer with the ABSOLUTE path.
|
|
|
|
|
|
// @ts-expect-error — Emscripten FS lives on window
|
|
|
|
|
|
const FS = (window as any).FS;
|
|
|
|
|
|
const dest = `${stockDir}/${arg}`;
|
|
|
|
|
|
FS.mkdirTree(dest.slice(0, dest.lastIndexOf('/')));
|
|
|
|
|
|
FS.writeFile(dest, data);
|
|
|
|
|
|
return dest;
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}, MODELS_ROOT_MEMFS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
// Project-local (${KIPRJMOD}) models — resolvable by the stock resolver in
|
|
|
|
|
|
// the EDITOR; the worker-side exporter must get them delivered too.
|
|
|
|
|
|
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/libs/3d_shapes/textool_40.wrl`,
|
|
|
|
|
|
`${PROJECT_DIR_MEMFS}/libs/3d_shapes/textool_40.wrl`);
|
|
|
|
|
|
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/libs/3d_shapes/adjustable_rx2v4.wrl`,
|
|
|
|
|
|
`${PROJECT_DIR_MEMFS}/libs/3d_shapes/adjustable_rx2v4.wrl`);
|
|
|
|
|
|
|
|
|
|
|
|
expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true);
|
|
|
|
|
|
await waitForMenuItems(page);
|
e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate
Squash of experiment/ff-big-modules vs main.
Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.
Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.
One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.
SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.
Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.
CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.
Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
|
|
|
|
// Items register progressively while the popup paints — wait for the one
|
|
|
|
|
|
// we click (clickMenuItem is single-shot; the >3-items gate isn't enough).
|
|
|
|
|
|
await waitForRenderedByLabel(page, 'Open...', { elementType: 'menuitem' });
|
feat(occ): ship board 3D model bodies with STEP/GLB exports (3d-models 0007)
The occ_service export worker has its own MEMFS — the editor's lazily-fetched
lib models were invisible there, so every export was a bare board (54
"Could not add 3D model" warnings on pic_programmer, 2 STEP products).
- models-bridge: collectBoardModelFiles(boardText) — scan refs, ensure via
the 0004 sparse source (IDB/R2, wrl->step fallback), read staged bytes
back, dedupe by real staged path.
- occ-service.ts: attach the collected models to every export request
(best-effort — prefetch failure still exports, misses reported by the
exporter); transfer the body buffers.
- occ-worker.js (shared app/harness): pass req.models through to occExport.
- occ_service_main.cpp: occExport(board, params, models) stages each entry
under PCBJAM_3D::MODELS_MEMFS_ROOT (path-sanitized) for the exporter's
staged-model probe (kicad 83645275ac), removed again after the export.
- tests: harness occ stub mirrors the prefetch against the page kicadLibs
provider + captures report/productCount; new occ-export-models.spec.ts
guards the delivery (green companion pins preconditions; guard asserts 0
missing lib models + component PRODUCTs). pic_programmer: 17/17 staged,
87 products @ 13.3 MB (was 2 @ 402 KB). models-bridge unit tests 13/13.
Known remainder (0007 step 4): project-local ${KIPRJMOD} refs still drop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UjpnviP3ZDxTM1Ap63Sqv
2026-07-10 08:44:55 +02:00
|
|
|
|
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 });
|
|
|
|
|
|
// Wait for the filename text input to paint (the dialog object exists before its
|
|
|
|
|
|
// inner controls register; replaces a fixed 1000ms).
|
|
|
|
|
|
await waitUntil(page, () => {
|
|
|
|
|
|
const r = window.wxElementRegistry;
|
|
|
|
|
|
return !!r && r.findAll({ visible: true }).some((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
|
|
|
|
|
|
}, 'file dialog filename input');
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
// Documented interaction dwells: focus + typed-text registration have no observable signal.
|
|
|
|
|
|
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
|
|
|
|
|
|
await page.keyboard.type(pcbFilename);
|
|
|
|
|
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
|
|
|
|
|
await page.keyboard.press('Enter');
|
|
|
|
|
|
|
|
|
|
|
|
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
|
|
|
|
|
console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Click a visible wx button by label; returns whether it was found. */
|
|
|
|
|
|
async function clickWxButton(page: Page, label: string): Promise<boolean> {
|
|
|
|
|
|
const pos = await page.evaluate((wanted: string) => {
|
|
|
|
|
|
const registry = window.wxElementRegistry;
|
|
|
|
|
|
if (!registry) return null;
|
|
|
|
|
|
const el = registry.findAll({ visible: true })
|
|
|
|
|
|
.find((e) => (e.label === wanted || e.label === `&${wanted}`)
|
|
|
|
|
|
&& (e.typeName ?? '').includes('Button'));
|
|
|
|
|
|
return el ? { x: el.centerX, y: el.centerY } : null;
|
|
|
|
|
|
}, label);
|
|
|
|
|
|
if (!pos) return false;
|
|
|
|
|
|
await page.mouse.click(pos.x, pos.y);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Drive File → Export → STEP through the (unchanged) dialog; return the capture. */
|
|
|
|
|
|
async function runStepExport(page: Page): Promise<{ exp: ExportCapture; ensures: Array<{ op: string; arg: string }> }> {
|
|
|
|
|
|
expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true);
|
|
|
|
|
|
await waitForMenuItems(page);
|
e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate
Squash of experiment/ff-big-modules vs main.
Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.
Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.
One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.
SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.
Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.
CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.
Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
|
|
|
|
await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' });
|
feat(occ): ship board 3D model bodies with STEP/GLB exports (3d-models 0007)
The occ_service export worker has its own MEMFS — the editor's lazily-fetched
lib models were invisible there, so every export was a bare board (54
"Could not add 3D model" warnings on pic_programmer, 2 STEP products).
- models-bridge: collectBoardModelFiles(boardText) — scan refs, ensure via
the 0004 sparse source (IDB/R2, wrl->step fallback), read staged bytes
back, dedupe by real staged path.
- occ-service.ts: attach the collected models to every export request
(best-effort — prefetch failure still exports, misses reported by the
exporter); transfer the body buffers.
- occ-worker.js (shared app/harness): pass req.models through to occExport.
- occ_service_main.cpp: occExport(board, params, models) stages each entry
under PCBJAM_3D::MODELS_MEMFS_ROOT (path-sanitized) for the exporter's
staged-model probe (kicad 83645275ac), removed again after the export.
- tests: harness occ stub mirrors the prefetch against the page kicadLibs
provider + captures report/productCount; new occ-export-models.spec.ts
guards the delivery (green companion pins preconditions; guard asserts 0
missing lib models + component PRODUCTs). pic_programmer: 17/17 staged,
87 products @ 13.3 MB (was 2 @ 402 KB). models-bridge unit tests 13/13.
Known remainder (0007 step 4): project-local ${KIPRJMOD} refs still drop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UjpnviP3ZDxTM1Ap63Sqv
2026-07-10 08:44:55 +02:00
|
|
|
|
expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true);
|
e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate
Squash of experiment/ff-big-modules vs main.
Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.
Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.
One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.
SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.
Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.
CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.
Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:10:40 +02:00
|
|
|
|
// Wait for the SUBMENU's item — waitForMenuItems(>3) is satisfied by
|
|
|
|
|
|
// the still-rendered File menu items before the submenu paints.
|
|
|
|
|
|
await waitForRenderedByLabel(page, 'STEP/GLB/BREP/XAO/PLY/STL...', { elementType: 'menuitem' });
|
feat(occ): ship board 3D model bodies with STEP/GLB exports (3d-models 0007)
The occ_service export worker has its own MEMFS — the editor's lazily-fetched
lib models were invisible there, so every export was a bare board (54
"Could not add 3D model" warnings on pic_programmer, 2 STEP products).
- models-bridge: collectBoardModelFiles(boardText) — scan refs, ensure via
the 0004 sparse source (IDB/R2, wrl->step fallback), read staged bytes
back, dedupe by real staged path.
- occ-service.ts: attach the collected models to every export request
(best-effort — prefetch failure still exports, misses reported by the
exporter); transfer the body buffers.
- occ-worker.js (shared app/harness): pass req.models through to occExport.
- occ_service_main.cpp: occExport(board, params, models) stages each entry
under PCBJAM_3D::MODELS_MEMFS_ROOT (path-sanitized) for the exporter's
staged-model probe (kicad 83645275ac), removed again after the export.
- tests: harness occ stub mirrors the prefetch against the page kicadLibs
provider + captures report/productCount; new occ-export-models.spec.ts
guards the delivery (green companion pins preconditions; guard asserts 0
missing lib models + component PRODUCTs). pic_programmer: 17/17 staged,
87 products @ 13.3 MB (was 2 @ 402 KB). models-bridge unit tests 13/13.
Known remainder (0007 step 4): project-local ${KIPRJMOD} refs still drop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UjpnviP3ZDxTM1Ap63Sqv
2026-07-10 08:44:55 +02:00
|
|
|
|
expect(await clickMenuItem(page, 'STEP/GLB/BREP/XAO/PLY/STL...'),
|
|
|
|
|
|
'STEP export menu item').toBe(true);
|
|
|
|
|
|
|
|
|
|
|
|
await page.waitForFunction(() => {
|
|
|
|
|
|
const registry = window.wxElementRegistry;
|
|
|
|
|
|
return !!registry && registry.findAll({ visible: true })
|
|
|
|
|
|
.some((el) => (el.label === 'Export' || el.label === '&Export')
|
|
|
|
|
|
&& (el.typeName ?? '').includes('Button'));
|
|
|
|
|
|
}, null, { timeout: 20000 });
|
|
|
|
|
|
|
|
|
|
|
|
expect(await clickWxButton(page, 'Export'), 'Export button click').toBe(true);
|
|
|
|
|
|
|
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
|
() => ((window as any).__occExports?.length ?? 0) > 0,
|
|
|
|
|
|
null, { timeout: 180000 });
|
|
|
|
|
|
|
|
|
|
|
|
const exports = await page.evaluate(() => (window as any).__occExports as ExportCapture[]);
|
|
|
|
|
|
expect(exports, 'exactly one export captured').toHaveLength(1);
|
|
|
|
|
|
const ensures = await page.evaluate(() => window.__modelEnsures ?? []);
|
|
|
|
|
|
return { exp: exports[0], ensures };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Parse the exporter report's missing-model warnings into lib / project refs. */
|
|
|
|
|
|
function missingModels(report: string): { lib: string[]; project: string[]; other: string[] } {
|
|
|
|
|
|
const out = { lib: [] as string[], project: [] as string[], other: [] as string[] };
|
|
|
|
|
|
const re = /Could not add 3D model for [^\n]+\n\s*File not found: ([^\n]+)/g;
|
|
|
|
|
|
for (let m = re.exec(report); m; m = re.exec(report)) {
|
|
|
|
|
|
const file = m[1].trim();
|
|
|
|
|
|
if (file.includes('.3dshapes')) out.lib.push(file);
|
|
|
|
|
|
else if (file.includes('KIPRJMOD') || file.includes('3d_shapes')) out.project.push(file);
|
|
|
|
|
|
else out.other.push(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
test.describe('STEP export × 3D model delivery', () => {
|
|
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
|
|
test.setTimeout(240000);
|
|
|
|
|
|
|
|
|
|
|
|
// GREEN COMPANION — pins every precondition of the red repro below:
|
|
|
|
|
|
// the board really references lib models, the export chain works end to
|
|
|
|
|
|
// end, and the report channel carries the exporter's warnings.
|
|
|
|
|
|
test('export chain works and the board references lib models', async ({ page, testLogger }) => {
|
|
|
|
|
|
// Precondition: the demo board references ${KICAD*_3DMODEL_DIR} lib
|
|
|
|
|
|
// models AND ${KIPRJMOD} project models (counted from the source file,
|
|
|
|
|
|
// so a demo change can't silently hollow out the repro).
|
|
|
|
|
|
const pcbText = fs.readFileSync(
|
|
|
|
|
|
path.resolve(__dirname, '..', '..', `kicad/demos/${DEMO.dir}/${DEMO.stem}.kicad_pcb`), 'utf8');
|
|
|
|
|
|
const libRefs = pcbText.match(/\(model "\$\{KICAD[^"]*\.3dshapes\/[^"]+"/g) ?? [];
|
|
|
|
|
|
const prjRefs = pcbText.match(/\(model "\$\{KIPRJMOD\}[^"]+"/g) ?? [];
|
|
|
|
|
|
console.log(`[TEST] board model refs: ${libRefs.length} lib, ${prjRefs.length} project`);
|
|
|
|
|
|
expect(libRefs.length, 'board must reference lib 3D models').toBeGreaterThan(0);
|
|
|
|
|
|
expect(prjRefs.length, 'board must reference project-local 3D models').toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
|
|
await page.goto('/kicad/pcbnew.html');
|
|
|
|
|
|
await waitForEditorReady(page);
|
|
|
|
|
|
await installModelProviderStub(page);
|
|
|
|
|
|
await loadBoard(page, testLogger);
|
|
|
|
|
|
|
|
|
|
|
|
const { exp, ensures } = await runStepExport(page);
|
|
|
|
|
|
|
|
|
|
|
|
// The chain itself is healthy: a real STEP came back with a report.
|
|
|
|
|
|
expect(exp.name, 'download name from the dialog').toMatch(/\.step$/i);
|
|
|
|
|
|
expect(exp.magic.startsWith('ISO-10303-21'), 'STEP magic').toBe(true);
|
|
|
|
|
|
expect(exp.size, 'non-trivial STEP body').toBeGreaterThan(10_000);
|
|
|
|
|
|
expect(exp.productCount, 'PRODUCT entities parsed from the body').toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
|
|
// Diagnostics for the red test's failure readout.
|
|
|
|
|
|
const missing = missingModels(exp.report);
|
|
|
|
|
|
console.log(`[TEST] export report: ${missing.lib.length} lib + ${missing.project.length} project`
|
|
|
|
|
|
+ ` + ${missing.other.length} other missing models;`
|
|
|
|
|
|
+ ` products=${exp.productCount}, size=${exp.size}B,`
|
|
|
|
|
|
+ ` model3d ensure requests during export: ${ensures.length}`);
|
|
|
|
|
|
for (const f of missing.lib.slice(0, 5)) console.log(`[TEST] missing lib: ${f}`);
|
|
|
|
|
|
for (const f of missing.project) console.log(`[TEST] missing project: ${f}`);
|
|
|
|
|
|
|
|
|
|
|
|
// Dismiss the export report dialog (its appearance after the worker
|
|
|
|
|
|
// returns has no distinct registry signal to poll).
|
|
|
|
|
|
await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell
|
|
|
|
|
|
await clickWxButton(page, 'OK');
|
|
|
|
|
|
|
|
|
|
|
|
expect(testLogger.errors, 'no page errors during the export flow').toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// The delivery guard (docs/features/3d-models/0007). Assertions are
|
|
|
|
|
|
// OUTCOME-level (report + geometry), not tied to a delivery mechanism.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Scope: LIB (`.3dshapes`) models — the R2/IDB-delivered kind. The two
|
|
|
|
|
|
// project-local (${KIPRJMOD}) refs are still dropped (logged by the
|
|
|
|
|
|
// companion above); asserting their delivery is the 0007 step-4
|
|
|
|
|
|
// fast-follow.
|
|
|
|
|
|
test('exported STEP includes the board lib component models', async ({ page, testLogger }) => {
|
|
|
|
|
|
await page.goto('/kicad/pcbnew.html');
|
|
|
|
|
|
await waitForEditorReady(page);
|
|
|
|
|
|
await installModelProviderStub(page);
|
|
|
|
|
|
await loadBoard(page, testLogger);
|
|
|
|
|
|
|
|
|
|
|
|
const { exp, ensures } = await runStepExport(page);
|
|
|
|
|
|
const missing = missingModels(exp.report);
|
|
|
|
|
|
console.log(`[TEST] model3d ensure requests during export: ${ensures.length}`);
|
|
|
|
|
|
|
|
|
|
|
|
// Every servable lib ref was delivered: none may be dropped from the
|
|
|
|
|
|
// assembly with a "File not found" report warning.
|
|
|
|
|
|
expect(missing.lib, 'no lib model may be missing from the export').toEqual([]);
|
|
|
|
|
|
|
|
|
|
|
|
// The prefetch really crossed the model bridge (the stub serves via
|
|
|
|
|
|
// kicadLibs, mirroring the app's models-bridge source).
|
|
|
|
|
|
expect(ensures.length, 'lib bodies were ensured for the export').toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
|
|
// The assembly carries per-component geometry: many PRODUCT entities,
|
|
|
|
|
|
// not just the bare board's 2.
|
|
|
|
|
|
expect(exp.productCount, 'exported STEP contains component products').toBeGreaterThan(5);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|