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>
This commit is contained in:
Istvan Matejcsok 2026-08-27 12:27:12 +02:00
commit c421d724b0
48 changed files with 2071 additions and 694 deletions

View file

@ -144,9 +144,9 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors
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.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
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);
@ -215,7 +215,7 @@ test.describe('3D viewer component models', () => {
SERVED_REF, { timeout: 120000 });
// Let the rest of the model-enumeration ensures flush after the served ref lands —
// the total count isn't known up front, so this is a documented settle interval.
await page.waitForTimeout(3000); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(3000); // eslint-disable-line -- documented interaction dwell: model-enumeration ensures flush; total count unknown up front
// --- bridge assertions (run on CI too) ---------------------------------
const ensures = await page.evaluate(() => window.__modelEnsures ?? []);

View file

@ -269,13 +269,13 @@ test.describe('3D viewer from pcbnew', () => {
// Let the frame-move op (wx_window_move → wxWindow::Move) fully settle before the
// next interaction: the DOM style.top updates before the wx-side op completes, so
// polling the outcome races the following close click (documented interaction dwell).
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: frame-move op settle; polling races the close click
const afterTop = await styleTop(winId as string);
expect(afterTop, 'dragging the title bar should move the 3D viewer frame').not.toBe(beforeTop);
// Close via the × (wx_window_close → wx Close() → OnCloseWindow).
await page.locator(`#${winId} .window-titlebar-close`).click();
await page.waitForTimeout(600); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(600); // eslint-disable-line -- documented interaction dwell: wx Close() commit before checking the frame is gone
const gone = await page.evaluate((wid) => {
const el = document.getElementById(wid);
return !el || getComputedStyle(el).display === 'none';
@ -352,7 +352,7 @@ test.describe('3D viewer from pcbnew', () => {
await page.mouse.up();
// Let the resize op (wx_window_resize → SetSize → relayout + GL canvas resize)
// settle before reading widths (documented interaction dwell).
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: resize + GL canvas relayout settle before reading widths
const afterFrame = await frameWidth(winId as string);
const afterGl = await glWidth();

View file

@ -0,0 +1,183 @@
import { test, expect } from './fixtures';
import { clickByTooltip, waitForEditorReady } from '../e2e/utils/element-tracker';
import { FATAL_WASM_PATTERNS, findNativeFailure } from './utils/native-failure';
import {
loadRectifier,
openSimulator,
runSimulation,
waitForRunToolEnabled,
} from './utils/sim-harness';
/**
* eeschema simulator worker-death recovery (findings E-10/E-12/E-11): the
* promise of the out-of-process engine is that a worker death settles
* everything in flight and the next Run transparently boots a fresh worker.
* These specs kill (or corrupt) the service at exact points and assert the
* simulator UI actually recovers:
*
* - E-10: a mid-run worker death must unlatch the client's s_bgRunning
* mirror (via the service's synthetic controlled-exit) — otherwise the
* Run tool's ENABLE(!simRunning) holds "running" forever and the promised
* fresh-worker restart is unreachable for the whole session.
* - E-12: a run whose transport dies between launch acceptance and its
* RUNNING transition delivers its crash-exit completion — the wasm-only
* unowned-event drop must not swallow an owned run's only IDLE.
* - E-11: a corrupted worker's oversized get_vec length must be clamped to
* the actually-transferred arrays — not copied into the editor heap as a
* multi-gigabyte read that traps the instance.
*/
test.describe('eeschema simulator worker-death recovery', () => {
test.setTimeout(300000);
test('a mid-run worker death re-enables Run and a rerun succeeds (E-10)', async ({ page, testLogger }) => {
await page.goto('/kicad/eeschema.html');
await waitForEditorReady(page);
await loadRectifier(page);
await openSimulator(page);
await waitForRunToolEnabled(page);
const checkpoint = await page.evaluate(() => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
return hooks.appliedGenerationCheckpoint() as number;
});
// Start a run and inject the worker death while it is live. The check
// and the retirement happen in ONE page.evaluate — frames dispatch on
// the same main thread, so no finish frame can interleave between the
// "still running" check and the kill. Event scans are scoped past any
// frame-open activity (workbook plot restoration).
const eventFloor = await page.evaluate(
() => ((window as any).__ngspiceEvents as unknown[]).length);
expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }),
'Run tool').toBe(true);
// Run accepted: the worker's bg started frame arrived.
await expect.poll(
() => page.evaluate((floor: number) =>
((window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>)
.slice(floor)
.some((e) => e.kind === 'bg' && e.finished === false), eventFloor),
{ message: 'the run must report bg started', timeout: 60000 },
).toBe(true);
const injected = await page.evaluate((floor: number) => {
const events = ((window as any).__ngspiceEvents as Array<{
kind: string; finished?: boolean }>).slice(floor);
const finishSeen = events.some((e) => e.kind === 'bg' && e.finished === true);
const retired = (globalThis as any).__ngspiceServiceTestHooks
.forceRetire('E-10 repro: worker death mid-run');
return { finishSeen, retired };
}, eventFloor);
expect(injected.finishSeen,
'repro window: the run must still be live when the fault is injected').toBe(false);
expect(injected.retired, 'the active generation was retired').toBe(true);
// THE E-10 oracle: without the synthetic controlled-exit the
// s_bgRunning mirror stays latched true; and without the worker's
// pre-init read guard the crash-recovery finish parks on a vector
// pull into the trapped replacement engine — either way this poll
// times out with the Run tool disabled forever.
await waitForRunToolEnabled(page);
// The crashed run's completion was delivered (cursor/finish body ran).
const crashReceipt = await page.evaluate(async (after: number) => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
return await hooks.waitForAppliedGenerationAfter(after, 60000);
}, checkpoint);
expect(crashReceipt.generation, 'the crashed run applied its completion')
.toBeGreaterThan(checkpoint);
// The synthetic exit is visible in the event record.
const exitSeen = await page.evaluate((floor: number) =>
((window as any).__ngspiceEvents as Array<{ kind: string }>)
.slice(floor)
.some((e) => e.kind === 'exit'), eventFloor);
expect(exitSeen, 'a controlled-exit event reached the client').toBe(true);
// And the promised transparent restart: a full rerun on a fresh
// worker generation succeeds end to end.
const rerunGeneration = await runSimulation(page);
expect(rerunGeneration).toBeGreaterThan(crashReceipt.generation);
const generations = await page.evaluate(() =>
(globalThis as any).__ngspiceServiceTestHooks.snapshot());
expect(generations.retiredGenerations, 'the killed generation was retired')
.toContain(1);
expect(findNativeFailure([...testLogger.consoleLogs, ...testLogger.errors]),
'no wasm abort during the recovery').toBeUndefined();
});
test('a launch that dies before RUNNING still delivers its completion (E-12)', async ({ page, testLogger }) => {
await page.goto('/kicad/eeschema.html');
await waitForEditorReady(page);
await loadRectifier(page);
await openSimulator(page);
await waitForRunToolEnabled(page);
// Arm: the transport dies on the bg_run launch itself — after the
// native side published its run generation, before any RUNNING
// transition could fire. The retirement's synthetic exit then
// delivers this run's ONLY completion. (The arm keys on bg_run
// specifically, so frame-open plot restoration cannot consume it.)
const checkpoint = await page.evaluate(() => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
hooks.dieOnNextBgRun();
return hooks.appliedGenerationCheckpoint() as number;
});
expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }),
'Run tool').toBe(true);
// THE E-12 oracle: on the unfixed build the crash-exit IDLE carries
// generation 0 (its RUNNING never fired) and is deleted — the owned
// run's completion never applies and this receipt times out.
const receipt = await page.evaluate(async (after: number) => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
return await hooks.waitForAppliedGenerationAfter(after, 60000);
}, checkpoint);
expect(receipt.generation, 'the dead launch applied its crash completion')
.toBeGreaterThan(checkpoint);
// Recovery stays intact: a rerun on the replacement generation works.
const rerunGeneration = await runSimulation(page);
expect(rerunGeneration).toBeGreaterThan(receipt.generation);
expect(findNativeFailure([...testLogger.consoleLogs, ...testLogger.errors]),
'no wasm abort during the recovery').toBeUndefined();
});
test('a corrupted get_vec length is clamped, not copied out of bounds (E-11)', async ({ page, testLogger }) => {
await page.goto('/kicad/eeschema.html');
await waitForEditorReady(page);
await loadRectifier(page);
await openSimulator(page);
await waitForRunToolEnabled(page);
// Arm BEFORE the run: the next vector pull reports a ~5e8-element
// length while its arrays stay ~101 elements. (Frame-open plot
// restoration also pulls vectors; whichever pull the arm hits, the
// corrupted answer flows through the same client prepare.)
await page.evaluate(() => {
(globalThis as any).__ngspiceServiceTestHooks.corruptNextGetVec();
});
// THE E-11 oracle: on the unfixed build the client copies v_length
// doubles from the small buffer. Observed death shape on this build:
// the 4 GiB std::vector throws an UNHANDLED std::length_error that
// exits the editor's main loop — the scheduler shuts down and the
// whole session is dead (an OOB trap is the sibling shape). Fixed,
// the length clamps to the transferred arrays and the run completes.
await runSimulation(page);
const scheduler = await page.evaluate(() => ({
dead: (globalThis as any).__wxScheduler?.dead === true,
terminal: (globalThis as any).__wxScheduler?.terminal === true,
}));
expect(scheduler.dead,
'the corrupted vector must not exit the editor main loop').toBe(false);
expect(scheduler.terminal, 'the editor instance must not be terminal').toBe(false);
const fatal = findNativeFailure([...testLogger.consoleLogs, ...testLogger.errors]);
expect(fatal, `no wasm trap from the corrupted vector (patterns: ${
FATAL_WASM_PATTERNS.join(', ')})`).toBeUndefined();
});
});

View file

@ -1,15 +1,7 @@
import { test, expect } from './fixtures';
import * as path from 'path';
import { PNG } from 'pngjs';
import {
clickByTooltip,
clickMenuBarItem,
clickMenuItemByText,
findByTooltip,
stableShot,
waitForEditorReady,
} from '../e2e/utils/element-tracker';
import { injectFileIntoMemfs } from './utils/fs-inject';
import { stableShot, waitForEditorReady } from '../e2e/utils/element-tracker';
import { loadRectifier, openSimulator, runSimulation } from './utils/sim-harness';
/**
* eeschema simulator end-to-end (docs/features/ngspice-split/): the historic
@ -26,116 +18,6 @@ import { injectFileIntoMemfs } from './utils/fs-inject';
* run with "unable to find definition of model").
*/
const RECTIFIER_DIR = path.resolve(__dirname, '..', '..',
'kicad', 'demos', 'simulation', 'rectifier');
const MEMFS_DIR = '/home/kicad/documents/rectifier';
const PROJECT_FILES = ['rectifier.kicad_sch', 'rectifier.kicad_pro', 'diode.mod',
'rectifier_schlib.kicad_sym', 'sym-lib-table', 'rectifier.wbk'];
async function loadRectifier(page: import('@playwright/test').Page): Promise<void> {
for (const f of PROJECT_FILES)
await injectFileIntoMemfs(page, path.join(RECTIFIER_DIR, f), `${MEMFS_DIR}/${f}`);
await page.evaluate(async (sch: string) => {
await (window as any).Module.kicadOpenFile(sch);
}, `${MEMFS_DIR}/rectifier.kicad_sch`);
await expect
.poll(async () => page.title(), { timeout: 120000 })
.toMatch(/rectifier/i);
}
// Open Inspect → Simulator and return the new top-level window's DOM id.
async function openSimulator(page: import('@playwright/test').Page): Promise<string> {
const idsBefore = await page.$$eval('#window-container [id^="window-"]',
(els) => els.map((e) => e.id));
expect(await clickMenuBarItem(page, 'Inspect'), 'Inspect menu').toBe(true);
await clickMenuItemByText(page, 'Simulator');
await page.waitForFunction((before: string[]) => {
const ids = Array.from(
document.querySelectorAll('#window-container [id^="window-"]'),
(e) => e.id);
return ids.some((id) => !before.includes(id));
}, idsBefore, { timeout: 60000 });
const idsAfter = await page.$$eval('#window-container [id^="window-"]',
(els) => els.map((e) => e.id));
const simWin = idsAfter.find((id) => !idsBefore.includes(id));
expect(simWin, 'simulator window appeared').toBeTruthy();
return simWin!;
}
// Run the loaded workbook's analysis and await the exact native run generation
// only after its final plot, operating-point, and canvas refresh calls return.
async function runSimulation(page: import('@playwright/test').Page): Promise<number> {
// The simulator window div appears while the frame ctor is still
// suspended in the init RPC; the toolbar registers its tools only after
// init completes and the frame first paints. The Run tool's
// ENABLE(!simRunning) condition is a wxUpdateUIEvent check, and the WASM
// port only reliably re-evaluates those when input events pump the loop —
// after a run finishes, the last input was the click that started it, so
// nudge the mouse each poll or the toolbar can hold its stale
// "running" state forever.
await expect
.poll(async () => {
await page.mouse.move(4, 4);
await page.mouse.move(8, 8);
const el = await findByTooltip(page, 'Run Simulation', { elementType: 'tool' });
return !!el && el.enabled;
}, { timeout: 60000 })
.toBe(true);
const generationCheckpoint = await page.evaluate(() => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
if (!hooks || typeof hooks.appliedGenerationCheckpoint !== 'function'
|| typeof hooks.waitForAppliedGenerationAfter !== 'function') {
throw new Error('exact ngspice applied-generation hooks are missing');
}
return hooks.appliedGenerationCheckpoint() as number;
});
expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }),
'Run tool').toBe(true);
const appliedReceipt = await page.evaluate(async (after: number) => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
return await hooks.waitForAppliedGenerationAfter(after, 120000);
}, generationCheckpoint);
expect(appliedReceipt.generation, 'the clicked run published a newer applied generation')
.toBeGreaterThan(generationCheckpoint);
// The native receipt fires after the final refreshes. Additionally
// require the scheduler to hold no parked ngspice wait — a stale
// suspended frame here means the finish path leaked a wait. (The codex
// line awaited the execution owner's barrier; that machinery does not
// exist on the JSPI line, and wait drainage is its observable
// equivalent.)
await expect.poll(
() => page.evaluate(() => {
const scheduler = (globalThis as any).__wxScheduler;
return scheduler?.pendingWaits?.('ngspice') ?? -1;
}),
{ message: 'no ngspice wait may stay parked after the applied receipt', timeout: 30000 },
).toBe(0);
// Vector traffic is result validation only. It is deliberately not used as
// completion evidence because periodic OnSimRefresh(false) pulls can look
// identical to the final pull at the worker boundary.
const vectorReceipt = await page.evaluate(() =>
((window as any).__ngspiceLog as Array<{
sequence: number; kind: string; error?: string; length?: number;
}>).find((entry) => entry.kind === 'get_vec_info'
&& entry.error === undefined
&& (entry.length ?? -1) >= 101) ?? null,
);
expect(vectorReceipt, 'the applied run returned a non-trivial successful vector')
.not.toBeNull();
return appliedReceipt.generation;
}
function distinctColors(png: PNG): number {
const colors = new Set<number>();
// 8x8 grid sampling, same spirit as the 3d-viewer render check.

View file

@ -139,9 +139,9 @@ function runLoadPcbTest(demo: DemoCfg): void {
await page.mouse.click(filenameInput.x, filenameInput.y);
// Small settle so the focus click lands before typing — no JS-observable "input
// focused" signal here (documented interaction wait).
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: focus click commit; no JS-observable focus signal
await page.keyboard.type(pcbFilename);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-text registration has no observable signal
await page.keyboard.press('Enter');
// ── Wait for the load to complete (no dialogs visible). The

View file

@ -5,6 +5,7 @@ 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 →
@ -52,19 +53,6 @@ interface ExportCapture {
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
@ -92,7 +80,6 @@ async function installModelProviderStub(page: Page): Promise<void> {
// 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('/')));
@ -149,48 +136,18 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors
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.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
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}`);
}
/** 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);
await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' });
expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true);
// 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' });
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 });
await openStepExportDialog(page);
expect(await clickWxButton(page, 'Export'), 'Export button click').toBe(true);
@ -258,10 +215,9 @@ test.describe('STEP export × 3D model delivery', () => {
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');
// 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([]);
});

View file

@ -2,22 +2,10 @@ import type { Page } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { test, expect } from './fixtures';
import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil, stableShot, settledShot } from '../e2e/utils/element-tracker';
import { waitForEditorReady, stableShot, settledShot } from '../e2e/utils/element-tracker';
import { injectFromSubmodule } from './utils/fs-inject';
import { openBoardProgrammatically } from './utils/board-ready';
/** 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',
);
}
import { findWxButton, clickWxButton, openStepExportDialog, dismissReportDialog } from './utils/wx-dialogs';
/**
* STEP export through the occ_service worker (docs/features/occ-split/):
@ -58,59 +46,6 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors
console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`);
}
type WxButtonTarget = { x: number; y: number; domId: number | null };
/** Resolve one visible wx button to its stable DOM identity and fallback point. */
async function findWxButton(page: Page, label: string): Promise<WxButtonTarget | null> {
return 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'));
// domId is present only for DOM-backed controls on lines that expose
// it; this line's registry may omit it — the coordinate fallback in
// clickWxButtonTarget is the supported path then.
const domId = (el as { domId?: number } | undefined)?.domId;
return el
? { x: el.centerX, y: el.centerY, domId: domId && domId > 0 ? domId : null }
: null;
}, label);
}
async function clickWxButtonTarget(page: Page, target: WxButtonTarget): Promise<void> {
if (target.domId) {
await page.locator(`[data-wx-dom-id="${target.domId}"]`).click();
return;
}
await page.mouse.click(target.x, target.y);
}
/** Click a visible wx button by label; returns whether it was found. */
async function clickWxButton(page: Page, label: string): Promise<boolean> {
const target = await findWxButton(page, label);
if (!target) return false;
await clickWxButtonTarget(page, target);
return true;
}
async function openStepExportDialog(page: Page): Promise<void> {
expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true);
await waitForMenuItems(page);
await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' });
expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true);
await waitForRenderedByLabel(page, 'STEP/GLB/BREP/XAO/PLY/STL...', { elementType: 'menuitem' });
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 });
}
test.describe('OCC export via occ_service worker', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(240000);
@ -161,11 +96,9 @@ test.describe('OCC export via occ_service worker', () => {
expect(occFetches.length, 'occ_service was fetched lazily by the export')
.toBeGreaterThan(0);
// Dismiss the "Export complete" report dialog if present. Its appearance after
// the worker returns has no distinct registry signal to poll — a short documented
// dwell, then click OK if present.
await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell
await clickWxButton(page, 'OK');
// Dismiss the "Export complete" report dialog: it opens on top of the
// export dialog's modal lease (1 → 2), which IS its observable signal.
await dismissReportDialog(page, 1, 'export complete');
await stableShot(page, 'occ-export-done.png');
});
@ -243,14 +176,14 @@ test.describe('OCC export via occ_service worker', () => {
expect(failed.service.maxPending,
'two requests must coexist in one generation; worker requests are not serialized')
.toBeGreaterThanOrEqual(2);
expect(failed.service.requestsStarted,
'the direct probe and one native export must be the only provider entries').toBe(2);
expect(failed.service.requestsPosted,
'both failed-generation requests must reach the real worker transport').toBe(2);
'every provider entry must reach the real worker transport')
.toBe(failed.service.requestsStarted);
expect(failed.service.workerGenerationsStarted,
'the two parallel requests must share one worker generation').toEqual([1]);
'the two parallel requests must share one worker generation').toHaveLength(1);
expect(failed.service.pending, 'fail-all must drain the failed generation').toBe(0);
expect(failed.service.retiredGenerations, 'generation 1 must be retired').toEqual([1]);
expect(failed.service.retiredGenerations, 'the shared generation must be retired')
.toContain(failed.service.workerGenerationsStarted[0]);
expect(failed.service.activeGeneration, 'the failed slot must be cleared').toBeNull();
expect(failed.service.armed, 'the one-shot fault must be consumed').toBe(false);
console.log(`[TEST-OCC] native fault dialog labels: ${JSON.stringify(failed.labels)}`);
@ -260,9 +193,6 @@ test.describe('OCC export via occ_service worker', () => {
// turning the handback race into a click on some replacement control.
const retryExport = await findWxButton(page, 'Export');
expect(retryExport, 'the original parent Export button must remain registered').not.toBeNull();
// On this line the export dialog's buttons may be canvas-rendered
// (domId null); clickWxButtonTarget's coordinate fallback is the
// supported path, so only the captured geometry must be sane.
expect(retryExport!.x, 'the retry target has stable geometry').toBeGreaterThan(0);
expect(retryExport!.y, 'the retry target has stable geometry').toBeGreaterThan(0);
@ -283,9 +213,10 @@ test.describe('OCC export via occ_service worker', () => {
).toBe(1);
// The export dialog remains open. Its next request must create a fresh
// generation and complete through the actual OCC module.
// generation and complete through the actual OCC module. Reuse the
// captured geometry so a re-query can't land on a replacement control.
if (!retryExport) throw new Error('parent Export button disappeared before retry');
await clickWxButtonTarget(page, retryExport);
await page.mouse.click(retryExport.x, retryExport.y);
await expect.poll(
() => page.evaluate(() => (globalThis as any)
.__occServiceTestHooks.snapshot().requestsStarted),
@ -321,16 +252,22 @@ test.describe('OCC export via occ_service worker', () => {
.toBeGreaterThan(10_000);
expect(recovered.service.activeGeneration, 'retry must own a replacement generation').toBe(2);
expect(recovered.service.requestsStarted,
'the parent retry must add exactly one provider entry').toBe(3);
'the parent retry must add exactly one provider entry')
.toBe(failed.service.requestsStarted + 1);
expect(recovered.service.requestsPosted,
'the parent retry must post exactly once to the replacement worker').toBe(3);
'the parent retry must post exactly once to the replacement worker')
.toBe(failed.service.requestsPosted + 1);
expect(recovered.service.workerGenerationsStarted,
'the retry must create exactly one replacement generation').toEqual([1, 2]);
'the retry must create exactly one replacement generation')
.toHaveLength(failed.service.workerGenerationsStarted.length + 1);
expect(recovered.service.workerGenerationsStarted,
'the replacement generation must be the active one').toContain(2);
expect(recovered.service.pending, 'replacement generation must quiesce').toBe(0);
expect(recovered.schedulerDead, 'the worker failure must not terminalize the editor').toBe(false);
expect(recovered.occWaits, 'the replacement native OCC wait must quiesce').toBe(0);
await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell
await clickWxButton(page, 'OK');
// Dismiss the retry's "Export complete" report dialog via its modal
// lease (export dialog holds 1; the report raises it to 2).
await dismissReportDialog(page, 1, 'retry export complete');
});
});

View file

@ -0,0 +1,82 @@
import * as fs from 'fs';
import * as path from 'path';
import { test, expect } from './fixtures';
/**
* occ_service boot watchdog (findings E-22): a wedged worker boot — an
* importScripts hang, pthread spawn wedge, or OOM-kill leaves a worker that
* never posts `ready` OR `bootError` — must settle the request with a loud
* boot-timeout report instead of hanging to the spec timeout with zero
* evidence, and the NEXT request must recover on a fresh generation against
* the real occ_service.
*
* The wedge is a real silent Worker (a data: module that runs nothing), armed
* one-shot through the harness hook; the recovery half exercises the real
* occ_service wasm end to end.
*/
test.describe('occ_service boot watchdog', () => {
test.setTimeout(240000);
test('a wedged boot settles with a timeout report and the next request recovers', async ({ page }) => {
await page.goto('/kicad/pcbnew.html', { waitUntil: 'domcontentloaded' });
const probeBoard = fs.readFileSync(
path.resolve(__dirname, '..', 'fixtures', 'demo', 'demo.kicad_pcb'),
'utf8',
);
await page.evaluate((boardText: string) => {
const runtime = globalThis as any;
runtime.__occServiceTestHooks.wedgeNextBoot(5000);
runtime.__occWedgeResult = null;
void runtime.occService.request({
kind: 'export',
board: new TextEncoder().encode(boardText),
jobJson: JSON.stringify({ format: 'step', export_components: false }),
fileName: 'wedged.step',
}).then((res: unknown) => { runtime.__occWedgeResult = res; });
}, probeBoard);
await expect.poll(
() => page.evaluate(() => (globalThis as any).__occWedgeResult),
{
message: 'the wedged boot must settle via the boot watchdog, not hang',
timeout: 30000,
},
).toMatchObject({
ok: false,
report: expect.stringContaining('boot timed out after 5000 ms'),
});
const wedgedState = await page.evaluate(
() => (globalThis as any).__occServiceTestHooks.snapshot());
expect(wedgedState.retiredGenerations, 'the wedged generation was retired')
.toEqual([1]);
expect(wedgedState.activeGeneration, 'no active generation remains').toBeNull();
expect(wedgedState.pending, 'nothing left pending').toBe(0);
// Recovery: the wedge was one-shot — this boots the REAL occ_service
// and completes a real export through it.
const recovered = await page.evaluate(async (boardText: string) => {
const runtime = globalThis as any;
return await runtime.occService.request({
kind: 'export',
board: new TextEncoder().encode(boardText),
jobJson: JSON.stringify({ format: 'step', export_components: false }),
fileName: 'recovered.step',
});
}, probeBoard);
expect(recovered.ok, 'the fresh generation must serve the retry').toBe(true);
const recoveredState = await page.evaluate(
() => (globalThis as any).__occServiceTestHooks.snapshot());
expect(recoveredState.workerGenerationsStarted, 'a replacement generation booted')
.toEqual([1, 2]);
expect(recoveredState.pending, 'the replacement generation quiesced').toBe(0);
const exports = await page.evaluate(() => (window as any).__occExports);
expect(exports, 'the recovery produced a real STEP capture').toHaveLength(1);
expect(exports[0].magic.startsWith('ISO-10303-21'), 'real STEP bytes').toBe(true);
});
});

View file

@ -118,15 +118,15 @@ test.describe('PCBnew move with "m" (#9)', () => {
// JS-observable signal, and the asyncified pointer-move handler needs wall-clock
// time to update the world cursor before each button press.
await page.mouse.move(startPoint.x, startPoint.y);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: asyncified pointer-move needs wall-clock time before press
await page.mouse.down();
await page.mouse.up();
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: line-vertex commit has no JS-observable signal
await page.mouse.move(endPoint.x, endPoint.y);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: asyncified pointer-move needs wall-clock time before press
await page.mouse.down();
await page.mouse.up();
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: line-vertex commit has no JS-observable signal
// Finish the segment, then wait for the new board item to register
// (deterministic — replaces two fixed 250ms sleeps).
await page.keyboard.press('Escape');
@ -150,21 +150,21 @@ test.describe('PCBnew move with "m" (#9)', () => {
// asyncified event loop to process before the next. The outcome (the item moved
// right) is asserted below via the embind position hook.
await page.mouse.move(midPoint.x, midPoint.y);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: asyncified pointer-move needs wall-clock time before select click
await page.mouse.down();
await page.mouse.up();
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: selection commit has no per-step observable
const NUDGES = 10;
await page.keyboard.press('m');
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: move-mode entry has no observable signal
for (let i = 0; i < NUDGES; i++) {
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: per-arrow nudge has no per-step observable
}
// Commit at the nudged position WITHOUT moving the cursor (Enter, not click).
await page.keyboard.press('Enter');
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: keyboard move commit; outcome asserted via position hook
const afterMove = await page.screenshot({ path: shotPath(page, 'pcbnew-move-01-after.png'), scale: 'css' });

View file

@ -1,7 +1,9 @@
import type { Page } from '@playwright/test';
import { waitForCanvasStable } from '../../e2e/utils/element-tracker';
import { assertNoNativeFailure, findNativeFailure } from './native-failure';
export type RuntimeLogger = { consoleLogs: string[]; errors: string[] };
export type { RuntimeLogger } from './native-failure';
import type { RuntimeLogger } from './native-failure';
/**
* Wait for pcbnew to finish opening a board.
@ -34,10 +36,7 @@ export async function waitForBoardLoaded(
// goes away and we'd burn the full timeout. KiCad logs the abort
// line through Module.printErr, which our test logger captures as
// a console error. We re-read the live arrays each tick.
const allLines = [...logger.consoleLogs, ...logger.errors];
const abort = allLines.find((l) =>
l.includes('Aborted(') || l.includes('RuntimeError: unreachable')
);
const abort = findNativeFailure([...logger.consoleLogs, ...logger.errors]);
if (abort) {
throw new Error(`WASM aborted during LoadBoard:\n${abort}`);
}
@ -79,16 +78,6 @@ function assertExpectedBoard(expectedBoard: string): void {
}
}
function assertNoNativeFailure(logger: RuntimeLogger | undefined, phase: string): void {
if (!logger) return;
const failure = [...logger.consoleLogs, ...logger.errors].find((line) =>
line.includes('Aborted(')
|| line.includes('RuntimeError: unreachable')
|| line.includes('memory access out of bounds')
);
if (failure) throw new Error(`WASM failed during ${phase}:\n${failure}`);
}
async function waitForBoardIdentityAndPaint(
page: Page,
expectedBoard: string,

View file

@ -0,0 +1,35 @@
export type RuntimeLogger = { consoleLogs: string[]; errors: string[] };
/**
* The one shared list of fatal wasm-runtime console signatures. Before this
* module, three divergent copies existed (trio.ts hasAbort: `Aborted(` only;
* board-ready.ts: + `RuntimeError: unreachable` + `memory access out of
* bounds`; spec-local variants: + `index out of bounds` etc.) — so whether a
* native crash failed a spec fast or burned its full timeout depended on
* which helper the spec happened to call. Add new engine wordings HERE.
*/
export const FATAL_WASM_PATTERNS = [
'Aborted(',
'RuntimeError: unreachable',
'memory access out of bounds',
'index out of bounds',
'indirect call to null',
'uncaught exception: unwind',
] as const;
/** First captured console/error line matching a fatal wasm signature, if any. */
export function findNativeFailure(lines: readonly string[]): string | undefined {
return lines.find((line) => FATAL_WASM_PATTERNS.some((p) => line.includes(p)));
}
/** Whether the logger has captured any fatal wasm signature. */
export function hasNativeFailure(logger: RuntimeLogger): boolean {
return findNativeFailure([...logger.consoleLogs, ...logger.errors]) !== undefined;
}
/** Throw (with the offending line) if the logger captured a fatal wasm signature. */
export function assertNoNativeFailure(logger: RuntimeLogger | undefined, phase: string): void {
if (!logger) return;
const failure = findNativeFailure([...logger.consoleLogs, ...logger.errors]);
if (failure) throw new Error(`WASM failed during ${phase}:\n${failure}`);
}

View file

@ -108,26 +108,10 @@ export async function installNgspiceServiceStub(
name?: string;
minimumLength?: number;
}
interface RequestWaiter {
after: number;
criteria: RequestCriteria;
resolve: (summary: RequestSummary) => void;
reject: (reason?: unknown) => void;
timer: ReturnType<typeof setTimeout>;
}
interface AppliedGenerationReceipt {
generation: number;
t: number;
}
interface AppliedGenerationWaiter {
after: number;
resolve: (receipt: AppliedGenerationReceipt) => void;
reject: (reason?: unknown) => void;
timer: ReturnType<typeof setTimeout>;
}
interface CancellableReceipt<T> extends Promise<T> {
cancel: (reason?: string) => void;
}
const RECEIPT_TIMEOUT_MS = 2 * 60_000;
const MAX_RECEIPT_TIMEOUT_MS = 5 * 60_000;
@ -140,11 +124,11 @@ export async function installNgspiceServiceStub(
let maxPending = 0;
let bootMessageErrorArmed = false;
let runtimeMessageErrorThreshold: number | null = null;
let dieOnNextBgRunArmed = false;
let corruptNextGetVecArmed = false;
const retiredGenerations: number[] = [];
let nextRequestSequence = 1;
const requestWaiters = new Set<RequestWaiter>();
const appliedGenerations: AppliedGenerationReceipt[] = [];
const appliedGenerationWaiters = new Set<AppliedGenerationWaiter>();
let disposed = false;
const validateReceiptTimeout = (timeoutMs: number): string | undefined => {
@ -155,15 +139,6 @@ export async function installNgspiceServiceStub(
return undefined;
};
const cancellable = <T>(
promise: Promise<T>,
cancel: (reason?: string) => void,
): CancellableReceipt<T> => {
const receipt = promise as CancellableReceipt<T>;
Object.defineProperty(receipt, 'cancel', { value: cancel });
return receipt;
};
const requestMatches = (
summary: RequestSummary,
after: number,
@ -175,115 +150,130 @@ export async function installNgspiceServiceStub(
&& (criteria.minimumLength === undefined
|| (summary.length ?? -1) >= criteria.minimumLength);
const rejectRequestWaiter = (waiter: RequestWaiter, reason: Error): void => {
if (!requestWaiters.delete(waiter)) return;
clearTimeout(waiter.timer);
waiter.reject(reason);
interface ReceiptWaiter<TReceipt, TCriteria> {
after: number;
criteria: TCriteria;
resolve: (receipt: TReceipt) => void;
reject: (reason?: unknown) => void;
timer: ReturnType<typeof setTimeout>;
}
/**
* One scan-then-subscribe receipt channel (shared by the request and
* applied-generation receipts, which differ only in their match
* predicate, existing-receipt scan, and error strings): scan the
* already-published receipts first, otherwise subscribe a bounded,
* timed waiter — so a receipt cannot land in the gap between an array
* scan and listener installation.
*/
const makeReceiptChannel = <TReceipt, TCriteria>(channel: {
validate: (after: number, criteria: TCriteria) => string | undefined;
/** Defensive copy of the criteria, taken only after validation. */
snapshotCriteria?: (criteria: TCriteria) => TCriteria;
scanExisting: (after: number, criteria: TCriteria) => TReceipt | undefined;
matches: (receipt: TReceipt, after: number, criteria: TCriteria) => boolean;
capacityError: string;
timeoutError: (timeoutMs: number) => string;
}) => {
const waiters = new Set<ReceiptWaiter<TReceipt, TCriteria>>();
const rejectWaiter = (
waiter: ReceiptWaiter<TReceipt, TCriteria>,
reason: Error,
): void => {
if (!waiters.delete(waiter)) return;
clearTimeout(waiter.timer);
waiter.reject(reason);
};
return {
wait(after: number, criteria: TCriteria, timeoutMs: number): Promise<TReceipt> {
if (disposed)
return Promise.reject(new Error('ngspice receipt service was disposed'));
const invalid = channel.validate(after, criteria)
?? validateReceiptTimeout(timeoutMs);
if (invalid) return Promise.reject(new Error(invalid));
const existing = channel.scanExisting(after, criteria);
if (existing) return Promise.resolve(existing);
if (waiters.size >= MAX_RECEIPT_WAITERS)
return Promise.reject(new Error(channel.capacityError));
const held = channel.snapshotCriteria
? channel.snapshotCriteria(criteria) : criteria;
return new Promise<TReceipt>((resolve, reject) => {
const waiter: ReceiptWaiter<TReceipt, TCriteria> = {
after,
criteria: held,
resolve,
reject,
timer: setTimeout(() => rejectWaiter(
waiter,
new Error(channel.timeoutError(timeoutMs)),
), timeoutMs),
};
waiters.add(waiter);
});
},
publish(receipt: TReceipt): void {
for (const waiter of [...waiters]) {
if (!channel.matches(receipt, waiter.after, waiter.criteria)) continue;
waiters.delete(waiter);
clearTimeout(waiter.timer);
waiter.resolve(receipt);
}
},
drain(reason: string): void {
for (const waiter of [...waiters]) rejectWaiter(waiter, new Error(reason));
},
size: () => waiters.size,
};
};
const requestReceipts = makeReceiptChannel<RequestSummary, RequestCriteria>({
validate: (after, criteria) => {
if (!Number.isSafeInteger(after) || after < 0)
return 'request checkpoint must be a non-negative integer';
if (!criteria || typeof criteria !== 'object')
return 'request receipt criteria must be an object';
if (criteria.minimumLength !== undefined
&& (!Number.isSafeInteger(criteria.minimumLength)
|| criteria.minimumLength < 0)) {
return 'minimumLength must be a non-negative integer';
}
return undefined;
},
snapshotCriteria: (criteria) => ({ ...criteria }),
scanExisting: (after, criteria) => ((window as any).__ngspiceLog as RequestSummary[])
.find((entry) => requestMatches(entry, after, criteria)),
matches: requestMatches,
capacityError: 'ngspice request receipt waiter capacity exceeded',
timeoutError: (timeoutMs) =>
`ngspice request receipt timed out after ${timeoutMs} ms`,
});
const appliedReceipts = makeReceiptChannel<AppliedGenerationReceipt, undefined>({
validate: (after) => (!Number.isSafeInteger(after) || after < 0)
? 'applied generation checkpoint must be a non-negative integer'
: undefined,
scanExisting: (after) => appliedGenerations.find((entry) => entry.generation > after),
matches: (receipt, after) => receipt.generation > after,
capacityError: 'ngspice applied-generation waiter capacity exceeded',
timeoutError: (timeoutMs) =>
`ngspice applied generation timed out after ${timeoutMs} ms`,
});
const publishRequestReceipt = (summary: RequestSummary) => {
(window as any).__ngspiceLog.push(summary);
for (const waiter of [...requestWaiters]) {
if (!requestMatches(summary, waiter.after, waiter.criteria)) continue;
requestWaiters.delete(waiter);
clearTimeout(waiter.timer);
waiter.resolve(summary);
}
requestReceipts.publish(summary);
};
const waitForRequestAfter = (
after: number,
criteria: RequestCriteria,
timeoutMs = RECEIPT_TIMEOUT_MS,
): CancellableReceipt<RequestSummary> => {
const rejected = (message: string) => cancellable(
Promise.reject(new Error(message)),
() => undefined,
);
if (disposed) return rejected('ngspice receipt service was disposed');
if (!Number.isSafeInteger(after) || after < 0)
return rejected('request checkpoint must be a non-negative integer');
if (!criteria || typeof criteria !== 'object')
return rejected('request receipt criteria must be an object');
if (criteria.minimumLength !== undefined
&& (!Number.isSafeInteger(criteria.minimumLength)
|| criteria.minimumLength < 0)) {
return rejected('minimumLength must be a non-negative integer');
}
const timeoutError = validateReceiptTimeout(timeoutMs);
if (timeoutError) return rejected(timeoutError);
const log = (window as any).__ngspiceLog as RequestSummary[];
const existing = log.find((entry) =>
requestMatches(entry, after, criteria));
if (existing) return cancellable(Promise.resolve(existing), () => undefined);
if (requestWaiters.size >= MAX_RECEIPT_WAITERS)
return rejected('ngspice request receipt waiter capacity exceeded');
let waiter!: RequestWaiter;
const promise = new Promise<RequestSummary>((resolve, reject) => {
waiter = {
after,
criteria: { ...criteria },
resolve,
reject,
timer: setTimeout(() => rejectRequestWaiter(
waiter,
new Error(`ngspice request receipt timed out after ${timeoutMs} ms`),
), timeoutMs),
};
requestWaiters.add(waiter);
});
return cancellable(promise, (reason = 'canceled') => rejectRequestWaiter(
waiter,
new Error(`ngspice request receipt ${reason}`),
));
};
const rejectAppliedGenerationWaiter = (
waiter: AppliedGenerationWaiter,
reason: Error,
): void => {
if (!appliedGenerationWaiters.delete(waiter)) return;
clearTimeout(waiter.timer);
waiter.reject(reason);
};
): Promise<RequestSummary> => requestReceipts.wait(after, criteria, timeoutMs);
const waitForAppliedGenerationAfter = (
after: number,
timeoutMs = RECEIPT_TIMEOUT_MS,
): CancellableReceipt<AppliedGenerationReceipt> => {
const rejected = (message: string) => cancellable(
Promise.reject(new Error(message)),
() => undefined,
);
if (disposed) return rejected('ngspice receipt service was disposed');
if (!Number.isSafeInteger(after) || after < 0)
return rejected('applied generation checkpoint must be a non-negative integer');
const timeoutError = validateReceiptTimeout(timeoutMs);
if (timeoutError) return rejected(timeoutError);
const existing = appliedGenerations.find((entry) => entry.generation > after);
if (existing) return cancellable(Promise.resolve(existing), () => undefined);
if (appliedGenerationWaiters.size >= MAX_RECEIPT_WAITERS)
return rejected('ngspice applied-generation waiter capacity exceeded');
let waiter!: AppliedGenerationWaiter;
const promise = new Promise<AppliedGenerationReceipt>((resolve, reject) => {
waiter = {
after,
resolve,
reject,
timer: setTimeout(() => rejectAppliedGenerationWaiter(
waiter,
new Error(`ngspice applied generation timed out after ${timeoutMs} ms`),
), timeoutMs),
};
appliedGenerationWaiters.add(waiter);
});
return cancellable(promise, (reason = 'canceled') => rejectAppliedGenerationWaiter(
waiter,
new Error(`ngspice applied-generation receipt ${reason}`),
));
};
): Promise<AppliedGenerationReceipt> => appliedReceipts.wait(after, undefined, timeoutMs);
const previousAppliedHook = (globalThis as any).__pcbjamNgspiceFinalRefreshApplied;
const publishAppliedGeneration = (generation: number): void => {
@ -301,12 +291,7 @@ export async function installNgspiceServiceStub(
}
const receipt = { generation, t: Date.now() - t0 };
appliedGenerations.push(receipt);
for (const waiter of [...appliedGenerationWaiters]) {
if (generation <= waiter.after) continue;
appliedGenerationWaiters.delete(waiter);
clearTimeout(waiter.timer);
waiter.resolve(receipt);
}
appliedReceipts.publish(receipt);
if (typeof previousAppliedHook === 'function') previousAppliedHook(generation);
};
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = publishAppliedGeneration;
@ -353,15 +338,21 @@ export async function installNgspiceServiceStub(
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
const handler = (globalThis as any).__ngspiceOnEvent;
if (handler) {
while (evtQueue.length) {
const queued = evtQueue.shift()!;
evtQueueBytes -= queued.bytes;
if (queued.generation !== slot.generation) continue;
// Queued frames were acked at enqueue (ownership taken then).
handler(queued.evt);
// Mirrors the production service: the host owns the frame's
// credit from onmessage on, so the ack survives a throwing
// handler (which keeps propagating).
try {
while (evtQueue.length) {
const queued = evtQueue.shift()!;
evtQueueBytes -= queued.bytes;
if (queued.generation !== slot.generation) continue;
// Queued frames were acked at enqueue (ownership taken then).
handler(queued.evt);
}
handler(evt);
} finally {
ackEvent(slot, frame);
}
handler(evt);
ackEvent(slot, frame);
} else {
if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES
|| evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) {
@ -377,6 +368,26 @@ export async function installNgspiceServiceStub(
}
};
// Mirrors the production service: the worker's terminal notice carries
// the deferred frames it had already accepted — deliver best-effort,
// in order, WITHOUT acking (the fatal frame is outside the credit
// protocol), and record them for the specs like any live frame.
const deliverTerminalEvents = (entries: unknown): void => {
if (!Array.isArray(entries) || entries.length === 0) return;
const handler = (globalThis as any).__ngspiceOnEvent;
for (const entry of entries) {
const evt = (entry as { evt?: any } | null)?.evt;
if (!evt) continue;
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
if (!handler) continue;
try {
handler(evt);
} catch (error) {
console.log(`[TEST-NGSPICE] terminal event delivery failed: ${String(error)}`);
}
}
};
const failPending = (generation: number, why: string) => {
for (const [id, request] of pending) {
if (request.generation !== generation) continue;
@ -389,6 +400,7 @@ export async function installNgspiceServiceStub(
const retireWorker = (slot: WorkerSlot, why: string) => {
if (slot.failed) return;
slot.failed = true;
console.log(`[TEST-NGSPICE] retiring generation ${slot.generation}: ${why}`);
retiredGenerations.push(slot.generation);
if (slot.bootTimer !== undefined) {
clearTimeout(slot.bootTimer);
@ -412,6 +424,23 @@ export async function installNgspiceServiceStub(
const reject = slot.rejectBoot;
slot.rejectBoot = undefined;
reject?.(new Error(why));
// Mirrors the production service (E-10): a retired worker emits
// no bg/exit frame of its own, so synthesize the controlled-exit
// the crashed engine could not send — straight to the installed
// handler, never through dispatchEvt (no fabricated credit).
const handler = (globalThis as any).__ngspiceOnEvent;
if (handler) {
(window as any).__ngspiceEvents.push({
kind: 'exit', status: 1, immediate: true, quit: false,
t: Date.now() - t0,
});
try {
handler({ kind: 'exit', status: 1, immediate: true, quit: false });
} catch (error) {
console.log(`[TEST-NGSPICE] synthetic exit dispatch failed: ${String(error)}`);
}
}
};
const ensureWorker = (): Promise<WorkerSlot> => {
@ -446,6 +475,7 @@ export async function installNgspiceServiceStub(
if (slot.failed || workerSlot !== slot) return;
const data = e.data ?? {};
if (data.fatal) {
deliverTerminalEvents(data.pendingEvents);
failWorker(`event stream failure: ${String(data.fatal)}`);
return;
}
@ -555,6 +585,27 @@ export async function installNgspiceServiceStub(
// checkpoint can therefore never satisfy that simulation.
if (disposed) return { error: 'ngspice service was disposed' };
const requestSequence = nextRequestSequence++;
// Armed fault: the transport dies on the bg_run launch itself —
// AFTER the native side published its run generation, BEFORE any
// RUNNING transition could fire (the E-12 window). Retirement
// runs the production funnel (and its synthetic exit).
if (dieOnNextBgRunArmed && req.kind === 'command'
&& typeof req.cmd === 'string' && req.cmd.startsWith('bg_run')) {
dieOnNextBgRunArmed = false;
console.log('[TEST-NGSPICE] armed fault: transport death on bg_run');
const res = {
error: 'ngspice_service crashed: transport died on bg_run (armed fault)',
};
if (workerSlot) retireWorker(workerSlot, res.error);
publishRequestReceipt({
sequence: requestSequence,
kind: req.kind,
cmd: req.cmd,
error: res.error,
t: Date.now() - t0,
});
return res;
}
let slot: WorkerSlot;
try {
slot = await ensureWorker();
@ -571,6 +622,15 @@ export async function installNgspiceServiceStub(
return res;
}
const res: any = await post(slot, req);
// Armed fault: corrupt the next get_vec_info answer's LENGTH field
// only (the arrays stay small) — the corrupted-worker shape the
// sharedspice client must clamp against.
if (corruptNextGetVecArmed && req.kind === 'get_vec_info'
&& res && !res.error) {
corruptNextGetVecArmed = false;
console.log('[TEST-NGSPICE] armed fault: inflating get_vec_info length');
res.length = 1 << 29;
}
publishRequestReceipt({
sequence: requestSequence,
kind: req.kind,
@ -588,15 +648,8 @@ export async function installNgspiceServiceStub(
if (disposed) return;
disposed = true;
if (workerSlot) retireWorker(workerSlot, 'ngspice service was disposed');
for (const waiter of [...requestWaiters]) {
rejectRequestWaiter(waiter, new Error('ngspice request receipt canceled by teardown'));
}
for (const waiter of [...appliedGenerationWaiters]) {
rejectAppliedGenerationWaiter(
waiter,
new Error('ngspice applied-generation receipt canceled by teardown'),
);
}
requestReceipts.drain('ngspice request receipt canceled by teardown');
appliedReceipts.drain('ngspice applied-generation receipt canceled by teardown');
if ((globalThis as any).__pcbjamNgspiceFinalRefreshApplied
=== publishAppliedGeneration) {
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = previousAppliedHook;
@ -623,6 +676,25 @@ export async function installNgspiceServiceStub(
throw new Error('pending threshold must be a positive safe integer');
runtimeMessageErrorThreshold = count;
},
/** Retire the active generation through the production funnel
* (the same retireWorker every watchdog/onerror path uses). */
forceRetire(reason: string): boolean {
const slot = workerSlot;
if (!slot) return false;
retireWorker(slot, String(reason || 'forced retirement'));
return true;
},
/** One-shot: the transport dies on the next bg_run launch (the
* generation retires through the production funnel before any
* RUNNING transition can fire). */
dieOnNextBgRun() {
dieOnNextBgRunArmed = true;
},
/** One-shot: the next successful get_vec_info answer reports a
* huge vector length while its arrays stay small. */
corruptNextGetVec() {
corruptNextGetVecArmed = true;
},
snapshot() {
return {
activeGeneration: workerSlot?.generation ?? null,
@ -632,9 +704,9 @@ export async function installNgspiceServiceStub(
bootFaultArmed: bootMessageErrorArmed,
runtimeFaultArmed: runtimeMessageErrorThreshold !== null,
lastRequestSequence: nextRequestSequence - 1,
requestReceiptWaiters: requestWaiters.size,
requestReceiptWaiters: requestReceipts.size(),
appliedGenerations: appliedGenerations.map((entry) => entry.generation),
appliedGenerationWaiters: appliedGenerationWaiters.size,
appliedGenerationWaiters: appliedReceipts.size(),
disposed,
};
},

View file

@ -39,10 +39,23 @@ const OCC_WORKER_SRC = fs.readFileSync(
'web', 'standalone', 'src', 'wasm', 'occ-worker.js'),
'utf8');
export async function installOccServiceStub(page: Page): Promise<void> {
await page.addInitScript((workerSrc: string) => {
export interface OccHarnessWatchdogs {
bootTimeoutMs?: number;
}
export async function installOccServiceStub(
page: Page,
watchdogs: OccHarnessWatchdogs = {},
): Promise<void> {
const bootTimeoutMs = watchdogs.bootTimeoutMs ?? 2 * 60_000;
if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1)
throw new Error('bootTimeoutMs must be a positive safe integer');
await page.addInitScript((options: { workerSrc: string; bootTimeoutMs: number }) => {
if ((globalThis as any).occService) return;
const { workerSrc, bootTimeoutMs } = options;
(window as any).__occExports = [];
interface WorkerSlot {
@ -50,6 +63,9 @@ export async function installOccServiceStub(page: Page): Promise<void> {
worker?: Worker;
failed: boolean;
ready: Promise<WorkerSlot>;
bootTimer?: ReturnType<typeof setTimeout>;
rejectBoot?: (reason?: unknown) => void;
removeBootListener?: () => void;
/** The exact lifecycle transition used by Worker.onmessageerror. */
failDecode: () => void;
}
@ -67,11 +83,9 @@ export async function installOccServiceStub(page: Page): Promise<void> {
let requestsStarted = 0;
let requestsPosted = 0;
const workerGenerationsStarted: number[] = [];
let armedFault: {
count: number;
kind: 'terminate' | 'messageerror';
report: string;
} | null = null;
let armedFault: { count: number; report: string } | null = null;
/** One-shot: the next boot uses a worker that never answers. */
let wedgeNextBootArmed: { bootTimeoutMs?: number } | null = null;
const retiredGenerations: number[] = [];
const pendingInGeneration = (generation: number): number => {
@ -94,6 +108,12 @@ export async function installOccServiceStub(page: Page): Promise<void> {
if (slot.failed) return;
slot.failed = true;
retiredGenerations.push(slot.generation);
if (slot.bootTimer !== undefined) {
clearTimeout(slot.bootTimer);
slot.bootTimer = undefined;
}
slot.removeBootListener?.();
slot.removeBootListener = undefined;
failPending(slot.generation, report);
if (workerSlot === slot) workerSlot = null;
try {
@ -101,21 +121,22 @@ export async function installOccServiceStub(page: Page): Promise<void> {
} catch {
/* already gone */
}
const reject = slot.rejectBoot;
slot.rejectBoot = undefined;
reject?.(new Error(report));
};
const maybeTriggerArmedFault = (slot: WorkerSlot): void => {
if (!armedFault || slot.failed) return;
if (pendingInGeneration(slot.generation) < armedFault.count) return;
const { kind, report } = armedFault;
const { report } = armedFault;
armedFault = null;
console.log(`[TEST-OCC] faulting generation ${slot.generation} (${kind}): ${report}`);
if (kind === 'messageerror' && slot.worker) {
console.log(`[TEST-OCC] faulting generation ${slot.generation} (messageerror): ${report}`);
if (slot.worker) {
// Synthetic dispatch on Worker is engine-dependent. Invoke
// the exact transition installed as the real event handler.
slot.failDecode();
} else {
// Worker.terminate() intentionally emits no error event, so
// the hook supplies the fatal lifecycle transition explicitly.
retireWorker(slot, report);
}
};
@ -127,36 +148,53 @@ export async function installOccServiceStub(page: Page): Promise<void> {
failed: false,
} as WorkerSlot;
workerGenerationsStarted.push(slot.generation);
workerSlot = slot;
slot.ready = (async () => {
const wedge = wedgeNextBootArmed;
wedgeNextBootArmed = null;
const bootDeadlineMs = wedge?.bootTimeoutMs ?? bootTimeoutMs;
// Legible boot (E-22): a worker DEATH shape that never posts
// ready OR bootError (importScripts hang, pthread spawn
// wedge, OOM-kill) used to hang every request until the
// spec's timeout with zero evidence. Bound the boot — same
// shape as the ngspice stub and the production service.
const bootDeadline = new Promise<never>((_resolve, reject) => {
slot.rejectBoot = reject;
slot.bootTimer = setTimeout(() => {
if (slot.failed || workerSlot !== slot) return;
const why = `occ_service boot timed out after ${bootDeadlineMs} ms`;
console.log(`[TEST-OCC] ${why} — resetting service`);
retireWorker(slot, why);
}, bootDeadlineMs);
});
const boot = (async () => {
const glue = new URL('occ_service.js', window.location.href).href;
console.log(`[TEST-OCC] booting occ_service from ${glue}`);
const worker = new Worker(URL.createObjectURL(new Blob(
[`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
{ type: 'text/javascript' })));
// A wedged boot is a REAL silent Worker (an empty module:
// it boots, runs nothing, never posts ready/bootError) —
// the importScripts-hang / pthread-wedge shape, engine
// independent.
const worker = wedge
? new Worker('data:text/javascript,/* [TEST-OCC] wedged boot */')
: new Worker(URL.createObjectURL(new Blob(
[`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
{ type: 'text/javascript' })));
slot.worker = worker;
let rejectBoot: ((reason?: unknown) => void) | undefined;
let removeBootListener: (() => void) | undefined;
// All fatal transitions settle the boot through the ONE
// retirement funnel (which clears the deadline, removes
// the boot listener, and rejects the raced promise).
worker.onerror = (e) => {
const report = `occ_service crashed: ${e.message || 'worker error'}`;
console.error(`[TEST-OCC] ${report}; resetting service`);
retireWorker(slot, report);
removeBootListener?.();
removeBootListener = undefined;
const reject = rejectBoot;
rejectBoot = undefined;
reject?.(new Error(report));
};
slot.failDecode = () => {
const report = 'occ_service transport failed: message decode failed';
console.error(`[TEST-OCC] ${report}; resetting service`);
retireWorker(slot, report);
removeBootListener?.();
removeBootListener = undefined;
const reject = rejectBoot;
rejectBoot = undefined;
reject?.(new Error(report));
};
worker.onmessageerror = slot.failDecode;
worker.onmessage = (e) => {
@ -169,38 +207,37 @@ export async function installOccServiceStub(page: Page): Promise<void> {
request.resolve(res);
}
};
await new Promise<void>((resolve, reject) => {
rejectBoot = reject;
await new Promise<void>((resolve) => {
const onFirst = (e: MessageEvent) => {
if (e.data?.ready) {
removeBootListener?.();
removeBootListener = undefined;
rejectBoot = undefined;
if (slot.bootTimer !== undefined) {
clearTimeout(slot.bootTimer);
slot.bootTimer = undefined;
}
slot.removeBootListener?.();
slot.removeBootListener = undefined;
slot.rejectBoot = undefined;
resolve();
} else if (e.data?.bootError) {
removeBootListener?.();
removeBootListener = undefined;
rejectBoot = undefined;
const report = `occ_service boot failed: ${String(e.data.bootError)}`;
retireWorker(slot, report);
reject(new Error(report));
retireWorker(slot,
`occ_service boot failed: ${String(e.data.bootError)}`);
}
};
worker.addEventListener('message', onFirst);
removeBootListener = () => worker.removeEventListener('message', onFirst);
slot.removeBootListener = () => worker.removeEventListener('message', onFirst);
});
if (slot.failed || workerSlot !== slot)
throw new Error('occ_service worker retired during boot');
console.log('[TEST-OCC] occ_service ready');
return slot;
})().catch((e) => {
})();
slot.ready = Promise.race([boot, bootDeadline]).catch((e) => {
// A late rejection from a retired generation cannot clear
// the replacement slot created by a new request.
retireWorker(slot, `occ_service unavailable: ${String(e)}`);
throw e;
});
workerSlot = slot;
}
return workerSlot.ready;
};
@ -291,12 +328,13 @@ export async function installOccServiceStub(page: Page): Promise<void> {
};
(globalThis as any).__occServiceTestHooks = {
/** Arm one deterministic host-side fault after N real posts. */
terminateWhenPendingAtLeast(count: number, report = 'occ_service test fault') {
if (!Number.isSafeInteger(count) || count < 1)
throw new Error('pending threshold must be a positive safe integer');
armedFault = { count, kind: 'terminate', report };
if (workerSlot) maybeTriggerArmedFault(workerSlot);
/** One-shot: wedge the next boot (silent worker, no ready and no
* bootError), optionally shortening that boot's deadline. */
wedgeNextBoot(bootTimeoutMs?: number) {
if (bootTimeoutMs !== undefined
&& (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1))
throw new Error('bootTimeoutMs must be a positive safe integer');
wedgeNextBootArmed = { bootTimeoutMs };
},
/** Arm the real Worker's production-parity messageerror handler. */
messageErrorWhenPendingAtLeast(count: number) {
@ -304,7 +342,6 @@ export async function installOccServiceStub(page: Page): Promise<void> {
throw new Error('pending threshold must be a positive safe integer');
armedFault = {
count,
kind: 'messageerror',
report: 'occ_service transport failed: message decode failed',
};
if (workerSlot) maybeTriggerArmedFault(workerSlot);
@ -324,5 +361,5 @@ export async function installOccServiceStub(page: Page): Promise<void> {
};
(globalThis as any).occService = { request };
}, OCC_WORKER_SRC);
}, { workerSrc: OCC_WORKER_SRC, bootTimeoutMs });
}

View file

@ -0,0 +1,133 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import * as path from 'path';
import {
clickByTooltip,
clickMenuBarItem,
clickMenuItemByText,
findByTooltip,
} from '../../e2e/utils/element-tracker';
import { injectFileIntoMemfs } from './fs-inject';
/**
* Shared eeschema-simulator harness (one copy for eeschema-sim.spec.ts and
* eeschema-sim-recovery.spec.ts): rectifier project load, Inspect → Simulator
* open, and the exact applied-generation run driver.
*/
const RECTIFIER_DIR = path.resolve(__dirname, '..', '..', '..',
'kicad', 'demos', 'simulation', 'rectifier');
const MEMFS_DIR = '/home/kicad/documents/rectifier';
const PROJECT_FILES = ['rectifier.kicad_sch', 'rectifier.kicad_pro', 'diode.mod',
'rectifier_schlib.kicad_sym', 'sym-lib-table', 'rectifier.wbk'];
export async function loadRectifier(page: Page): Promise<void> {
for (const f of PROJECT_FILES)
await injectFileIntoMemfs(page, path.join(RECTIFIER_DIR, f), `${MEMFS_DIR}/${f}`);
await page.evaluate(async (sch: string) => {
await (window as any).Module.kicadOpenFile(sch);
}, `${MEMFS_DIR}/rectifier.kicad_sch`);
await expect
.poll(async () => page.title(), { timeout: 120000 })
.toMatch(/rectifier/i);
}
/** Open Inspect → Simulator and return the new top-level window's DOM id. */
export async function openSimulator(page: Page): Promise<string> {
const idsBefore = await page.$$eval('#window-container [id^="window-"]',
(els) => els.map((e) => e.id));
expect(await clickMenuBarItem(page, 'Inspect'), 'Inspect menu').toBe(true);
await clickMenuItemByText(page, 'Simulator');
await page.waitForFunction((before: string[]) => {
const ids = Array.from(
document.querySelectorAll('#window-container [id^="window-"]'),
(e) => e.id);
return ids.some((id) => !before.includes(id));
}, idsBefore, { timeout: 60000 });
const idsAfter = await page.$$eval('#window-container [id^="window-"]',
(els) => els.map((e) => e.id));
const simWin = idsAfter.find((id) => !idsBefore.includes(id));
expect(simWin, 'simulator window appeared').toBeTruthy();
return simWin!;
}
/**
* Poll until the Run Simulation tool is enabled. The Run tool's
* ENABLE(!simRunning) condition is a wxUpdateUIEvent check, and the WASM port
* only reliably re-evaluates those when input events pump the loop — nudge
* the mouse each poll or the toolbar can hold a stale state forever.
*/
export async function waitForRunToolEnabled(page: Page, timeout = 60000): Promise<void> {
await expect
.poll(async () => {
await page.mouse.move(4, 4);
await page.mouse.move(8, 8);
const el = await findByTooltip(page, 'Run Simulation', { elementType: 'tool' });
return !!el && el.enabled;
}, { message: 'Run Simulation tool must be enabled', timeout })
.toBe(true);
}
/**
* Run the loaded workbook's analysis and await the exact native run generation
* only after its final plot, operating-point, and canvas refresh calls return.
*/
export async function runSimulation(page: Page): Promise<number> {
// The simulator window div appears while the frame ctor is still
// suspended in the init RPC; the toolbar registers its tools only after
// init completes and the frame first paints.
await waitForRunToolEnabled(page);
const generationCheckpoint = await page.evaluate(() => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
if (!hooks || typeof hooks.appliedGenerationCheckpoint !== 'function'
|| typeof hooks.waitForAppliedGenerationAfter !== 'function') {
throw new Error('exact ngspice applied-generation hooks are missing');
}
return hooks.appliedGenerationCheckpoint() as number;
});
expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }),
'Run tool').toBe(true);
const appliedReceipt = await page.evaluate(async (after: number) => {
const hooks = (globalThis as any).__ngspiceServiceTestHooks;
return await hooks.waitForAppliedGenerationAfter(after, 120000);
}, generationCheckpoint);
expect(appliedReceipt.generation, 'the clicked run published a newer applied generation')
.toBeGreaterThan(generationCheckpoint);
// The native receipt fires after the final refreshes. Additionally
// require the scheduler to hold no parked ngspice wait — a stale
// suspended frame here means the finish path leaked a wait. (The codex
// line awaited the execution owner's barrier; that machinery does not
// exist on the JSPI line, and wait drainage is its observable
// equivalent.)
await expect.poll(
() => page.evaluate(() => {
const scheduler = (globalThis as any).__wxScheduler;
return scheduler?.pendingWaits?.('ngspice') ?? -1;
}),
{ message: 'no ngspice wait may stay parked after the applied receipt', timeout: 30000 },
).toBe(0);
// Vector traffic is result validation only. It is deliberately not used as
// completion evidence because periodic OnSimRefresh(false) pulls can look
// identical to the final pull at the worker boundary.
const vectorReceipt = await page.evaluate(() =>
((window as any).__ngspiceLog as Array<{
sequence: number; kind: string; error?: string; length?: number;
}>).find((entry) => entry.kind === 'get_vec_info'
&& entry.error === undefined
&& (entry.length ?? -1) >= 101) ?? null,
);
expect(vectorReceipt, 'the applied run returned a non-trivial successful vector')
.not.toBeNull();
return appliedReceipt.generation;
}

View file

@ -39,8 +39,10 @@ export const BOOT_TIMEOUT = 150000;
* `(instances (project "trio" …))` entries must match this name. */
export const TRIO_DOC = "trio";
import { hasNativeFailure } from "./native-failure";
export function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
return hasNativeFailure(l);
}
// ── Fixtures ─────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,98 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { clickMenuBarItem, clickMenuItem, waitForRenderedByLabel, waitUntil } from '../../e2e/utils/element-tracker';
/**
* Shared wx dialog/menu drivers for the kicad specs (one copy — previously
* duplicated per spec, and the copies had started to drift).
*
* All clicks are coordinate clicks through the wx element registry: wx
* controls are canvas-rendered on this line (no DOM identity exists — nothing
* ever produces a data-wx-dom-id attribute), so the registry's geometry is
* the one supported click path.
*/
/** Wait for a rendered popup menu to have its items (replaces a fixed post-menu-click sleep). */
export 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',
);
}
/** Resolve one visible wx button (label or &-mnemonic label) to its registry geometry. */
export async function findWxButton(page: Page, label: string): Promise<{ x: number; y: number } | null> {
return 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);
}
/** Click a visible wx button by label; returns whether it was found. */
export async function clickWxButton(page: Page, label: string): Promise<boolean> {
const pos = await findWxButton(page, label);
if (!pos) return false;
await page.mouse.click(pos.x, pos.y);
return true;
}
/**
* Drive File → Export → STEP/GLB/… and wait until the export dialog's Export
* button is visible (the dialog object exists before its controls register).
*/
export async function openStepExportDialog(page: Page): Promise<void> {
expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true);
await waitForMenuItems(page);
await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' });
expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true);
// 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' });
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 });
}
/** Observable count of parked modal waits (each open wx modal holds one lease). */
export async function pendingModalWaits(page: Page): Promise<number> {
return page.evaluate(() => {
const scheduler = (globalThis as { __wxScheduler?: { pendingWaits?: (kind: string) => number } }).__wxScheduler;
return scheduler?.pendingWaits?.('modal') ?? -1;
});
}
/**
* Dismiss a report dialog that opens on top of the current modal stack (e.g.
* the "Export complete" report): wait for its modal lease and its OK button,
* click OK, and wait for the lease to release. `baseline` is the modal count
* before the report dialog appears.
*/
export async function dismissReportDialog(page: Page, baseline: number, what: string): Promise<void> {
await expect.poll(
() => pendingModalWaits(page),
{ message: `${what}: report dialog must open (modal lease)`, timeout: 30000 },
).toBe(baseline + 1);
await expect.poll(
() => findWxButton(page, 'OK'),
{ message: `${what}: report dialog OK button must render`, timeout: 10000 },
).not.toBeNull();
expect(await clickWxButton(page, 'OK'), `${what}: dismiss report dialog`).toBe(true);
await expect.poll(
() => pendingModalWaits(page),
{ message: `${what}: report dialog must release its modal lease`, timeout: 10000 },
).toBe(baseline);
}