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>
254 lines
13 KiB
TypeScript
254 lines
13 KiB
TypeScript
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);
|
||
});
|
||
});
|