pcbjam/tests/kicad/occ-export-models.spec.ts
Istvan Matejcsok c421d724b0 findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work
A review of the group-E fixes found 13 further defects; ten were introduced by
those fixes, two pre-existed and were merely relocated, one is deferred.

Services / transport
  E-10  retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning
        mirror stayed latched true after a mid-run worker death: Run stayed
        disabled and the promised fresh-worker restart was unreachable for the
        whole session. Retirement now dispatches a synthetic controlled-exit
        straight to the installed handler (never through dispatchEvt — a
        fabricated frame must not touch the credit ledger). Driving the repro
        exposed two further defects, both fixed here: a replacement worker
        trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit
        that uninitialized engine before KiCad's validate() re-init (the native
        flow assumes a crashed engine survives in-process — true for the dll,
        false for a dead worker). Reads now answer their empty shapes pre-init,
        writes lazy-init, and init is idempotent per worker engine.
  E-19  dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice
        client deliberately rethrows non-trap errors — so each throw leaked one
        unit of the 64-frame credit window until the stream died with a
        misattributed "transport exceeded". The ack moves to a finally in both
        service copies; the throw still propagates (the trap machinery needs it).
  E-20  the oversize-line path promises to transfer the accepted prefix, but
        with the window full that flush only DEFERS, and stopEventStream wiped
        the deferred queue — losing the diagnostics that explain the failure.
        The terminal notice now carries them as pendingEvents; both hosts
        deliver them in order, unacked (the fatal frame is outside the credit
        protocol).
  E-21  the 30s prefetch deadline discarded every model already collected and
        reported nothing. A caller-owned progress sink ships the partials and
        the omission reaches the export report. (Awaiting the aborted collection
        was rejected: an in-flight source fetch is not abortable — E-4's
        original disease.) Plus a serving-candidate memo, so a .wrl ref served
        by its .step fallback stops re-probing the miss on every export.

Scheduler
  E-14  _terminalizeNativeTrap classified by message substring, so any plain JS
        error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a
        healthy instance. Now structural only: instanceof RuntimeError plus a
        duck-typed name check (verified in this build's glue that abort() throws
        a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort
        now latches the gate — the authoritative notification, previously
        ignored.
  E-15  the shim half: _pumpResume gates on terminal (catching wakes already
        queued at latch time) and resolveWait refuses on terminal WITHOUT
        consuming the entry, so a frame stays visibly parked rather than
        resuming inside a trapped module.
  E-16  the E-5 handler read the realm-global scheduler at dispatch instead of
        its installing module's; also frees the per-line buffer on the non-trap
        rethrow path.
  E-11  get_vec trusted the worker's res.length over the transferred arrays.
        Observed death shape: a 4 GiB std::vector threw an unhandled
        std::length_error that exited the editor's main loop. Now clamped, with
        the buffers freed on every failure path.

Guardrails (replacing two deferred refactors: e2e→production-code injection and
collapsing the four copies of the worker-lifecycle machinery)
  E-18  the source contract asserted comment-string counts — rewording failed
        CI while moving a guard outside its #ifdef passed. It now parses the
        #ifdef regions and asserts on code.
        service-stub-parity.ts pins what the four lifecycle copies must share:
        credit-window equality parsed from source, the finally-ack, boot
        deadlines, terminal-notice consumption. The transport numbers are now
        single-sourced from the worker.
        CI actually runs the gates: the web/standalone vitest suites (which had
        NEVER run in CI), the reducer, the source contract and the parity tool —
        with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint.
  E-22  the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in
        the ngspice-stub shape with a wedgeNextBoot() repro hook.

Every behavioral fix has red-then-green evidence (the reds were captured first).
E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's
transport deferral) is DEFERRED with its analysis recorded — a real fix needs
run identity on the bg frames.

Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare
markers carry their reason; three export-report dwells became modal-lease polls;
exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch,
an unused fault hook and unused receipt plumbing are gone; abort scans, wx
dialog drivers, the sim harness and the vitest FakeWorker are each one copy now.

Bumps kicad and wxwidgets to their findings-group-e tips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 18:19:16 +02:00

254 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil } from '../e2e/utils/element-tracker';
import { injectFromSubmodule } from './utils/fs-inject';
import { waitForBoardLoaded } from './utils/board-ready';
import { clickWxButton, openStepExportDialog, waitForMenuItems, dismissReportDialog } from './utils/wx-dialogs';
/**
* 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;
}
/**
* 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.
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);
// 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' });
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: focus registration has no observable signal
await page.keyboard.type(pcbFilename);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-text registration has no observable signal
await page.keyboard.press('Enter');
const result = await waitForBoardLoaded(page, testLogger, 60000);
console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`);
}
/** 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 }> }> {
await openStepExportDialog(page);
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 via its modal lease (the export
// dialog holds 1; the report raises it to 2).
await dismissReportDialog(page, 1, 'export report');
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);
});
});