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:
parent
0f21a33216
commit
c421d724b0
48 changed files with 2071 additions and 694 deletions
20
.github/workflows/wasm-build.yml
vendored
20
.github/workflows/wasm-build.yml
vendored
|
|
@ -393,6 +393,14 @@ jobs:
|
||||||
corepack enable
|
corepack enable
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# The web/standalone vitest suites carry the red/green evidence for the
|
||||||
|
# findings-E service-layer fixes (worker lifecycle, transport credits,
|
||||||
|
# admission gate, models prefetch). Node-env, no wasm build needed.
|
||||||
|
- name: web standalone unit tests (vitest)
|
||||||
|
if: inputs.run_tests
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm --filter @pcbjam/standalone test
|
||||||
|
|
||||||
# kicad_tools gates (tasks-runner 0001 R2): the corpus lint (fixtures +
|
# kicad_tools gates (tasks-runner 0001 R2): the corpus lint (fixtures +
|
||||||
# shared-codec round-trips — the wrapInBoardEnvelope-class E3 gate,
|
# shared-codec round-trips — the wrapInBoardEnvelope-class E3 gate,
|
||||||
# kicad-validity 0001 §5) and the CLI contract the backend job runner
|
# kicad-validity 0001 §5) and the CLI contract the backend job runner
|
||||||
|
|
@ -415,6 +423,18 @@ jobs:
|
||||||
npm ci
|
npm ci
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
|
# Findings-E gates: the ngspice transport reducer (production worker
|
||||||
|
# source in a node:vm), the C++/shim source contract, and the
|
||||||
|
# stub/production parity tripwire. lint-ci-coverage asserts these exact
|
||||||
|
# invocations stay wired (NON_PLAYWRIGHT_GATES).
|
||||||
|
- name: findings-E transport reducer + source/parity contracts
|
||||||
|
if: inputs.run_tests
|
||||||
|
working-directory: tests
|
||||||
|
run: |
|
||||||
|
npm run ngspice:worker-batch
|
||||||
|
npm run findings-e:contract
|
||||||
|
npm run findings-e:parity
|
||||||
|
|
||||||
# Browser binaries keyed on the lockfile (which pins the playwright version).
|
# Browser binaries keyed on the lockfile (which pins the playwright version).
|
||||||
# On a hit `playwright install` skips the downloads; --with-deps still
|
# On a hit `playwright install` skips the downloads; --with-deps still
|
||||||
# apt-installs its small OS dep set either way.
|
# apt-installs its small OS dep set either way.
|
||||||
|
|
|
||||||
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
||||||
Subproject commit 230682955a2873ade2273875659a630311982abb
|
Subproject commit 56678639eea1b3c0358f384fa47ef7fab78bd09b
|
||||||
|
|
@ -250,6 +250,16 @@
|
||||||
resolveWait: function (token, result) {
|
resolveWait: function (token, result) {
|
||||||
var entry = this.waits.get(token);
|
var entry = this.waits.get(token);
|
||||||
if (!entry || entry.resolved) return false;
|
if (!entry || entry.resolved) return false;
|
||||||
|
if (this.terminal) {
|
||||||
|
// Resolving would resume the parked frame INSIDE the trapped module
|
||||||
|
// (the runWaitCompletion invariant, which the bare finishers used to
|
||||||
|
// bypass). Refuse WITHOUT consuming the entry — the frame stays
|
||||||
|
// visibly parked in dump() and the ring says why.
|
||||||
|
this._note("resolveRefused", entry.kind, token);
|
||||||
|
console.warn("[wx-scheduler] resolveWait(" + token + ", " + entry.kind
|
||||||
|
+ ") refused: instance is terminal");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
entry.resolved = true;
|
entry.resolved = true;
|
||||||
this.waitsResolved++;
|
this.waitsResolved++;
|
||||||
var stack = this.waitStacks[entry.kind];
|
var stack = this.waitStacks[entry.kind];
|
||||||
|
|
@ -282,27 +292,36 @@
|
||||||
|
|
||||||
dead: false,
|
dead: false,
|
||||||
// --- E-8: admission gate for delayed worker/MEMFS completions -----------
|
// --- E-8: admission gate for delayed worker/MEMFS completions -----------
|
||||||
// `terminal` means the wasm instance TRAPPED (WebAssembly.RuntimeError or
|
// `terminal` means the wasm instance TRAPPED (WebAssembly.RuntimeError,
|
||||||
// its cross-realm string equivalent): the heap may be mid-mutation, so no
|
// or emscripten's abort — which throws a RuntimeError itself and is also
|
||||||
// further native work (malloc / heap stores / FS writes) may run and no
|
// latched authoritatively via Module.onAbort → terminalize): the heap may
|
||||||
// parked frame may be resumed into it. Distinct from `dead` (orderly
|
// be mid-mutation, so no further native work (malloc / heap stores / FS
|
||||||
// shutdown). One-way.
|
// writes) may run and no parked frame may be resumed into it. Distinct
|
||||||
|
// from `dead` (orderly shutdown). One-way.
|
||||||
terminal: false,
|
terminal: false,
|
||||||
canTouchNative: function () { return !this.dead && !this.terminal; },
|
canTouchNative: function () { return !this.dead && !this.terminal; },
|
||||||
|
// Public one-way latch (also wired from boot's Module.onAbort — the
|
||||||
|
// authoritative abort notification).
|
||||||
|
terminalize: function (site, e) {
|
||||||
|
if (this.terminal) return;
|
||||||
|
this.terminal = true;
|
||||||
|
this._note("terminal", site, 0);
|
||||||
|
console.error("[wx-scheduler] instance is terminal (" + site
|
||||||
|
+ ") — all further native completions are inert: " + (e || ""));
|
||||||
|
},
|
||||||
_terminalizeNativeTrap: function (site, e) {
|
_terminalizeNativeTrap: function (site, e) {
|
||||||
|
// Structural signals only: a genuine engine trap in this same-realm
|
||||||
|
// prepare/entry IS a WebAssembly.RuntimeError instance; the duck-typed
|
||||||
|
// name fallback survives realm loss on a relayed error object. The old
|
||||||
|
// message-substring sniff ('Aborted(', 'index out of bounds', …) only
|
||||||
|
// added false positives — any plain JS error QUOTING such text bricked
|
||||||
|
// a healthy instance permanently.
|
||||||
var isTrap = (typeof WebAssembly !== "undefined"
|
var isTrap = (typeof WebAssembly !== "undefined"
|
||||||
&& WebAssembly.RuntimeError
|
&& WebAssembly.RuntimeError
|
||||||
&& e instanceof WebAssembly.RuntimeError)
|
&& e instanceof WebAssembly.RuntimeError)
|
||||||
|| /unreachable|memory access out of bounds|index out of bounds|null function or function signature mismatch|Aborted\(/i
|
|| !!(e && e.name === "RuntimeError");
|
||||||
.test(String((e && e.message) || e));
|
|
||||||
if (!isTrap) return false;
|
if (!isTrap) return false;
|
||||||
if (!this.terminal) {
|
this.terminalize(site, e);
|
||||||
this.terminal = true;
|
|
||||||
this._note("terminal", site, 0);
|
|
||||||
console.error("[wx-scheduler] native trap in " + site
|
|
||||||
+ " — instance is terminal; all further native completions are inert: "
|
|
||||||
+ e);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
// The one admission boundary for delayed completions that both touch
|
// The one admission boundary for delayed completions that both touch
|
||||||
|
|
@ -550,7 +569,11 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
_pumpResume: function () {
|
_pumpResume: function () {
|
||||||
if (this.dead) return;
|
// `terminal` too: a queued wake must never re-enter a trapped module —
|
||||||
|
// resuming swaps SP into (and runs wasm on) a heap that may be
|
||||||
|
// mid-mutation. Freezing the pump on a terminal instance is by design:
|
||||||
|
// the fatal overlay owns the page from here.
|
||||||
|
if (this.dead || this.terminal) return;
|
||||||
if (this._windowLive) {
|
if (this._windowLive) {
|
||||||
// Self-heal: an activation that suspended RAW (bypassing the shim)
|
// Self-heal: an activation that suspended RAW (bypassing the shim)
|
||||||
// or completed untracked never ends its window here; without this
|
// or completed untracked never ends its window here; without this
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,9 @@ test.describe('wxFileDialog Tests', () => {
|
||||||
|
|
||||||
// Try all three buttons
|
// Try all three buttons
|
||||||
await clickByLabel(page, 'Open File...');
|
await clickByLabel(page, 'Open File...');
|
||||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: click commit before the next dialog button click
|
||||||
await clickByLabel(page, 'Save File...');
|
await clickByLabel(page, 'Save File...');
|
||||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: click commit before the next dialog button click
|
||||||
await clickByLabel(page, 'Open Multiple...');
|
await clickByLabel(page, 'Open Multiple...');
|
||||||
|
|
||||||
await stableShot(page, 'filedialog-05-all-buttons.png', { fullPage: true });
|
await stableShot(page, 'filedialog-05-all-buttons.png', { fullPage: true });
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ test.describe('wxSplitterWindow & wxScrolledWindow Tests', () => {
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(sash!.centerX + 100, sash!.centerY, { steps: 5 });
|
await page.mouse.move(sash!.centerX + 100, sash!.centerY, { steps: 5 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell (splitter drag commit before re-reading sash from registry)
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: splitter drag commit before re-reading sash from registry
|
||||||
|
|
||||||
// Get updated sash position after drag
|
// Get updated sash position after drag
|
||||||
const sashAfter = await getSplitterSash(page);
|
const sashAfter = await getSplitterSash(page);
|
||||||
|
|
@ -91,7 +91,7 @@ test.describe('wxSplitterWindow & wxScrolledWindow Tests', () => {
|
||||||
// Scroll left pane (use position left of sash)
|
// Scroll left pane (use position left of sash)
|
||||||
await page.mouse.move(sashAfter!.centerX - 100, sashAfter!.centerY);
|
await page.mouse.move(sashAfter!.centerX - 100, sashAfter!.centerY);
|
||||||
await page.mouse.wheel(0, 50);
|
await page.mouse.wheel(0, 50);
|
||||||
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell (scroll commit between the two pane scrolls)
|
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: scroll commit between the two pane scrolls
|
||||||
|
|
||||||
// Scroll right pane (use position right of sash)
|
// Scroll right pane (use position right of sash)
|
||||||
await page.mouse.move(sashAfter!.centerX + 100, sashAfter!.centerY);
|
await page.mouse.move(sashAfter!.centerX + 100, sashAfter!.centerY);
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ test.describe('wxMenuBar Tests', () => {
|
||||||
for (const label of menuLabels) {
|
for (const label of menuLabels) {
|
||||||
const clicked = await clickMenuBarItem(page, label);
|
const clicked = await clickMenuBarItem(page, label);
|
||||||
expect(clicked, `Menu "${label}" should be found and clicked`).toBe(true);
|
expect(clicked, `Menu "${label}" should be found and clicked`).toBe(true);
|
||||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: menu open commit between menu-bar clicks
|
||||||
}
|
}
|
||||||
|
|
||||||
await stableShot(page, 'menu-05-all-menus.png', { fullPage: true });
|
await stableShot(page, 'menu-05-all-menus.png', { fullPage: true });
|
||||||
|
|
|
||||||
|
|
@ -173,9 +173,9 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
|
||||||
const startX = tbox!.x + tbox!.width / 2;
|
const startX = tbox!.x + tbox!.width / 2;
|
||||||
const startY = tbox!.y + tbox!.height / 2;
|
const startY = tbox!.y + tbox!.height / 2;
|
||||||
await page.mouse.move(startX, startY);
|
await page.mouse.move(startX, startY);
|
||||||
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell (pointer settle before grabbing the title bar)
|
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: pointer settle before grabbing the title bar
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell (press commit before the drag begins)
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: press commit before the drag begins
|
||||||
|
|
||||||
// Drag in many small steps, sampling the modal canvas immediately after each
|
// Drag in many small steps, sampling the modal canvas immediately after each
|
||||||
// move. Each move calls setWindowRect, which clears the canvas; the dialog's
|
// move. Each move calls setWindowRect, which clears the canvas; the dialog's
|
||||||
|
|
@ -257,7 +257,7 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
|
||||||
const startY = hbox!.y + hbox!.height / 2;
|
const startY = hbox!.y + hbox!.height / 2;
|
||||||
await page.mouse.move(startX, startY);
|
await page.mouse.move(startX, startY);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell (press commit before the resize drag begins)
|
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell: press commit before the resize drag begins
|
||||||
|
|
||||||
let minOpaque = 1;
|
let minOpaque = 1;
|
||||||
let lowFrames = 0;
|
let lowFrames = 0;
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ test.describe('DOM-port scrollbars', () => {
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(tx, ty, { steps: 6 });
|
await page.mouse.move(tx, ty, { steps: 6 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: slider drag commit before the next drag
|
||||||
}
|
}
|
||||||
|
|
||||||
// At least one standalone scrollbar must have reported a non-zero position.
|
// At least one standalone scrollbar must have reported a non-zero position.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => {
|
||||||
const after = await listWindows();
|
const after = await listWindows();
|
||||||
const id = after.find((w) => !before.includes(w));
|
const id = after.find((w) => !before.includes(w));
|
||||||
expect(id, `${buttonLabel} should open a new window`).toBeTruthy();
|
expect(id, `${buttonLabel} should open a new window`).toBeTruthy();
|
||||||
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell (new-window DOM population settle; no event/registry observable)
|
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: new-window DOM population settle; no event/registry observable
|
||||||
return id as string;
|
return id as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,14 +61,14 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => {
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(sx, sy + 90, { steps: 10 });
|
await page.mouse.move(sx, sy + 90, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell (title-bar drag commit; no event/registry observable)
|
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell: title-bar drag commit; no event/registry observable
|
||||||
const after = await styleRect(winId);
|
const after = await styleRect(winId);
|
||||||
return !!before && !!after && (Math.abs(after.top - before.top) > 5 || Math.abs(after.left - before.left) > 5);
|
return !!before && !!after && (Math.abs(after.top - before.top) > 5 || Math.abs(after.left - before.left) > 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeViaTitlebar(winId: string): Promise<boolean> {
|
async function closeViaTitlebar(winId: string): Promise<boolean> {
|
||||||
await page.locator(`#${winId} .window-titlebar-close`).click();
|
await page.locator(`#${winId} .window-titlebar-close`).click();
|
||||||
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell (× close / modal EndModal commit; no event/registry observable)
|
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: × close / modal EndModal commit; no event/registry observable
|
||||||
return page.evaluate((wid) => {
|
return page.evaluate((wid) => {
|
||||||
const el = document.getElementById(wid);
|
const el = document.getElementById(wid);
|
||||||
return !el || getComputedStyle(el).display === 'none';
|
return !el || getComputedStyle(el).display === 'none';
|
||||||
|
|
@ -87,7 +87,7 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => {
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(sx + 60, sy + 60, { steps: 10 });
|
await page.mouse.move(sx + 60, sy + 60, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell (se-corner resize drag commit; no event/registry observable)
|
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell: se-corner resize drag commit; no event/registry observable
|
||||||
const after = await styleRect(winId);
|
const after = await styleRect(winId);
|
||||||
return !!before && !!after
|
return !!before && !!after
|
||||||
&& (after.width - before.width > 20) && (after.height - before.height > 20);
|
&& (after.width - before.width > 20) && (after.height - before.height > 20);
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ test.describe('wxWizard Tests', () => {
|
||||||
|
|
||||||
// Let the Next page-transition commit before clicking Back (the Back/Next
|
// Let the Next page-transition commit before clicking Back (the Back/Next
|
||||||
// buttons persist across pages, so there is no registry delta to poll on).
|
// buttons persist across pages, so there is no registry delta to poll on).
|
||||||
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: Next page-transition commit; no registry delta to poll
|
||||||
|
|
||||||
// Click Back using element registry
|
// Click Back using element registry
|
||||||
const backClicked = await clickByLabel(page, 'Back');
|
const backClicked = await clickByLabel(page, 'Back');
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,9 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors
|
||||||
|
|
||||||
await page.mouse.click(filenameInput.x, filenameInput.y);
|
await page.mouse.click(filenameInput.x, filenameInput.y);
|
||||||
// Documented interaction dwells: focus + typed-text registration have no observable signal.
|
// 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.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');
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
||||||
|
|
@ -215,7 +215,7 @@ test.describe('3D viewer component models', () => {
|
||||||
SERVED_REF, { timeout: 120000 });
|
SERVED_REF, { timeout: 120000 });
|
||||||
// Let the rest of the model-enumeration ensures flush after the served ref lands —
|
// 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.
|
// 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) ---------------------------------
|
// --- bridge assertions (run on CI too) ---------------------------------
|
||||||
const ensures = await page.evaluate(() => window.__modelEnsures ?? []);
|
const ensures = await page.evaluate(() => window.__modelEnsures ?? []);
|
||||||
|
|
|
||||||
|
|
@ -269,13 +269,13 @@ test.describe('3D viewer from pcbnew', () => {
|
||||||
// Let the frame-move op (wx_window_move → wxWindow::Move) fully settle before the
|
// 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
|
// 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).
|
// 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);
|
const afterTop = await styleTop(winId as string);
|
||||||
expect(afterTop, 'dragging the title bar should move the 3D viewer frame').not.toBe(beforeTop);
|
expect(afterTop, 'dragging the title bar should move the 3D viewer frame').not.toBe(beforeTop);
|
||||||
|
|
||||||
// Close via the × (wx_window_close → wx Close() → OnCloseWindow).
|
// Close via the × (wx_window_close → wx Close() → OnCloseWindow).
|
||||||
await page.locator(`#${winId} .window-titlebar-close`).click();
|
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 gone = await page.evaluate((wid) => {
|
||||||
const el = document.getElementById(wid);
|
const el = document.getElementById(wid);
|
||||||
return !el || getComputedStyle(el).display === 'none';
|
return !el || getComputedStyle(el).display === 'none';
|
||||||
|
|
@ -352,7 +352,7 @@ test.describe('3D viewer from pcbnew', () => {
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
// Let the resize op (wx_window_resize → SetSize → relayout + GL canvas resize)
|
// Let the resize op (wx_window_resize → SetSize → relayout + GL canvas resize)
|
||||||
// settle before reading widths (documented interaction dwell).
|
// 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 afterFrame = await frameWidth(winId as string);
|
||||||
const afterGl = await glWidth();
|
const afterGl = await glWidth();
|
||||||
|
|
|
||||||
183
tests/kicad/eeschema-sim-recovery.spec.ts
Normal file
183
tests/kicad/eeschema-sim-recovery.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
import * as path from 'path';
|
|
||||||
import { PNG } from 'pngjs';
|
import { PNG } from 'pngjs';
|
||||||
import {
|
import { stableShot, waitForEditorReady } from '../e2e/utils/element-tracker';
|
||||||
clickByTooltip,
|
import { loadRectifier, openSimulator, runSimulation } from './utils/sim-harness';
|
||||||
clickMenuBarItem,
|
|
||||||
clickMenuItemByText,
|
|
||||||
findByTooltip,
|
|
||||||
stableShot,
|
|
||||||
waitForEditorReady,
|
|
||||||
} from '../e2e/utils/element-tracker';
|
|
||||||
import { injectFileIntoMemfs } from './utils/fs-inject';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* eeschema simulator end-to-end (docs/features/ngspice-split/): the historic
|
* 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").
|
* 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 {
|
function distinctColors(png: PNG): number {
|
||||||
const colors = new Set<number>();
|
const colors = new Set<number>();
|
||||||
// 8x8 grid sampling, same spirit as the 3d-viewer render check.
|
// 8x8 grid sampling, same spirit as the 3d-viewer render check.
|
||||||
|
|
|
||||||
|
|
@ -139,9 +139,9 @@ function runLoadPcbTest(demo: DemoCfg): void {
|
||||||
await page.mouse.click(filenameInput.x, filenameInput.y);
|
await page.mouse.click(filenameInput.x, filenameInput.y);
|
||||||
// Small settle so the focus click lands before typing — no JS-observable "input
|
// Small settle so the focus click lands before typing — no JS-observable "input
|
||||||
// focused" signal here (documented interaction wait).
|
// 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.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');
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
// ── Wait for the load to complete (no dialogs visible). The
|
// ── Wait for the load to complete (no dialogs visible). The
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { test, expect } from './fixtures';
|
||||||
import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil } from '../e2e/utils/element-tracker';
|
import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil } from '../e2e/utils/element-tracker';
|
||||||
import { injectFromSubmodule } from './utils/fs-inject';
|
import { injectFromSubmodule } from './utils/fs-inject';
|
||||||
import { waitForBoardLoaded } from './utils/board-ready';
|
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 →
|
* STEP export × 3D model delivery (docs/features/3d-models, 0007): File →
|
||||||
|
|
@ -52,19 +53,6 @@ interface ExportCapture {
|
||||||
productCount: number;
|
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 —
|
* 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
|
* 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
|
// Mirror models-bridge.ts ensureModelInMemfs: write under the
|
||||||
// JS-owned model root, answer with the ABSOLUTE path.
|
// JS-owned model root, answer with the ABSOLUTE path.
|
||||||
// @ts-expect-error — Emscripten FS lives on window
|
|
||||||
const FS = (window as any).FS;
|
const FS = (window as any).FS;
|
||||||
const dest = `${stockDir}/${arg}`;
|
const dest = `${stockDir}/${arg}`;
|
||||||
FS.mkdirTree(dest.slice(0, dest.lastIndexOf('/')));
|
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);
|
await page.mouse.click(filenameInput.x, filenameInput.y);
|
||||||
// Documented interaction dwells: focus + typed-text registration have no observable signal.
|
// 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.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');
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
||||||
console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`);
|
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. */
|
/** 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 }> }> {
|
async function runStepExport(page: Page): Promise<{ exp: ExportCapture; ensures: Array<{ op: string; arg: string }> }> {
|
||||||
expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true);
|
await openStepExportDialog(page);
|
||||||
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 });
|
|
||||||
|
|
||||||
expect(await clickWxButton(page, 'Export'), 'Export button click').toBe(true);
|
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.lib.slice(0, 5)) console.log(`[TEST] missing lib: ${f}`);
|
||||||
for (const f of missing.project) console.log(`[TEST] missing project: ${f}`);
|
for (const f of missing.project) console.log(`[TEST] missing project: ${f}`);
|
||||||
|
|
||||||
// Dismiss the export report dialog (its appearance after the worker
|
// Dismiss the export report dialog via its modal lease (the export
|
||||||
// returns has no distinct registry signal to poll).
|
// dialog holds 1; the report raises it to 2).
|
||||||
await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell
|
await dismissReportDialog(page, 1, 'export report');
|
||||||
await clickWxButton(page, 'OK');
|
|
||||||
|
|
||||||
expect(testLogger.errors, 'no page errors during the export flow').toEqual([]);
|
expect(testLogger.errors, 'no page errors during the export flow').toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,10 @@ import type { Page } from '@playwright/test';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { test, expect } from './fixtures';
|
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 { injectFromSubmodule } from './utils/fs-inject';
|
||||||
import { openBoardProgrammatically } from './utils/board-ready';
|
import { openBoardProgrammatically } from './utils/board-ready';
|
||||||
|
import { findWxButton, clickWxButton, openStepExportDialog, dismissReportDialog } from './utils/wx-dialogs';
|
||||||
/** 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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* STEP export through the occ_service worker (docs/features/occ-split/):
|
* 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}`);
|
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('OCC export via occ_service worker', () => {
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
test.setTimeout(240000);
|
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')
|
expect(occFetches.length, 'occ_service was fetched lazily by the export')
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
// Dismiss the "Export complete" report dialog if present. Its appearance after
|
// Dismiss the "Export complete" report dialog: it opens on top of the
|
||||||
// the worker returns has no distinct registry signal to poll — a short documented
|
// export dialog's modal lease (1 → 2), which IS its observable signal.
|
||||||
// dwell, then click OK if present.
|
await dismissReportDialog(page, 1, 'export complete');
|
||||||
await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell
|
|
||||||
await clickWxButton(page, 'OK');
|
|
||||||
|
|
||||||
await stableShot(page, 'occ-export-done.png');
|
await stableShot(page, 'occ-export-done.png');
|
||||||
});
|
});
|
||||||
|
|
@ -243,14 +176,14 @@ test.describe('OCC export via occ_service worker', () => {
|
||||||
expect(failed.service.maxPending,
|
expect(failed.service.maxPending,
|
||||||
'two requests must coexist in one generation; worker requests are not serialized')
|
'two requests must coexist in one generation; worker requests are not serialized')
|
||||||
.toBeGreaterThanOrEqual(2);
|
.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,
|
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,
|
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.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.activeGeneration, 'the failed slot must be cleared').toBeNull();
|
||||||
expect(failed.service.armed, 'the one-shot fault must be consumed').toBe(false);
|
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)}`);
|
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.
|
// turning the handback race into a click on some replacement control.
|
||||||
const retryExport = await findWxButton(page, 'Export');
|
const retryExport = await findWxButton(page, 'Export');
|
||||||
expect(retryExport, 'the original parent Export button must remain registered').not.toBeNull();
|
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!.x, 'the retry target has stable geometry').toBeGreaterThan(0);
|
||||||
expect(retryExport!.y, '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);
|
).toBe(1);
|
||||||
|
|
||||||
// The export dialog remains open. Its next request must create a fresh
|
// 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');
|
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(
|
await expect.poll(
|
||||||
() => page.evaluate(() => (globalThis as any)
|
() => page.evaluate(() => (globalThis as any)
|
||||||
.__occServiceTestHooks.snapshot().requestsStarted),
|
.__occServiceTestHooks.snapshot().requestsStarted),
|
||||||
|
|
@ -321,16 +252,22 @@ test.describe('OCC export via occ_service worker', () => {
|
||||||
.toBeGreaterThan(10_000);
|
.toBeGreaterThan(10_000);
|
||||||
expect(recovered.service.activeGeneration, 'retry must own a replacement generation').toBe(2);
|
expect(recovered.service.activeGeneration, 'retry must own a replacement generation').toBe(2);
|
||||||
expect(recovered.service.requestsStarted,
|
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,
|
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,
|
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.service.pending, 'replacement generation must quiesce').toBe(0);
|
||||||
expect(recovered.schedulerDead, 'the worker failure must not terminalize the editor').toBe(false);
|
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);
|
expect(recovered.occWaits, 'the replacement native OCC wait must quiesce').toBe(0);
|
||||||
|
|
||||||
await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell
|
// Dismiss the retry's "Export complete" report dialog via its modal
|
||||||
await clickWxButton(page, 'OK');
|
// lease (export dialog holds 1; the report raises it to 2).
|
||||||
|
await dismissReportDialog(page, 1, 'retry export complete');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
82
tests/kicad/occ-service-watchdog.spec.ts
Normal file
82
tests/kicad/occ-service-watchdog.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -118,15 +118,15 @@ test.describe('PCBnew move with "m" (#9)', () => {
|
||||||
// JS-observable signal, and the asyncified pointer-move handler needs wall-clock
|
// JS-observable signal, and the asyncified pointer-move handler needs wall-clock
|
||||||
// time to update the world cursor before each button press.
|
// time to update the world cursor before each button press.
|
||||||
await page.mouse.move(startPoint.x, startPoint.y);
|
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.down();
|
||||||
await page.mouse.up();
|
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.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.down();
|
||||||
await page.mouse.up();
|
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
|
// Finish the segment, then wait for the new board item to register
|
||||||
// (deterministic — replaces two fixed 250ms sleeps).
|
// (deterministic — replaces two fixed 250ms sleeps).
|
||||||
await page.keyboard.press('Escape');
|
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
|
// asyncified event loop to process before the next. The outcome (the item moved
|
||||||
// right) is asserted below via the embind position hook.
|
// right) is asserted below via the embind position hook.
|
||||||
await page.mouse.move(midPoint.x, midPoint.y);
|
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.down();
|
||||||
await page.mouse.up();
|
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;
|
const NUDGES = 10;
|
||||||
await page.keyboard.press('m');
|
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++) {
|
for (let i = 0; i < NUDGES; i++) {
|
||||||
await page.keyboard.press('ArrowRight');
|
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).
|
// Commit at the nudged position WITHOUT moving the cursor (Enter, not click).
|
||||||
await page.keyboard.press('Enter');
|
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' });
|
const afterMove = await page.screenshot({ path: shotPath(page, 'pcbnew-move-01-after.png'), scale: 'css' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { waitForCanvasStable } from '../../e2e/utils/element-tracker';
|
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.
|
* 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
|
// goes away and we'd burn the full timeout. KiCad logs the abort
|
||||||
// line through Module.printErr, which our test logger captures as
|
// line through Module.printErr, which our test logger captures as
|
||||||
// a console error. We re-read the live arrays each tick.
|
// a console error. We re-read the live arrays each tick.
|
||||||
const allLines = [...logger.consoleLogs, ...logger.errors];
|
const abort = findNativeFailure([...logger.consoleLogs, ...logger.errors]);
|
||||||
const abort = allLines.find((l) =>
|
|
||||||
l.includes('Aborted(') || l.includes('RuntimeError: unreachable')
|
|
||||||
);
|
|
||||||
if (abort) {
|
if (abort) {
|
||||||
throw new Error(`WASM aborted during LoadBoard:\n${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(
|
async function waitForBoardIdentityAndPaint(
|
||||||
page: Page,
|
page: Page,
|
||||||
expectedBoard: string,
|
expectedBoard: string,
|
||||||
|
|
|
||||||
35
tests/kicad/utils/native-failure.ts
Normal file
35
tests/kicad/utils/native-failure.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
|
@ -108,26 +108,10 @@ export async function installNgspiceServiceStub(
|
||||||
name?: string;
|
name?: string;
|
||||||
minimumLength?: number;
|
minimumLength?: number;
|
||||||
}
|
}
|
||||||
interface RequestWaiter {
|
|
||||||
after: number;
|
|
||||||
criteria: RequestCriteria;
|
|
||||||
resolve: (summary: RequestSummary) => void;
|
|
||||||
reject: (reason?: unknown) => void;
|
|
||||||
timer: ReturnType<typeof setTimeout>;
|
|
||||||
}
|
|
||||||
interface AppliedGenerationReceipt {
|
interface AppliedGenerationReceipt {
|
||||||
generation: number;
|
generation: number;
|
||||||
t: 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 RECEIPT_TIMEOUT_MS = 2 * 60_000;
|
||||||
const MAX_RECEIPT_TIMEOUT_MS = 5 * 60_000;
|
const MAX_RECEIPT_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
@ -140,11 +124,11 @@ export async function installNgspiceServiceStub(
|
||||||
let maxPending = 0;
|
let maxPending = 0;
|
||||||
let bootMessageErrorArmed = false;
|
let bootMessageErrorArmed = false;
|
||||||
let runtimeMessageErrorThreshold: number | null = null;
|
let runtimeMessageErrorThreshold: number | null = null;
|
||||||
|
let dieOnNextBgRunArmed = false;
|
||||||
|
let corruptNextGetVecArmed = false;
|
||||||
const retiredGenerations: number[] = [];
|
const retiredGenerations: number[] = [];
|
||||||
let nextRequestSequence = 1;
|
let nextRequestSequence = 1;
|
||||||
const requestWaiters = new Set<RequestWaiter>();
|
|
||||||
const appliedGenerations: AppliedGenerationReceipt[] = [];
|
const appliedGenerations: AppliedGenerationReceipt[] = [];
|
||||||
const appliedGenerationWaiters = new Set<AppliedGenerationWaiter>();
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
const validateReceiptTimeout = (timeoutMs: number): string | undefined => {
|
const validateReceiptTimeout = (timeoutMs: number): string | undefined => {
|
||||||
|
|
@ -155,15 +139,6 @@ export async function installNgspiceServiceStub(
|
||||||
return undefined;
|
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 = (
|
const requestMatches = (
|
||||||
summary: RequestSummary,
|
summary: RequestSummary,
|
||||||
after: number,
|
after: number,
|
||||||
|
|
@ -175,115 +150,130 @@ export async function installNgspiceServiceStub(
|
||||||
&& (criteria.minimumLength === undefined
|
&& (criteria.minimumLength === undefined
|
||||||
|| (summary.length ?? -1) >= criteria.minimumLength);
|
|| (summary.length ?? -1) >= criteria.minimumLength);
|
||||||
|
|
||||||
const rejectRequestWaiter = (waiter: RequestWaiter, reason: Error): void => {
|
interface ReceiptWaiter<TReceipt, TCriteria> {
|
||||||
if (!requestWaiters.delete(waiter)) return;
|
after: number;
|
||||||
clearTimeout(waiter.timer);
|
criteria: TCriteria;
|
||||||
waiter.reject(reason);
|
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) => {
|
const publishRequestReceipt = (summary: RequestSummary) => {
|
||||||
(window as any).__ngspiceLog.push(summary);
|
(window as any).__ngspiceLog.push(summary);
|
||||||
for (const waiter of [...requestWaiters]) {
|
requestReceipts.publish(summary);
|
||||||
if (!requestMatches(summary, waiter.after, waiter.criteria)) continue;
|
|
||||||
requestWaiters.delete(waiter);
|
|
||||||
clearTimeout(waiter.timer);
|
|
||||||
waiter.resolve(summary);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const waitForRequestAfter = (
|
const waitForRequestAfter = (
|
||||||
after: number,
|
after: number,
|
||||||
criteria: RequestCriteria,
|
criteria: RequestCriteria,
|
||||||
timeoutMs = RECEIPT_TIMEOUT_MS,
|
timeoutMs = RECEIPT_TIMEOUT_MS,
|
||||||
): CancellableReceipt<RequestSummary> => {
|
): Promise<RequestSummary> => requestReceipts.wait(after, criteria, timeoutMs);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const waitForAppliedGenerationAfter = (
|
const waitForAppliedGenerationAfter = (
|
||||||
after: number,
|
after: number,
|
||||||
timeoutMs = RECEIPT_TIMEOUT_MS,
|
timeoutMs = RECEIPT_TIMEOUT_MS,
|
||||||
): CancellableReceipt<AppliedGenerationReceipt> => {
|
): Promise<AppliedGenerationReceipt> => appliedReceipts.wait(after, undefined, timeoutMs);
|
||||||
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}`),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousAppliedHook = (globalThis as any).__pcbjamNgspiceFinalRefreshApplied;
|
const previousAppliedHook = (globalThis as any).__pcbjamNgspiceFinalRefreshApplied;
|
||||||
const publishAppliedGeneration = (generation: number): void => {
|
const publishAppliedGeneration = (generation: number): void => {
|
||||||
|
|
@ -301,12 +291,7 @@ export async function installNgspiceServiceStub(
|
||||||
}
|
}
|
||||||
const receipt = { generation, t: Date.now() - t0 };
|
const receipt = { generation, t: Date.now() - t0 };
|
||||||
appliedGenerations.push(receipt);
|
appliedGenerations.push(receipt);
|
||||||
for (const waiter of [...appliedGenerationWaiters]) {
|
appliedReceipts.publish(receipt);
|
||||||
if (generation <= waiter.after) continue;
|
|
||||||
appliedGenerationWaiters.delete(waiter);
|
|
||||||
clearTimeout(waiter.timer);
|
|
||||||
waiter.resolve(receipt);
|
|
||||||
}
|
|
||||||
if (typeof previousAppliedHook === 'function') previousAppliedHook(generation);
|
if (typeof previousAppliedHook === 'function') previousAppliedHook(generation);
|
||||||
};
|
};
|
||||||
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = publishAppliedGeneration;
|
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = publishAppliedGeneration;
|
||||||
|
|
@ -353,15 +338,21 @@ export async function installNgspiceServiceStub(
|
||||||
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
|
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
|
||||||
const handler = (globalThis as any).__ngspiceOnEvent;
|
const handler = (globalThis as any).__ngspiceOnEvent;
|
||||||
if (handler) {
|
if (handler) {
|
||||||
while (evtQueue.length) {
|
// Mirrors the production service: the host owns the frame's
|
||||||
const queued = evtQueue.shift()!;
|
// credit from onmessage on, so the ack survives a throwing
|
||||||
evtQueueBytes -= queued.bytes;
|
// handler (which keeps propagating).
|
||||||
if (queued.generation !== slot.generation) continue;
|
try {
|
||||||
// Queued frames were acked at enqueue (ownership taken then).
|
while (evtQueue.length) {
|
||||||
handler(queued.evt);
|
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 {
|
} else {
|
||||||
if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES
|
if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES
|
||||||
|| evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) {
|
|| 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) => {
|
const failPending = (generation: number, why: string) => {
|
||||||
for (const [id, request] of pending) {
|
for (const [id, request] of pending) {
|
||||||
if (request.generation !== generation) continue;
|
if (request.generation !== generation) continue;
|
||||||
|
|
@ -389,6 +400,7 @@ export async function installNgspiceServiceStub(
|
||||||
const retireWorker = (slot: WorkerSlot, why: string) => {
|
const retireWorker = (slot: WorkerSlot, why: string) => {
|
||||||
if (slot.failed) return;
|
if (slot.failed) return;
|
||||||
slot.failed = true;
|
slot.failed = true;
|
||||||
|
console.log(`[TEST-NGSPICE] retiring generation ${slot.generation}: ${why}`);
|
||||||
retiredGenerations.push(slot.generation);
|
retiredGenerations.push(slot.generation);
|
||||||
if (slot.bootTimer !== undefined) {
|
if (slot.bootTimer !== undefined) {
|
||||||
clearTimeout(slot.bootTimer);
|
clearTimeout(slot.bootTimer);
|
||||||
|
|
@ -412,6 +424,23 @@ export async function installNgspiceServiceStub(
|
||||||
const reject = slot.rejectBoot;
|
const reject = slot.rejectBoot;
|
||||||
slot.rejectBoot = undefined;
|
slot.rejectBoot = undefined;
|
||||||
reject?.(new Error(why));
|
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> => {
|
const ensureWorker = (): Promise<WorkerSlot> => {
|
||||||
|
|
@ -446,6 +475,7 @@ export async function installNgspiceServiceStub(
|
||||||
if (slot.failed || workerSlot !== slot) return;
|
if (slot.failed || workerSlot !== slot) return;
|
||||||
const data = e.data ?? {};
|
const data = e.data ?? {};
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
|
deliverTerminalEvents(data.pendingEvents);
|
||||||
failWorker(`event stream failure: ${String(data.fatal)}`);
|
failWorker(`event stream failure: ${String(data.fatal)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -555,6 +585,27 @@ export async function installNgspiceServiceStub(
|
||||||
// checkpoint can therefore never satisfy that simulation.
|
// checkpoint can therefore never satisfy that simulation.
|
||||||
if (disposed) return { error: 'ngspice service was disposed' };
|
if (disposed) return { error: 'ngspice service was disposed' };
|
||||||
const requestSequence = nextRequestSequence++;
|
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;
|
let slot: WorkerSlot;
|
||||||
try {
|
try {
|
||||||
slot = await ensureWorker();
|
slot = await ensureWorker();
|
||||||
|
|
@ -571,6 +622,15 @@ export async function installNgspiceServiceStub(
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
const res: any = await post(slot, req);
|
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({
|
publishRequestReceipt({
|
||||||
sequence: requestSequence,
|
sequence: requestSequence,
|
||||||
kind: req.kind,
|
kind: req.kind,
|
||||||
|
|
@ -588,15 +648,8 @@ export async function installNgspiceServiceStub(
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
if (workerSlot) retireWorker(workerSlot, 'ngspice service was disposed');
|
if (workerSlot) retireWorker(workerSlot, 'ngspice service was disposed');
|
||||||
for (const waiter of [...requestWaiters]) {
|
requestReceipts.drain('ngspice request receipt canceled by teardown');
|
||||||
rejectRequestWaiter(waiter, new Error('ngspice request receipt canceled by teardown'));
|
appliedReceipts.drain('ngspice applied-generation receipt canceled by teardown');
|
||||||
}
|
|
||||||
for (const waiter of [...appliedGenerationWaiters]) {
|
|
||||||
rejectAppliedGenerationWaiter(
|
|
||||||
waiter,
|
|
||||||
new Error('ngspice applied-generation receipt canceled by teardown'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ((globalThis as any).__pcbjamNgspiceFinalRefreshApplied
|
if ((globalThis as any).__pcbjamNgspiceFinalRefreshApplied
|
||||||
=== publishAppliedGeneration) {
|
=== publishAppliedGeneration) {
|
||||||
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = previousAppliedHook;
|
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = previousAppliedHook;
|
||||||
|
|
@ -623,6 +676,25 @@ export async function installNgspiceServiceStub(
|
||||||
throw new Error('pending threshold must be a positive safe integer');
|
throw new Error('pending threshold must be a positive safe integer');
|
||||||
runtimeMessageErrorThreshold = count;
|
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() {
|
snapshot() {
|
||||||
return {
|
return {
|
||||||
activeGeneration: workerSlot?.generation ?? null,
|
activeGeneration: workerSlot?.generation ?? null,
|
||||||
|
|
@ -632,9 +704,9 @@ export async function installNgspiceServiceStub(
|
||||||
bootFaultArmed: bootMessageErrorArmed,
|
bootFaultArmed: bootMessageErrorArmed,
|
||||||
runtimeFaultArmed: runtimeMessageErrorThreshold !== null,
|
runtimeFaultArmed: runtimeMessageErrorThreshold !== null,
|
||||||
lastRequestSequence: nextRequestSequence - 1,
|
lastRequestSequence: nextRequestSequence - 1,
|
||||||
requestReceiptWaiters: requestWaiters.size,
|
requestReceiptWaiters: requestReceipts.size(),
|
||||||
appliedGenerations: appliedGenerations.map((entry) => entry.generation),
|
appliedGenerations: appliedGenerations.map((entry) => entry.generation),
|
||||||
appliedGenerationWaiters: appliedGenerationWaiters.size,
|
appliedGenerationWaiters: appliedReceipts.size(),
|
||||||
disposed,
|
disposed,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,23 @@ const OCC_WORKER_SRC = fs.readFileSync(
|
||||||
'web', 'standalone', 'src', 'wasm', 'occ-worker.js'),
|
'web', 'standalone', 'src', 'wasm', 'occ-worker.js'),
|
||||||
'utf8');
|
'utf8');
|
||||||
|
|
||||||
export async function installOccServiceStub(page: Page): Promise<void> {
|
export interface OccHarnessWatchdogs {
|
||||||
await page.addInitScript((workerSrc: string) => {
|
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;
|
if ((globalThis as any).occService) return;
|
||||||
|
|
||||||
|
const { workerSrc, bootTimeoutMs } = options;
|
||||||
|
|
||||||
(window as any).__occExports = [];
|
(window as any).__occExports = [];
|
||||||
|
|
||||||
interface WorkerSlot {
|
interface WorkerSlot {
|
||||||
|
|
@ -50,6 +63,9 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
worker?: Worker;
|
worker?: Worker;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
ready: Promise<WorkerSlot>;
|
ready: Promise<WorkerSlot>;
|
||||||
|
bootTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
rejectBoot?: (reason?: unknown) => void;
|
||||||
|
removeBootListener?: () => void;
|
||||||
/** The exact lifecycle transition used by Worker.onmessageerror. */
|
/** The exact lifecycle transition used by Worker.onmessageerror. */
|
||||||
failDecode: () => void;
|
failDecode: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -67,11 +83,9 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
let requestsStarted = 0;
|
let requestsStarted = 0;
|
||||||
let requestsPosted = 0;
|
let requestsPosted = 0;
|
||||||
const workerGenerationsStarted: number[] = [];
|
const workerGenerationsStarted: number[] = [];
|
||||||
let armedFault: {
|
let armedFault: { count: number; report: string } | null = null;
|
||||||
count: number;
|
/** One-shot: the next boot uses a worker that never answers. */
|
||||||
kind: 'terminate' | 'messageerror';
|
let wedgeNextBootArmed: { bootTimeoutMs?: number } | null = null;
|
||||||
report: string;
|
|
||||||
} | null = null;
|
|
||||||
const retiredGenerations: number[] = [];
|
const retiredGenerations: number[] = [];
|
||||||
|
|
||||||
const pendingInGeneration = (generation: number): number => {
|
const pendingInGeneration = (generation: number): number => {
|
||||||
|
|
@ -94,6 +108,12 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
if (slot.failed) return;
|
if (slot.failed) return;
|
||||||
slot.failed = true;
|
slot.failed = true;
|
||||||
retiredGenerations.push(slot.generation);
|
retiredGenerations.push(slot.generation);
|
||||||
|
if (slot.bootTimer !== undefined) {
|
||||||
|
clearTimeout(slot.bootTimer);
|
||||||
|
slot.bootTimer = undefined;
|
||||||
|
}
|
||||||
|
slot.removeBootListener?.();
|
||||||
|
slot.removeBootListener = undefined;
|
||||||
failPending(slot.generation, report);
|
failPending(slot.generation, report);
|
||||||
if (workerSlot === slot) workerSlot = null;
|
if (workerSlot === slot) workerSlot = null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -101,21 +121,22 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
} catch {
|
} catch {
|
||||||
/* already gone */
|
/* already gone */
|
||||||
}
|
}
|
||||||
|
const reject = slot.rejectBoot;
|
||||||
|
slot.rejectBoot = undefined;
|
||||||
|
reject?.(new Error(report));
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeTriggerArmedFault = (slot: WorkerSlot): void => {
|
const maybeTriggerArmedFault = (slot: WorkerSlot): void => {
|
||||||
if (!armedFault || slot.failed) return;
|
if (!armedFault || slot.failed) return;
|
||||||
if (pendingInGeneration(slot.generation) < armedFault.count) return;
|
if (pendingInGeneration(slot.generation) < armedFault.count) return;
|
||||||
const { kind, report } = armedFault;
|
const { report } = armedFault;
|
||||||
armedFault = null;
|
armedFault = null;
|
||||||
console.log(`[TEST-OCC] faulting generation ${slot.generation} (${kind}): ${report}`);
|
console.log(`[TEST-OCC] faulting generation ${slot.generation} (messageerror): ${report}`);
|
||||||
if (kind === 'messageerror' && slot.worker) {
|
if (slot.worker) {
|
||||||
// Synthetic dispatch on Worker is engine-dependent. Invoke
|
// Synthetic dispatch on Worker is engine-dependent. Invoke
|
||||||
// the exact transition installed as the real event handler.
|
// the exact transition installed as the real event handler.
|
||||||
slot.failDecode();
|
slot.failDecode();
|
||||||
} else {
|
} else {
|
||||||
// Worker.terminate() intentionally emits no error event, so
|
|
||||||
// the hook supplies the fatal lifecycle transition explicitly.
|
|
||||||
retireWorker(slot, report);
|
retireWorker(slot, report);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -127,36 +148,53 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
failed: false,
|
failed: false,
|
||||||
} as WorkerSlot;
|
} as WorkerSlot;
|
||||||
workerGenerationsStarted.push(slot.generation);
|
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;
|
const glue = new URL('occ_service.js', window.location.href).href;
|
||||||
console.log(`[TEST-OCC] booting occ_service from ${glue}`);
|
console.log(`[TEST-OCC] booting occ_service from ${glue}`);
|
||||||
const worker = new Worker(URL.createObjectURL(new Blob(
|
// A wedged boot is a REAL silent Worker (an empty module:
|
||||||
[`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
|
// it boots, runs nothing, never posts ready/bootError) —
|
||||||
{ type: 'text/javascript' })));
|
// 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;
|
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) => {
|
worker.onerror = (e) => {
|
||||||
const report = `occ_service crashed: ${e.message || 'worker error'}`;
|
const report = `occ_service crashed: ${e.message || 'worker error'}`;
|
||||||
console.error(`[TEST-OCC] ${report}; resetting service`);
|
console.error(`[TEST-OCC] ${report}; resetting service`);
|
||||||
retireWorker(slot, report);
|
retireWorker(slot, report);
|
||||||
removeBootListener?.();
|
|
||||||
removeBootListener = undefined;
|
|
||||||
const reject = rejectBoot;
|
|
||||||
rejectBoot = undefined;
|
|
||||||
reject?.(new Error(report));
|
|
||||||
};
|
};
|
||||||
slot.failDecode = () => {
|
slot.failDecode = () => {
|
||||||
const report = 'occ_service transport failed: message decode failed';
|
const report = 'occ_service transport failed: message decode failed';
|
||||||
console.error(`[TEST-OCC] ${report}; resetting service`);
|
console.error(`[TEST-OCC] ${report}; resetting service`);
|
||||||
retireWorker(slot, report);
|
retireWorker(slot, report);
|
||||||
removeBootListener?.();
|
|
||||||
removeBootListener = undefined;
|
|
||||||
const reject = rejectBoot;
|
|
||||||
rejectBoot = undefined;
|
|
||||||
reject?.(new Error(report));
|
|
||||||
};
|
};
|
||||||
worker.onmessageerror = slot.failDecode;
|
worker.onmessageerror = slot.failDecode;
|
||||||
worker.onmessage = (e) => {
|
worker.onmessage = (e) => {
|
||||||
|
|
@ -169,38 +207,37 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
request.resolve(res);
|
request.resolve(res);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve) => {
|
||||||
rejectBoot = reject;
|
|
||||||
const onFirst = (e: MessageEvent) => {
|
const onFirst = (e: MessageEvent) => {
|
||||||
if (e.data?.ready) {
|
if (e.data?.ready) {
|
||||||
removeBootListener?.();
|
if (slot.bootTimer !== undefined) {
|
||||||
removeBootListener = undefined;
|
clearTimeout(slot.bootTimer);
|
||||||
rejectBoot = undefined;
|
slot.bootTimer = undefined;
|
||||||
|
}
|
||||||
|
slot.removeBootListener?.();
|
||||||
|
slot.removeBootListener = undefined;
|
||||||
|
slot.rejectBoot = undefined;
|
||||||
resolve();
|
resolve();
|
||||||
} else if (e.data?.bootError) {
|
} else if (e.data?.bootError) {
|
||||||
removeBootListener?.();
|
retireWorker(slot,
|
||||||
removeBootListener = undefined;
|
`occ_service boot failed: ${String(e.data.bootError)}`);
|
||||||
rejectBoot = undefined;
|
|
||||||
const report = `occ_service boot failed: ${String(e.data.bootError)}`;
|
|
||||||
retireWorker(slot, report);
|
|
||||||
reject(new Error(report));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
worker.addEventListener('message', onFirst);
|
worker.addEventListener('message', onFirst);
|
||||||
removeBootListener = () => worker.removeEventListener('message', onFirst);
|
slot.removeBootListener = () => worker.removeEventListener('message', onFirst);
|
||||||
});
|
});
|
||||||
if (slot.failed || workerSlot !== slot)
|
if (slot.failed || workerSlot !== slot)
|
||||||
throw new Error('occ_service worker retired during boot');
|
throw new Error('occ_service worker retired during boot');
|
||||||
console.log('[TEST-OCC] occ_service ready');
|
console.log('[TEST-OCC] occ_service ready');
|
||||||
return slot;
|
return slot;
|
||||||
})().catch((e) => {
|
})();
|
||||||
|
|
||||||
|
slot.ready = Promise.race([boot, bootDeadline]).catch((e) => {
|
||||||
// A late rejection from a retired generation cannot clear
|
// A late rejection from a retired generation cannot clear
|
||||||
// the replacement slot created by a new request.
|
// the replacement slot created by a new request.
|
||||||
retireWorker(slot, `occ_service unavailable: ${String(e)}`);
|
retireWorker(slot, `occ_service unavailable: ${String(e)}`);
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
workerSlot = slot;
|
|
||||||
}
|
}
|
||||||
return workerSlot.ready;
|
return workerSlot.ready;
|
||||||
};
|
};
|
||||||
|
|
@ -291,12 +328,13 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
};
|
};
|
||||||
|
|
||||||
(globalThis as any).__occServiceTestHooks = {
|
(globalThis as any).__occServiceTestHooks = {
|
||||||
/** Arm one deterministic host-side fault after N real posts. */
|
/** One-shot: wedge the next boot (silent worker, no ready and no
|
||||||
terminateWhenPendingAtLeast(count: number, report = 'occ_service test fault') {
|
* bootError), optionally shortening that boot's deadline. */
|
||||||
if (!Number.isSafeInteger(count) || count < 1)
|
wedgeNextBoot(bootTimeoutMs?: number) {
|
||||||
throw new Error('pending threshold must be a positive safe integer');
|
if (bootTimeoutMs !== undefined
|
||||||
armedFault = { count, kind: 'terminate', report };
|
&& (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1))
|
||||||
if (workerSlot) maybeTriggerArmedFault(workerSlot);
|
throw new Error('bootTimeoutMs must be a positive safe integer');
|
||||||
|
wedgeNextBootArmed = { bootTimeoutMs };
|
||||||
},
|
},
|
||||||
/** Arm the real Worker's production-parity messageerror handler. */
|
/** Arm the real Worker's production-parity messageerror handler. */
|
||||||
messageErrorWhenPendingAtLeast(count: number) {
|
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');
|
throw new Error('pending threshold must be a positive safe integer');
|
||||||
armedFault = {
|
armedFault = {
|
||||||
count,
|
count,
|
||||||
kind: 'messageerror',
|
|
||||||
report: 'occ_service transport failed: message decode failed',
|
report: 'occ_service transport failed: message decode failed',
|
||||||
};
|
};
|
||||||
if (workerSlot) maybeTriggerArmedFault(workerSlot);
|
if (workerSlot) maybeTriggerArmedFault(workerSlot);
|
||||||
|
|
@ -324,5 +361,5 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
||||||
};
|
};
|
||||||
|
|
||||||
(globalThis as any).occService = { request };
|
(globalThis as any).occService = { request };
|
||||||
}, OCC_WORKER_SRC);
|
}, { workerSrc: OCC_WORKER_SRC, bootTimeoutMs });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
tests/kicad/utils/sim-harness.ts
Normal file
133
tests/kicad/utils/sim-harness.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -39,8 +39,10 @@ export const BOOT_TIMEOUT = 150000;
|
||||||
* `(instances (project "trio" …))` entries must match this name. */
|
* `(instances (project "trio" …))` entries must match this name. */
|
||||||
export const TRIO_DOC = "trio";
|
export const TRIO_DOC = "trio";
|
||||||
|
|
||||||
|
import { hasNativeFailure } from "./native-failure";
|
||||||
|
|
||||||
export function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
|
export function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
|
||||||
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
|
return hasNativeFailure(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
98
tests/kicad/utils/wx-dialogs.ts
Normal file
98
tests/kicad/utils/wx-dialogs.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
"3d:test:webgl": "playwright test --project=wx-chromium e2e/3d-webgl.spec.ts",
|
"3d:test:webgl": "playwright test --project=wx-chromium e2e/3d-webgl.spec.ts",
|
||||||
"tools:contract": "tsx tools/cli-contract.ts",
|
"tools:contract": "tsx tools/cli-contract.ts",
|
||||||
"ngspice:worker-batch": "tsx tools/ngspice-worker-batch-unit.ts",
|
"ngspice:worker-batch": "tsx tools/ngspice-worker-batch-unit.ts",
|
||||||
"findings-e:contract": "tsx tools/findings-e-source-contract.ts"
|
"findings-e:contract": "tsx tools/findings-e-source-contract.ts",
|
||||||
|
"findings-e:parity": "tsx tools/service-stub-parity.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,64 @@ const simFrame = read("kicad/eeschema/sim/simulator_frame.cpp");
|
||||||
const ngspiceCpp = read("kicad/eeschema/sim/ngspice.cpp");
|
const ngspiceCpp = read("kicad/eeschema/sim/ngspice.cpp");
|
||||||
const shim = read("scripts/common/shims/jspi-scheduler.js");
|
const shim = read("scripts/common/shims/jspi-scheduler.js");
|
||||||
|
|
||||||
|
// --- structural #ifdef scanner ----------------------------------------------
|
||||||
|
// The wasm-only-confinement guarantees are asserted on CODE STRUCTURE (is this
|
||||||
|
// statement lexically inside an `__EMSCRIPTEN__`-conditioned region?), never on
|
||||||
|
// comment text: a comment-string contract fails on rewording with no behavior
|
||||||
|
// change and passes when the guard moves outside the ifdef but the comment
|
||||||
|
// stays — the exact regression it exists to catch.
|
||||||
|
type Cond = "em" | "not-em" | "other";
|
||||||
|
|
||||||
|
function emscriptenLineMap(src: string): boolean[] {
|
||||||
|
const stack: Cond[] = [];
|
||||||
|
return src.split("\n").map((line) => {
|
||||||
|
const t = line.trim();
|
||||||
|
let m: RegExpMatchArray | null;
|
||||||
|
if ((m = t.match(/^#\s*ifdef\s+(\w+)/))) {
|
||||||
|
stack.push(m[1] === "__EMSCRIPTEN__" ? "em" : "other");
|
||||||
|
} else if ((m = t.match(/^#\s*ifndef\s+(\w+)/))) {
|
||||||
|
stack.push(m[1] === "__EMSCRIPTEN__" ? "not-em" : "other");
|
||||||
|
} else if ((m = t.match(/^#\s*if\b(.*)/))) {
|
||||||
|
const cond = m[1];
|
||||||
|
const negated = /!\s*defined\s*\(?\s*__EMSCRIPTEN__/.test(cond);
|
||||||
|
const positive = /defined\s*\(?\s*__EMSCRIPTEN__/.test(cond) && !negated;
|
||||||
|
stack.push(positive ? "em" : negated ? "not-em" : "other");
|
||||||
|
} else if (/^#\s*(else|elif)\b/.test(t)) {
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (top === "em") stack[stack.length - 1] = "not-em";
|
||||||
|
else if (top === "not-em") stack[stack.length - 1] = "em";
|
||||||
|
} else if (/^#\s*endif\b/.test(t)) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
return stack.includes("em");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line indexes (0-based) of every occurrence of `needle` in `src`. */
|
||||||
|
function occurrenceLines(src: string, needle: string): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
src.split("\n").forEach((line, i) => {
|
||||||
|
if (line.includes(needle)) out.push(i);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOccurrences(
|
||||||
|
src: string,
|
||||||
|
map: boolean[],
|
||||||
|
needle: string,
|
||||||
|
expect: { total: number; insideEm: number },
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const lines = occurrenceLines(src, needle);
|
||||||
|
assert.equal(lines.length, expect.total,
|
||||||
|
`${label}: expected ${expect.total} occurrence(s) of "${needle}", found ${lines.length}`);
|
||||||
|
const inside = lines.filter((i) => map[i]).length;
|
||||||
|
assert.equal(inside, expect.insideEm,
|
||||||
|
`${label}: ${inside} of ${lines.length} occurrence(s) of "${needle}" are inside an `
|
||||||
|
+ `__EMSCRIPTEN__ region, expected ${expect.insideEm}`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- E-5: ngspice event handler bound to exact module identity --------------
|
// --- E-5: ngspice event handler bound to exact module identity --------------
|
||||||
assert.ok(sharedspice.includes("const installingModule = Module"),
|
assert.ok(sharedspice.includes("const installingModule = Module"),
|
||||||
"E-5: js_ngspice_install_events must capture the installing module");
|
"E-5: js_ngspice_install_events must capture the installing module");
|
||||||
|
|
@ -61,18 +119,82 @@ for (const symbol of ["runWaitCompletion", "_terminalizeNativeTrap",
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- E-7: per-session run generation, behavioral drops wasm-only ------------
|
// --- E-7: per-session run generation, behavioral drops wasm-only ------------
|
||||||
for (const token of ["s_nextSimRunGeneration", "allocateSimRunGeneration",
|
const simMap = emscriptenLineMap(simFrame);
|
||||||
"SetExtraLong", "m_lastAppliedSimRunGeneration"]) {
|
|
||||||
assert.ok(simFrame.includes(token),
|
// The acceptance guards (onSimStarted entry, onSimFinished entry, and the
|
||||||
`E-7: simulator_frame.cpp must carry the run-generation mechanism (${token})`);
|
// post-wxYield re-check) are the three `generation != m_simRunGeneration`
|
||||||
|
// comparisons — every one must sit inside an __EMSCRIPTEN__ region.
|
||||||
|
assertOccurrences(simFrame, simMap, "generation != m_simRunGeneration",
|
||||||
|
{ total: 3, insideEm: 3 }, "E-7 acceptance guards");
|
||||||
|
// The unowned-event drop.
|
||||||
|
assertOccurrences(simFrame, simMap, "delete event;",
|
||||||
|
{ total: 1, insideEm: 1 }, "E-7 unowned-event drop");
|
||||||
|
// The bookkeeping stays UNGUARDED by design (inert on native — every reader
|
||||||
|
// is guarded): the generation allocator and the event stamping.
|
||||||
|
assertOccurrences(simFrame, simMap, "= allocateSimRunGeneration()",
|
||||||
|
{ total: 1, insideEm: 0 }, "E-7 bookkeeping (allocator call)");
|
||||||
|
assertOccurrences(simFrame, simMap, "SetExtraLong",
|
||||||
|
{ total: 1, insideEm: 0 }, "E-7 bookkeeping (event stamping)");
|
||||||
|
assert.ok(simFrame.includes("s_nextSimRunGeneration")
|
||||||
|
&& simFrame.includes("m_lastAppliedSimRunGeneration"),
|
||||||
|
"E-7: simulator_frame.cpp must carry the run-generation mechanism");
|
||||||
|
|
||||||
|
// The final-refresh receipt lives at the right altitude: one ifdef'd call in
|
||||||
|
// kicad, the JS hook knowledge in the stub layer.
|
||||||
|
assertOccurrences(simFrame, simMap, "pcbjam_sim_run_applied( generation )",
|
||||||
|
{ total: 1, insideEm: 1 }, "E-7 receipt call");
|
||||||
|
assert.ok(!simFrame.includes("__pcbjamNgspiceFinalRefreshApplied"),
|
||||||
|
"E-7 REGRESSION: the harness hook name is back inside kicad source — it belongs "
|
||||||
|
+ "to wasm/stubs/sharedspice_client.cpp");
|
||||||
|
assert.ok(sharedspice.includes("__pcbjamNgspiceFinalRefreshApplied"),
|
||||||
|
"E-7: sharedspice_client.cpp must implement the final-refresh receipt hook");
|
||||||
|
|
||||||
|
// --- E-12: crash-exit IDLE before RUNNING consumes the pending token --------
|
||||||
|
// `generation = m_pendingRunGeneration.exchange` appears twice: the RUNNING
|
||||||
|
// consumption (unguarded bookkeeping) and the IDLE crash-exit fallback
|
||||||
|
// (behavioral — wasm-only).
|
||||||
|
assertOccurrences(simFrame, simMap, "generation = m_pendingRunGeneration.exchange",
|
||||||
|
{ total: 2, insideEm: 1 }, "E-12 IDLE pending fallback");
|
||||||
|
|
||||||
|
// --- E-13: a failed launch withdraws its token and resets the busy state ----
|
||||||
|
assertOccurrences(simFrame, simMap, "m_reporter->SetRunGeneration( 0 )",
|
||||||
|
{ total: 1, insideEm: 1 }, "E-13 failed-launch reset");
|
||||||
|
|
||||||
|
// --- E-11: get_vec clamps v_length to the transferred arrays + frees on fail -
|
||||||
|
assert.ok(sharedspice.includes("length = Math.min( length, nComp >> 1 )"),
|
||||||
|
"E-11: the vector prepare must clamp v_length to the transferred arrays");
|
||||||
|
assert.ok(/std::free\( vname \);\s*\n\s*std::free\( real \);\s*\n\s*std::free\( comp \);/
|
||||||
|
.test(sharedspice),
|
||||||
|
"E-11: pcbjam_ngGet_Vec_Info must free the prepare's buffers on every failure path");
|
||||||
|
|
||||||
|
// --- E-16: the event handler gates on its INSTALLING module's scheduler -----
|
||||||
|
assert.ok(sharedspice.includes("const installingScheduler = globalThis.__wxScheduler"),
|
||||||
|
"E-16: js_ngspice_install_events must capture the installing scheduler");
|
||||||
|
|
||||||
|
// --- E-15: every wxWasmBeginWait caller bails on a refused token -------------
|
||||||
|
// (The three worker stubs are asserted in the E-8 block above.)
|
||||||
|
for (const [rel, expectedBegins] of [
|
||||||
|
["wxwidgets/src/wasm/fontenum.cpp", 1],
|
||||||
|
["wxwidgets/src/wasm/clipbrd.cpp", 4],
|
||||||
|
["wxwidgets/src/wasm/dialog.cpp", 1],
|
||||||
|
["wxwidgets/src/wasm/evtloop.cpp", 1],
|
||||||
|
["kicad/3d-viewer/3d_cache/pcbjam_model_fetch.cpp", 1],
|
||||||
|
["kicad/pcbnew/pcb_io/pcbjam_fp/pcb_io_pcbjam_fp.cpp", 1],
|
||||||
|
["kicad/eeschema/sch_io/pcbjam_lib/sch_io_pcbjam_lib.cpp", 1],
|
||||||
|
] as const) {
|
||||||
|
const src = read(rel);
|
||||||
|
const begins = (src.match(/=\s*wxWasmBeginWait\s*\(/g) ?? []).length;
|
||||||
|
const guards = (src.match(/if\s*\(\s*(?:token|waitToken)\s*<=\s*0\s*\)/g) ?? []).length;
|
||||||
|
assert.equal(begins, expectedBegins,
|
||||||
|
`E-15: ${rel} should mint ${expectedBegins} wait token(s), found ${begins}`);
|
||||||
|
assert.ok(guards >= begins,
|
||||||
|
`E-15: ${rel} has ${begins} wxWasmBeginWait call(s) but only ${guards} `
|
||||||
|
+ "token<=0 guard(s) — a refused token must never start its request");
|
||||||
|
}
|
||||||
|
for (const symbol of ["terminalize", "resolveRefused"]) {
|
||||||
|
assert.ok(shim.includes(symbol),
|
||||||
|
`E-14/E-15: jspi-scheduler.js must provide ${symbol}`);
|
||||||
}
|
}
|
||||||
assert.equal(
|
|
||||||
(simFrame.match(/Generation acceptance is confined to the wasm build/g) ?? []).length, 2,
|
|
||||||
"E-7: both handler acceptance guards must be confined to the wasm build");
|
|
||||||
assert.ok(simFrame.includes("drop is confined to the wasm build"),
|
|
||||||
"E-7: the unowned-event drop must be confined to the wasm build");
|
|
||||||
assert.ok(simFrame.includes("Wasm-only, findings E-7"),
|
|
||||||
"E-7: the post-wxYield re-check must be confined to the wasm build");
|
|
||||||
|
|
||||||
// --- E-9: destructor unregisters the sharedspice callbacks ------------------
|
// --- E-9: destructor unregisters the sharedspice callbacks ------------------
|
||||||
assert.ok(/#ifdef __EMSCRIPTEN__[\s\S]{0,400}pcbjam_ngspice_reset_callbacks\( this \)/
|
assert.ok(/#ifdef __EMSCRIPTEN__[\s\S]{0,400}pcbjam_ngspice_reset_callbacks\( this \)/
|
||||||
|
|
|
||||||
57
tests/tools/lib/worker-constants.ts
Normal file
57
tests/tools/lib/worker-constants.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `const NAME = <numeric expr>;` constants out of a JS/TS source — the
|
||||||
|
* ONE source of truth for the ngspice transport numbers. The reducer and the
|
||||||
|
* parity tripwire derive their expectations from here instead of hardcoding
|
||||||
|
* copies that silently go stale when the protocol numbers move.
|
||||||
|
*/
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const repoRoot = path.resolve(here, "../../..");
|
||||||
|
|
||||||
|
export function readRepoFile(rel: string): string {
|
||||||
|
return readFileSync(path.join(repoRoot, rel), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConstants(
|
||||||
|
source: string,
|
||||||
|
names: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const name of names) {
|
||||||
|
const m = source.match(new RegExp(`const ${name}\\s*=\\s*([^;]+);`));
|
||||||
|
if (!m) throw new Error(`${label}: constant ${name} not found`);
|
||||||
|
const expr = m[1]!.trim();
|
||||||
|
// Strictly digits/arithmetic/underscores — anything else is rejected, so
|
||||||
|
// the Function() evaluation below can only compute a number, never run
|
||||||
|
// code from the scanned source.
|
||||||
|
if (!/^[\d\s*+\-()_]+$/.test(expr)) {
|
||||||
|
throw new Error(`${label}: constant ${name} is not a numeric expression: ${expr}`);
|
||||||
|
}
|
||||||
|
out[name] = Function(`"use strict"; return (${expr});`)() as number;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NGSPICE_WORKER_REL = "web/standalone/src/wasm/ngspice-worker.js";
|
||||||
|
|
||||||
|
export const NGSPICE_WORKER_CONSTANTS = [
|
||||||
|
"MAX_EVENT_BATCH_LINES",
|
||||||
|
"MAX_EVENT_BATCH_UTF8_BYTES",
|
||||||
|
"MAX_EVENT_UNACKED_FRAMES",
|
||||||
|
"MAX_EVENT_UNACKED_UTF8_BYTES",
|
||||||
|
"MAX_DEFERRED_EVENTS",
|
||||||
|
"MAX_DEFERRED_UTF8_BYTES",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** The production worker's transport constants, parsed from its source. */
|
||||||
|
export function ngspiceWorkerConstants(): Record<string, number> {
|
||||||
|
return parseConstants(
|
||||||
|
readRepoFile(NGSPICE_WORKER_REL),
|
||||||
|
NGSPICE_WORKER_CONSTANTS,
|
||||||
|
NGSPICE_WORKER_REL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,38 @@ const EXCLUDED_DIRS = new Set([
|
||||||
'scripts',
|
'scripts',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Non-playwright gates CI must keep invoking. This lint's per-spec model only
|
||||||
|
// understands "npm run test:*" playwright scripts; these gates (vitest for
|
||||||
|
// web/standalone, the ngspice transport reducer, and the findings-E source/
|
||||||
|
// parity contracts) live outside that model — so pin their literal workflow
|
||||||
|
// invocations here. Deleting a step from a workflow re-fails this lint,
|
||||||
|
// closing the exact "nothing runs it" rot class findings-E was about. (The
|
||||||
|
// vitest include glob auto-covers new *.test.ts files, so per-file coverage
|
||||||
|
// needs no proof.)
|
||||||
|
const NON_PLAYWRIGHT_GATES = [
|
||||||
|
'pnpm --filter @pcbjam/standalone test',
|
||||||
|
'npm run ngspice:worker-batch',
|
||||||
|
'npm run findings-e:contract',
|
||||||
|
'npm run findings-e:parity',
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertNonPlaywrightGates(): void {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
for (const f of fs.readdirSync(WORKFLOWS_DIR)) {
|
||||||
|
if (!/\.ya?ml$/.test(f)) continue;
|
||||||
|
bodies.push(fs.readFileSync(path.join(WORKFLOWS_DIR, f), 'utf8'));
|
||||||
|
}
|
||||||
|
const all = bodies.join('\n');
|
||||||
|
const missing = NON_PLAYWRIGHT_GATES.filter((cmd) => !all.includes(cmd));
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(
|
||||||
|
`non-playwright CI gate(s) missing from ${WORKFLOWS_DIR}: ` +
|
||||||
|
missing.map((m) => `"${m}"`).join(', ') +
|
||||||
|
' — a gate nothing invokes protects nothing'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 1. what CI invokes ────────────────────────────────────────────────────────
|
// ── 1. what CI invokes ────────────────────────────────────────────────────────
|
||||||
function ciTestScripts(): string[] {
|
function ciTestScripts(): string[] {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
@ -141,6 +173,8 @@ function specUniverse(dir = TESTS_ROOT, rel = ''): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── run ───────────────────────────────────────────────────────────────────────
|
// ── run ───────────────────────────────────────────────────────────────────────
|
||||||
|
assertNonPlaywrightGates();
|
||||||
|
|
||||||
const invocations = ciTestScripts().map(resolveScript);
|
const invocations = ciTestScripts().map(resolveScript);
|
||||||
|
|
||||||
const covered = new Set<string>();
|
const covered = new Set<string>();
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,25 @@ type Rule = {
|
||||||
|
|
||||||
const marker = (s: string) => /eslint-disable|documented|dwell/i.test(s);
|
const marker = (s: string) => /eslint-disable|documented|dwell/i.test(s);
|
||||||
|
|
||||||
|
// The canonical dwell marker (tests/TESTING.md) is
|
||||||
|
// `// eslint-disable-line -- documented interaction dwell: <why>`
|
||||||
|
// — a marker without the `: <why>` is a blind sleep wearing the uniform.
|
||||||
|
const DWELL_MARKER = /documented interaction dwell/;
|
||||||
|
const DWELL_MARKER_WITH_WHY = /documented interaction dwell:\s*\S/;
|
||||||
|
const bareDwellMarker = (s: string) => DWELL_MARKER.test(s) && !DWELL_MARKER_WITH_WHY.test(s);
|
||||||
|
|
||||||
const RULES: Rule[] = [
|
const RULES: Rule[] = [
|
||||||
{
|
{
|
||||||
name: 'no-blind-waitForTimeout',
|
name: 'no-blind-waitForTimeout',
|
||||||
message: 'blind waitForTimeout — use waitUntil/expect.poll/web-first assertion, or annotate a documented interaction dwell',
|
message: 'blind waitForTimeout — use waitUntil/expect.poll/web-first assertion, or annotate a documented interaction dwell',
|
||||||
hit: (line, prev) => /\.waitForTimeout\s*\(/.test(line) && !marker(line) && !marker(prev),
|
hit: (line, prev) => /\.waitForTimeout\s*\(/.test(line) && !marker(line) && !marker(prev),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'dwell-marker-needs-why',
|
||||||
|
message: 'dwell marker without its reason — the mandated form is `// eslint-disable-line -- documented interaction dwell: <why>` (tests/TESTING.md)',
|
||||||
|
hit: (line, prev) => /\.waitForTimeout\s*\(/.test(line)
|
||||||
|
&& (bareDwellMarker(line) || (!DWELL_MARKER.test(line) && bareDwellMarker(prev))),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'no-toHaveScreenshot',
|
name: 'no-toHaveScreenshot',
|
||||||
message: 'toHaveScreenshot does inline pixel comparison — use stableShot() (offline gate)',
|
message: 'toHaveScreenshot does inline pixel comparison — use stableShot() (offline gate)',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { ngspiceWorkerConstants } from "./lib/worker-constants.js";
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repo = path.resolve(here, "../..");
|
const repo = path.resolve(here, "../..");
|
||||||
|
|
@ -16,10 +17,19 @@ const workerSource = readFileSync(
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The transport numbers, parsed from the PRODUCTION worker source — the
|
||||||
|
// reducer must never hardcode copies that go stale when the protocol moves.
|
||||||
|
const C = ngspiceWorkerConstants();
|
||||||
|
const BATCH_LINES = C.MAX_EVENT_BATCH_LINES!;
|
||||||
|
const BATCH_BYTES = C.MAX_EVENT_BATCH_UTF8_BYTES!;
|
||||||
|
const WINDOW_FRAMES = C.MAX_EVENT_UNACKED_FRAMES!;
|
||||||
|
const WINDOW_BYTES = C.MAX_EVENT_UNACKED_UTF8_BYTES!;
|
||||||
|
|
||||||
type Frame = {
|
type Frame = {
|
||||||
id?: number;
|
id?: number;
|
||||||
evt?: { kind: string; lines?: string[]; finished?: boolean };
|
evt?: { kind: string; lines?: string[]; finished?: boolean };
|
||||||
fatal?: string;
|
fatal?: string;
|
||||||
|
pendingEvents?: Array<{ evt?: { kind: string; lines?: string[] }; eventBytes?: number }>;
|
||||||
res?: { error?: string };
|
res?: { error?: string };
|
||||||
eventSequence?: number;
|
eventSequence?: number;
|
||||||
eventBytes?: number;
|
eventBytes?: number;
|
||||||
|
|
@ -31,7 +41,9 @@ type WorkerHarness = {
|
||||||
message(data: unknown): Promise<void>;
|
message(data: unknown): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createWorkerHarness(): Promise<WorkerHarness> {
|
async function createWorkerHarness(
|
||||||
|
moduleOverrides: Record<string, any> = {},
|
||||||
|
): Promise<WorkerHarness> {
|
||||||
const frames: Frame[] = [];
|
const frames: Frame[] = [];
|
||||||
let messageHandler!: (event: { data: unknown }) => Promise<void>;
|
let messageHandler!: (event: { data: unknown }) => Promise<void>;
|
||||||
const module: Record<string, any> = {
|
const module: Record<string, any> = {
|
||||||
|
|
@ -44,6 +56,7 @@ async function createWorkerHarness(): Promise<WorkerHarness> {
|
||||||
allVecs: () => [],
|
allVecs: () => [],
|
||||||
running: () => false,
|
running: () => false,
|
||||||
cmInputPath: () => undefined,
|
cmInputPath: () => undefined,
|
||||||
|
...moduleOverrides,
|
||||||
};
|
};
|
||||||
const workerGlobal: Record<string, unknown> = {
|
const workerGlobal: Record<string, unknown> = {
|
||||||
NGSPICE_GLUE_URL: "https://pcbjam.test/ngspice_service.js",
|
NGSPICE_GLUE_URL: "https://pcbjam.test/ngspice_service.js",
|
||||||
|
|
@ -63,6 +76,7 @@ async function createWorkerHarness(): Promise<WorkerHarness> {
|
||||||
Map,
|
Map,
|
||||||
JSON,
|
JSON,
|
||||||
Promise,
|
Promise,
|
||||||
|
TextEncoder,
|
||||||
queueMicrotask,
|
queueMicrotask,
|
||||||
importScripts: () => undefined,
|
importScripts: () => undefined,
|
||||||
NgspiceService: () => Promise.resolve(module),
|
NgspiceService: () => Promise.resolve(module),
|
||||||
|
|
@ -96,21 +110,23 @@ async function acknowledgeAll(harness: WorkerHarness): Promise<void> {
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const bounded = await createWorkerHarness();
|
const bounded = await createWorkerHarness();
|
||||||
// 1,025 synchronous lines cannot wait for a microtask: 512, 512 flush on the
|
// Two full batches plus one synchronous line cannot wait for a microtask:
|
||||||
// line bound, and the final line flushes at the queued microtask.
|
// both flush on the line bound, and the final line flushes at the queued
|
||||||
for (let i = 0; i < 1_025; ++i) bounded.emit(0, `line-${i}`, 0, 0);
|
// microtask.
|
||||||
|
const floodLines = 2 * BATCH_LINES + 1;
|
||||||
|
for (let i = 0; i < floodLines; ++i) bounded.emit(0, `line-${i}`, 0, 0);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
bounded.frames.map((frame) => frame.evt?.lines?.length),
|
bounded.frames.map((frame) => frame.evt?.lines?.length),
|
||||||
[512, 512],
|
[BATCH_LINES, BATCH_LINES],
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
bounded.frames.map((frame) => frame.evt?.lines?.length),
|
bounded.frames.map((frame) => frame.evt?.lines?.length),
|
||||||
[512, 512, 1],
|
[BATCH_LINES, BATCH_LINES, 1],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
bounded.frames.flatMap((frame) => frame.evt?.lines ?? []),
|
bounded.frames.flatMap((frame) => frame.evt?.lines ?? []),
|
||||||
Array.from({ length: 1_025 }, (_, i) => `line-${i}`),
|
Array.from({ length: floodLines }, (_, i) => `line-${i}`),
|
||||||
);
|
);
|
||||||
await acknowledgeAll(bounded);
|
await acknowledgeAll(bounded);
|
||||||
console.log("ok synchronous output flushes in bounded ordered line chunks");
|
console.log("ok synchronous output flushes in bounded ordered line chunks");
|
||||||
|
|
@ -125,10 +141,10 @@ assert.deepEqual(
|
||||||
bounded.frames[0]!.evt!.lines!.map((line) => line.at(-1)),
|
bounded.frames[0]!.evt!.lines!.map((line) => line.at(-1)),
|
||||||
["0", "1"],
|
["0", "1"],
|
||||||
);
|
);
|
||||||
assert.ok(bounded.frames[0]!.eventBytes! <= 1024 * 1024);
|
assert.ok(bounded.frames[0]!.eventBytes! <= BATCH_BYTES);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(bounded.frames[1]!.evt!.lines!.length, 1);
|
assert.equal(bounded.frames[1]!.evt!.lines!.length, 1);
|
||||||
assert.ok(bounded.frames[1]!.eventBytes! <= 1024 * 1024);
|
assert.ok(bounded.frames[1]!.eventBytes! <= BATCH_BYTES);
|
||||||
await acknowledgeAll(bounded);
|
await acknowledgeAll(bounded);
|
||||||
console.log("ok UTF-8 byte pressure flushes before retaining the crossing line");
|
console.log("ok UTF-8 byte pressure flushes before retaining the crossing line");
|
||||||
|
|
||||||
|
|
@ -144,10 +160,10 @@ for (let i = 0; i < 100_000; ++i) {
|
||||||
}
|
}
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const stormEvents = storm.frames.filter((frame) => frame.evt);
|
const stormEvents = storm.frames.filter((frame) => frame.evt);
|
||||||
assert.ok(stormEvents.length <= 64);
|
assert.ok(stormEvents.length <= WINDOW_FRAMES);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
stormEvents.reduce((sum, frame) => sum + frame.eventBytes!, 0)
|
stormEvents.reduce((sum, frame) => sum + frame.eventBytes!, 0)
|
||||||
<= 8 * 1024 * 1024,
|
<= WINDOW_BYTES,
|
||||||
);
|
);
|
||||||
assert.equal(storm.frames.filter((frame) => frame.fatal).length, 1);
|
assert.equal(storm.frames.filter((frame) => frame.fatal).length, 1);
|
||||||
assert.match(storm.frames.find((frame) => frame.fatal)!.fatal!, /deferred/);
|
assert.match(storm.frames.find((frame) => frame.fatal)!.fatal!, /deferred/);
|
||||||
|
|
@ -160,44 +176,119 @@ console.log("ok 100,000 synchronous chunk attempts cannot exceed transport cre
|
||||||
// window behind (observed as "event transport exceeded 64 frames" ending the
|
// window behind (observed as "event transport exceeded 64 frames" ending the
|
||||||
// eeschema second-run spec).
|
// eeschema second-run spec).
|
||||||
const paced = await createWorkerHarness();
|
const paced = await createWorkerHarness();
|
||||||
for (let i = 0; i < 80; ++i) {
|
const pacedTotal = WINDOW_FRAMES + 16;
|
||||||
|
for (let i = 0; i < pacedTotal; ++i) {
|
||||||
paced.emit(2, "", i % 2, 0); // bg toggles: one frame per emit, no batching
|
paced.emit(2, "", i % 2, 0); // bg toggles: one frame per emit, no batching
|
||||||
}
|
}
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(paced.frames.filter((f) => f.fatal).length, 0,
|
assert.equal(paced.frames.filter((f) => f.fatal).length, 0,
|
||||||
"a full window with a live consumer must not be terminal");
|
"a full window with a live consumer must not be terminal");
|
||||||
assert.equal(paced.frames.filter((f) => f.evt).length, 64,
|
assert.equal(paced.frames.filter((f) => f.evt).length, WINDOW_FRAMES,
|
||||||
"exactly the credit window is in flight");
|
"exactly the credit window is in flight");
|
||||||
await acknowledgeAll(paced);
|
await acknowledgeAll(paced);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const pacedEvents = paced.frames.filter((f) => f.evt);
|
const pacedEvents = paced.frames.filter((f) => f.evt);
|
||||||
assert.equal(pacedEvents.length, 80, "deferred frames drained after acks");
|
assert.equal(pacedEvents.length, pacedTotal, "deferred frames drained after acks");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
pacedEvents.map((f) => f.evt!.finished),
|
pacedEvents.map((f) => f.evt!.finished),
|
||||||
Array.from({ length: 80 }, (_, i) => !(i % 2 === 0)),
|
Array.from({ length: pacedTotal }, (_, i) => !(i % 2 === 0)),
|
||||||
"deferred frames preserve emission order",
|
"deferred frames preserve emission order",
|
||||||
);
|
);
|
||||||
console.log("ok a full credit window defers and drains in order, never terminal");
|
console.log("ok a full credit window defers and drains in order, never terminal");
|
||||||
|
|
||||||
const oversize = await createWorkerHarness();
|
const oversize = await createWorkerHarness();
|
||||||
|
const oversizeFatal = `ngspice event line exceeds ${BATCH_BYTES} UTF-8 bytes`;
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => oversize.emit(0, "y".repeat(1024 * 1024), 0, 0),
|
() => oversize.emit(0, "y".repeat(BATCH_BYTES), 0, 0),
|
||||||
/ngspice event line exceeds 1048576 UTF-8 bytes/,
|
new RegExp(oversizeFatal),
|
||||||
);
|
);
|
||||||
assert.deepEqual(oversize.frames, [{
|
assert.deepEqual(oversize.frames, [{
|
||||||
fatal: "ngspice event line exceeds 1048576 UTF-8 bytes",
|
fatal: oversizeFatal,
|
||||||
|
pendingEvents: [],
|
||||||
}]);
|
}]);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => oversize.emit(0, "late", 0, 0),
|
() => oversize.emit(0, "late", 0, 0),
|
||||||
/ngspice event line exceeds 1048576 UTF-8 bytes/,
|
new RegExp(oversizeFatal),
|
||||||
);
|
);
|
||||||
await oversize.message({ id: 91, req: { kind: "running" } });
|
await oversize.message({ id: 91, req: { kind: "running" } });
|
||||||
assert.deepEqual(oversize.frames.at(-1), {
|
assert.deepEqual(oversize.frames.at(-1), {
|
||||||
id: 91,
|
id: 91,
|
||||||
res: { error: "ngspice event line exceeds 1048576 UTF-8 bytes" },
|
res: { error: oversizeFatal },
|
||||||
});
|
});
|
||||||
console.log("ok a single over-limit line is never retained and terminalizes requests");
|
console.log("ok a single over-limit line is never retained and terminalizes requests");
|
||||||
|
|
||||||
|
// E-20: the oversize-line path promises "every earlier line was accepted …
|
||||||
|
// transfer it before refusing this line". With the credit window FULL, that
|
||||||
|
// flush can only DEFER — the terminal stop must ship the deferred frames
|
||||||
|
// inside the fatal notice instead of wiping them (they are typically the last
|
||||||
|
// diagnostics explaining why the run died).
|
||||||
|
const prefixed = await createWorkerHarness();
|
||||||
|
for (let i = 0; i < WINDOW_FRAMES; ++i) prefixed.emit(2, "", i % 2, 0); // fill the window
|
||||||
|
prefixed.emit(0, "accepted-1", 0, 0);
|
||||||
|
prefixed.emit(0, "accepted-2", 0, 0);
|
||||||
|
prefixed.emit(0, "accepted-3", 0, 0); // open batch, flush still queued
|
||||||
|
assert.throws(
|
||||||
|
() => prefixed.emit(0, "y".repeat(BATCH_BYTES), 0, 0),
|
||||||
|
new RegExp(oversizeFatal),
|
||||||
|
);
|
||||||
|
const terminalNotice = prefixed.frames.find((frame) => frame.fatal);
|
||||||
|
assert.ok(terminalNotice, "terminal notice posted");
|
||||||
|
const deliveredLines = [
|
||||||
|
...prefixed.frames.flatMap((frame) => frame.evt?.lines ?? []),
|
||||||
|
...(terminalNotice!.pendingEvents ?? [])
|
||||||
|
.flatMap((entry) => entry.evt?.lines ?? []),
|
||||||
|
];
|
||||||
|
for (const line of ["accepted-1", "accepted-2", "accepted-3"]) {
|
||||||
|
assert.ok(deliveredLines.includes(line),
|
||||||
|
`accepted line "${line}" must reach the host despite the terminal stop`);
|
||||||
|
}
|
||||||
|
console.log("ok the accepted prefix survives a terminal stop under a full window");
|
||||||
|
|
||||||
|
// E-10 recovery: a REPLACEMENT worker serves engine reads before its first
|
||||||
|
// init (the editor's crash-recovery finish pulls vectors right after a
|
||||||
|
// worker death). An uninitialized engine traps on those entries — and a
|
||||||
|
// trapped engine then hangs later requests, parking the finish chain. The
|
||||||
|
// worker must answer the empty shapes itself, never touching the engine.
|
||||||
|
const engineTrap = () => {
|
||||||
|
throw new Error("RuntimeError: indirect call to null (uninitialized engine)");
|
||||||
|
};
|
||||||
|
const preInit = await createWorkerHarness({
|
||||||
|
getVecInfo: engineTrap, curPlot: engineTrap, allPlots: engineTrap,
|
||||||
|
allVecs: engineTrap, running: engineTrap,
|
||||||
|
});
|
||||||
|
await preInit.message({ id: 1, req: { kind: "get_vec_info", name: "time" } });
|
||||||
|
assert.deepEqual(preInit.frames.at(-1), { id: 1, res: { found: false } });
|
||||||
|
await preInit.message({ id: 2, req: { kind: "cur_plot" } });
|
||||||
|
assert.deepEqual(preInit.frames.at(-1), { id: 2, res: { name: "" } });
|
||||||
|
await preInit.message({ id: 3, req: { kind: "all_plots" } });
|
||||||
|
assert.deepEqual(preInit.frames.at(-1), { id: 3, res: { names: [] } });
|
||||||
|
await preInit.message({ id: 4, req: { kind: "running" } });
|
||||||
|
assert.deepEqual(preInit.frames.at(-1), { id: 4, res: { running: false } });
|
||||||
|
// After init, reads reach the engine again (the trapping fake IS called).
|
||||||
|
await preInit.message({ id: 5, req: { kind: "init" } });
|
||||||
|
assert.deepEqual(preInit.frames.at(-1), { id: 5, res: { ret: 0 } });
|
||||||
|
await preInit.message({ id: 6, req: { kind: "get_vec_info", name: "time" } });
|
||||||
|
assert.match(
|
||||||
|
(preInit.frames.at(-1) as { res?: { error?: string } }).res!.error!,
|
||||||
|
/uninitialized engine/,
|
||||||
|
);
|
||||||
|
console.log("ok engine reads answer their empty shapes before the first init");
|
||||||
|
|
||||||
|
// …and engine WRITES lazy-init the fresh engine (the editor issues
|
||||||
|
// cm_input_path/circ before its validate() re-init), with the init request
|
||||||
|
// idempotent per worker engine.
|
||||||
|
let lazyInits = 0;
|
||||||
|
const lazy = await createWorkerHarness({
|
||||||
|
init: () => { lazyInits++; return 0; },
|
||||||
|
});
|
||||||
|
await lazy.message({ id: 1, req: { kind: "circ", lines: ["*", ".end"] } });
|
||||||
|
assert.equal(lazyInits, 1, "first write initialized the engine");
|
||||||
|
assert.deepEqual(lazy.frames.at(-1), { id: 1, res: { ret: 0 } });
|
||||||
|
await lazy.message({ id: 2, req: { kind: "init" } });
|
||||||
|
assert.equal(lazyInits, 1, "init is idempotent per worker engine");
|
||||||
|
assert.deepEqual(lazy.frames.at(-1), { id: 2, res: { ret: 0 } });
|
||||||
|
console.log("ok engine writes lazy-init a fresh engine; init is idempotent");
|
||||||
|
|
||||||
const ackMismatch = await createWorkerHarness();
|
const ackMismatch = await createWorkerHarness();
|
||||||
ackMismatch.emit(2, "", 0, 0);
|
ackMismatch.emit(2, "", 0, 0);
|
||||||
const credited = ackMismatch.frames[0]!;
|
const credited = ackMismatch.frames[0]!;
|
||||||
|
|
|
||||||
94
tests/tools/service-stub-parity.ts
Normal file
94
tests/tools/service-stub-parity.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* Stub/production parity tripwire (findings group E guardrail). The e2e
|
||||||
|
* harness drives hand-maintained MIRRORS of the worker services
|
||||||
|
* (tests/kicad/utils/{ngspice,occ}-service.ts) while production ships
|
||||||
|
* web/standalone/src/wasm/{ngspice,occ}-service.ts — a fix landed in one copy
|
||||||
|
* and not the other silently invalidates what the browser specs claim to
|
||||||
|
* prove. Until the copies collapse into one shared lifecycle module (the
|
||||||
|
* deferred refactor), this tool pins the load-bearing invariants both sides
|
||||||
|
* must share, parsing ACTUAL VALUES — never comment text.
|
||||||
|
* Run: npm run findings-e:parity
|
||||||
|
*/
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import {
|
||||||
|
ngspiceWorkerConstants,
|
||||||
|
parseConstants,
|
||||||
|
readRepoFile,
|
||||||
|
} from "./lib/worker-constants.js";
|
||||||
|
|
||||||
|
const prodNgspice = readRepoFile("web/standalone/src/wasm/ngspice-service.ts");
|
||||||
|
const prodOcc = readRepoFile("web/standalone/src/wasm/occ-service.ts");
|
||||||
|
const stubNgspice = readRepoFile("tests/kicad/utils/ngspice-service.ts");
|
||||||
|
const stubOcc = readRepoFile("tests/kicad/utils/occ-service.ts");
|
||||||
|
const bootTs = readRepoFile("web/standalone/src/wasm/boot.ts");
|
||||||
|
const workerJs = readRepoFile("web/standalone/src/wasm/ngspice-worker.js");
|
||||||
|
|
||||||
|
// --- credit window: worker ≡ production host ≡ harness stub -----------------
|
||||||
|
// The host queue caps must EQUAL the worker's credit window or the protocol
|
||||||
|
// retires healthy workers ("event-frame queue exceeded credit").
|
||||||
|
const worker = ngspiceWorkerConstants();
|
||||||
|
const hostCaps = ["MAX_QUEUED_EVENT_FRAMES", "MAX_QUEUED_EVENT_BYTES"] as const;
|
||||||
|
const prodCaps = parseConstants(prodNgspice, hostCaps, "production ngspice-service.ts");
|
||||||
|
const stubCaps = parseConstants(stubNgspice, hostCaps, "stub ngspice-service.ts");
|
||||||
|
assert.equal(prodCaps.MAX_QUEUED_EVENT_FRAMES, worker.MAX_EVENT_UNACKED_FRAMES,
|
||||||
|
"credit window FRAMES: production host must equal the worker");
|
||||||
|
assert.equal(prodCaps.MAX_QUEUED_EVENT_BYTES, worker.MAX_EVENT_UNACKED_UTF8_BYTES,
|
||||||
|
"credit window BYTES: production host must equal the worker");
|
||||||
|
assert.deepEqual(stubCaps, prodCaps,
|
||||||
|
"credit window: the harness stub must equal the production host");
|
||||||
|
|
||||||
|
// --- E-19: the frame ack survives a throwing handler (both copies) ----------
|
||||||
|
for (const [label, src] of [
|
||||||
|
["production ngspice-service.ts", prodNgspice],
|
||||||
|
["stub ngspice-service.ts", stubNgspice],
|
||||||
|
] as const) {
|
||||||
|
const start = src.indexOf("const dispatchEvt");
|
||||||
|
const end = src.indexOf("deliverTerminalEvents", start);
|
||||||
|
assert.ok(start >= 0 && end > start, `${label}: dispatchEvt body not found`);
|
||||||
|
const body = src.slice(start, end);
|
||||||
|
assert.ok(/finally\s*\{[\s\S]{0,80}?ackEvent\(/.test(body),
|
||||||
|
`E-19 REGRESSION (${label}): dispatchEvt must ack the owned frame in a `
|
||||||
|
+ "finally — a throwing handler leaked one credit unit per throw");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- E-20: the terminal notice's pendingEvents are consumed (both copies) ---
|
||||||
|
for (const [label, src] of [
|
||||||
|
["production ngspice-service.ts", prodNgspice],
|
||||||
|
["stub ngspice-service.ts", stubNgspice],
|
||||||
|
] as const) {
|
||||||
|
assert.ok(src.includes("deliverTerminalEvents(data.pendingEvents)"),
|
||||||
|
`E-20 (${label}): the fatal branch must deliver the worker's accepted-`
|
||||||
|
+ "prefix pendingEvents before retiring");
|
||||||
|
}
|
||||||
|
assert.ok(workerJs.includes("postMessage({ fatal: reason, pendingEvents })"),
|
||||||
|
"E-20 (ngspice-worker.js): the terminal notice must carry the deferred frames");
|
||||||
|
|
||||||
|
// --- E-10: retirement synthesizes the controlled exit (both copies) ---------
|
||||||
|
assert.ok(/kind: "exit", status: 1, immediate: true, quit: false/.test(prodNgspice),
|
||||||
|
"E-10 (production): retireWorker must synthesize the controlled exit");
|
||||||
|
assert.ok(/kind: 'exit', status: 1, immediate: true, quit: false/.test(stubNgspice),
|
||||||
|
"E-10 (stub): retireWorker must synthesize the controlled exit");
|
||||||
|
|
||||||
|
// --- E-10 recovery: the worker guards pre-init engine access ----------------
|
||||||
|
assert.ok(workerJs.includes("let engineReady") && workerJs.includes("ensureEngine("),
|
||||||
|
"E-10 (ngspice-worker.js): pre-init reads must answer empty shapes and "
|
||||||
|
+ "writes must lazy-init the fresh engine");
|
||||||
|
|
||||||
|
// --- E-22 / E-1: a boot deadline exists in all four lifecycle copies --------
|
||||||
|
for (const [label, src] of [
|
||||||
|
["production ngspice-service.ts", prodNgspice],
|
||||||
|
["production occ-service.ts", prodOcc],
|
||||||
|
["stub ngspice-service.ts", stubNgspice],
|
||||||
|
["stub occ-service.ts", stubOcc],
|
||||||
|
] as const) {
|
||||||
|
assert.ok(src.includes("bootTimer") && src.includes("boot timed out after"),
|
||||||
|
`E-22 REGRESSION (${label}): the boot deadline is gone — a wedged worker `
|
||||||
|
+ "boot hangs every request with zero evidence");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- E-14: boot wires Module.onAbort to the scheduler's terminal latch ------
|
||||||
|
assert.ok(/onAbort[\s\S]{0,600}?terminalize\?\.\(\s*"emscripten abort"/.test(bootTs),
|
||||||
|
"E-14 (boot.ts): Module.onAbort must latch __wxScheduler.terminalize — the "
|
||||||
|
+ "authoritative abort notification");
|
||||||
|
|
||||||
|
console.log("service-stub-parity: all green");
|
||||||
|
|
@ -36,7 +36,7 @@ async function clickTreeRow(page: Page, label: string): Promise<boolean> {
|
||||||
}, label);
|
}, label);
|
||||||
if (!hit) return false;
|
if (!hit) return false;
|
||||||
await page.mouse.click(hit.x, hit.y);
|
await page.mouse.click(hit.x, hit.y);
|
||||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: tree-row selection commit; no observable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ test.fixme(
|
||||||
|
|
||||||
// New Footprint → auto-saves into the writable lib (tryToSaveFootprintInLibrary).
|
// New Footprint → auto-saves into the writable lib (tryToSaveFootprintInLibrary).
|
||||||
expect(await clickByTooltip(page, 'New Footprint'), 'New Footprint clicked').toBe(true);
|
expect(await clickByTooltip(page, 'New Footprint'), 'New Footprint clicked').toBe(true);
|
||||||
await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell: New Footprint dialog/creation commit
|
||||||
// Belt-and-braces explicit save.
|
// Belt-and-braces explicit save.
|
||||||
await focusCanvas(page);
|
await focusCanvas(page);
|
||||||
await page.keyboard.press('Control+s');
|
await page.keyboard.press('Control+s');
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,13 @@ test.fixme(
|
||||||
});
|
});
|
||||||
expect(hdr, 'Item column header found').not.toBeNull();
|
expect(hdr, 'Item column header found').not.toBeNull();
|
||||||
await page.mouse.click(hdr!.cx, hdr!.cy + hdr!.hgt + 8);
|
await page.mouse.click(hdr!.cx, hdr!.cy + hdr!.hgt + 8);
|
||||||
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: tree focus commit
|
||||||
await page.keyboard.press('Home');
|
await page.keyboard.press('Home');
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit
|
||||||
await page.keyboard.press('ArrowDown');
|
await page.keyboard.press('ArrowDown');
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit
|
||||||
await page.keyboard.press('ArrowUp');
|
await page.keyboard.press('ArrowUp');
|
||||||
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: tree selection commit
|
||||||
|
|
||||||
expect(await clickByTooltip(page, 'New Symbol...'), 'New Symbol clicked').toBe(true);
|
expect(await clickByTooltip(page, 'New Symbol...'), 'New Symbol clicked').toBe(true);
|
||||||
await stableShot(page, 'symremote-02-newsym.png');
|
await stableShot(page, 'symremote-02-newsym.png');
|
||||||
|
|
@ -77,7 +77,7 @@ test.fixme(
|
||||||
});
|
});
|
||||||
expect(nameField, 'New Symbol name field present').toBeTruthy();
|
expect(nameField, 'New Symbol name field present').toBeTruthy();
|
||||||
await page.mouse.click(nameField!.cx, nameField!.cy);
|
await page.mouse.click(nameField!.cx, nameField!.cy);
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: name field focus commit
|
||||||
await page.keyboard.press('Control+a');
|
await page.keyboard.press('Control+a');
|
||||||
await page.keyboard.press('Delete');
|
await page.keyboard.press('Delete');
|
||||||
await page.keyboard.type('RemoteRes', { delay: 40 });
|
await page.keyboard.type('RemoteRes', { delay: 40 });
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,13 @@ test.fixme(
|
||||||
// Click focuses the tree row but doesn't always select it; drive the keyboard
|
// Click focuses the tree row but doesn't always select it; drive the keyboard
|
||||||
// to make the first (only) library row the SELECTED item (GetTargetLibId).
|
// to make the first (only) library row the SELECTED item (GetTargetLibId).
|
||||||
await page.mouse.click(hdr!.cx, hdr!.cy + hdr!.hgt + 8); // focus the tree
|
await page.mouse.click(hdr!.cx, hdr!.cy + hdr!.hgt + 8); // focus the tree
|
||||||
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: tree focus commit
|
||||||
await page.keyboard.press('Home');
|
await page.keyboard.press('Home');
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit
|
||||||
await page.keyboard.press('ArrowDown');
|
await page.keyboard.press('ArrowDown');
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit
|
||||||
await page.keyboard.press('ArrowUp');
|
await page.keyboard.press('ArrowUp');
|
||||||
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: tree selection commit
|
||||||
await stableShot(page, 'symwrite-02-lib-selected.png');
|
await stableShot(page, 'symwrite-02-lib-selected.png');
|
||||||
|
|
||||||
// New Symbol via the toolbar button (tooltip), proven-clickable in the harness.
|
// New Symbol via the toolbar button (tooltip), proven-clickable in the harness.
|
||||||
|
|
@ -91,11 +91,11 @@ test.fixme(
|
||||||
const nameField = dlg.texts.find((t) => Math.abs(t.cy - 65) > 30 && Math.abs(t.cy - 87) > 30);
|
const nameField = dlg.texts.find((t) => Math.abs(t.cy - 65) > 30 && Math.abs(t.cy - 87) > 30);
|
||||||
expect(nameField, 'New Symbol name field present').toBeTruthy();
|
expect(nameField, 'New Symbol name field present').toBeTruthy();
|
||||||
await page.mouse.click(nameField!.cx, nameField!.cy);
|
await page.mouse.click(nameField!.cx, nameField!.cy);
|
||||||
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: name field focus commit
|
||||||
await page.keyboard.press('Control+a');
|
await page.keyboard.press('Control+a');
|
||||||
await page.keyboard.press('Delete');
|
await page.keyboard.press('Delete');
|
||||||
await page.keyboard.type(SYM, { delay: 40 });
|
await page.keyboard.type(SYM, { delay: 40 });
|
||||||
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-name registration commit
|
||||||
await stableShot(page, 'symwrite-04-name-typed.png');
|
await stableShot(page, 'symwrite-04-name-typed.png');
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await waitUntil(
|
await waitUntil(
|
||||||
|
|
@ -139,7 +139,7 @@ test.fixme(
|
||||||
expect(body).toContain(`(symbol "${savedName}"`);
|
expect(body).toContain(`(symbol "${savedName}"`);
|
||||||
|
|
||||||
// No post-save error dialog (the placeholder-file fix for GetModificationTime).
|
// No post-save error dialog (the placeholder-file fix for GetModificationTime).
|
||||||
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell
|
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: negative-assert window for a post-save error dialog
|
||||||
const errDialog = await page.evaluate(() =>
|
const errDialog = await page.evaluate(() =>
|
||||||
window.wxElementRegistry
|
window.wxElementRegistry
|
||||||
.findAll({ visible: true })
|
.findAll({ visible: true })
|
||||||
|
|
|
||||||
|
|
@ -120,14 +120,26 @@ EM_JS( void, js_ngspice_get_vec_start,
|
||||||
return 0;
|
return 0;
|
||||||
HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0;
|
HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0;
|
||||||
HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0;
|
HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0;
|
||||||
HEAP32[( aMeta >> 2 ) + 3] = res.length | 0;
|
// E-11: v_length must describe what was actually TRANSFERRED,
|
||||||
if( res.real && res.real.length ) {
|
// never the worker's self-reported count — a corrupted worker
|
||||||
const p = _malloc( res.real.length * 8 );
|
// answering a huge length with small arrays otherwise drives the
|
||||||
|
// native consumer through a multi-gigabyte copy (observed dying
|
||||||
|
// as an unhandled std::length_error that exits the main loop).
|
||||||
|
// Interleaved re,im doubles: 2 per complex element.
|
||||||
|
let length = Math.max( 0, res.length | 0 );
|
||||||
|
const nReal = ( res.real && res.real.length ) | 0;
|
||||||
|
const nComp = ( res.comp && res.comp.length ) | 0;
|
||||||
|
if( nReal ) length = Math.min( length, nReal );
|
||||||
|
if( nComp ) length = Math.min( length, nComp >> 1 );
|
||||||
|
if( !nReal && !nComp ) length = 0;
|
||||||
|
HEAP32[( aMeta >> 2 ) + 3] = length;
|
||||||
|
if( nReal ) {
|
||||||
|
const p = _malloc( nReal * 8 );
|
||||||
HEAPF64.set( res.real, p >> 3 );
|
HEAPF64.set( res.real, p >> 3 );
|
||||||
HEAPU32[aReal >> 2] = p;
|
HEAPU32[aReal >> 2] = p;
|
||||||
}
|
}
|
||||||
if( res.comp && res.comp.length ) {
|
if( nComp ) {
|
||||||
const p = _malloc( res.comp.length * 8 );
|
const p = _malloc( nComp * 8 );
|
||||||
HEAPF64.set( res.comp, p >> 3 );
|
HEAPF64.set( res.comp, p >> 3 );
|
||||||
HEAPU32[aComp >> 2] = p;
|
HEAPU32[aComp >> 2] = p;
|
||||||
}
|
}
|
||||||
|
|
@ -159,13 +171,19 @@ extern "C" int wxWasmYieldUntil( int aToken );
|
||||||
// handler, and a superseded handler disarms itself.
|
// handler, and a superseded handler disarms itself.
|
||||||
EM_JS( void, js_ngspice_install_events, (), {
|
EM_JS( void, js_ngspice_install_events, (), {
|
||||||
const installingModule = Module;
|
const installingModule = Module;
|
||||||
|
// E-16: capture the installing module's SCHEDULER too — the liveness gate
|
||||||
|
// and trap latch below must describe the exact instance this handler
|
||||||
|
// drives, not whatever scheduler the realm holds at dispatch time (under
|
||||||
|
// same-realm module replacement the realm-global would belong to the
|
||||||
|
// successor).
|
||||||
|
const installingScheduler = globalThis.__wxScheduler;
|
||||||
const installed = globalThis.__ngspiceOnEvent;
|
const installed = globalThis.__ngspiceOnEvent;
|
||||||
if( installed && installed.__pcbjamNgspiceOwnerModule === installingModule )
|
if( installed && installed.__pcbjamNgspiceOwnerModule === installingModule )
|
||||||
return;
|
return;
|
||||||
const handler = ( evt ) => {
|
const handler = ( evt ) => {
|
||||||
if( globalThis.__ngspiceOnEvent !== handler )
|
if( globalThis.__ngspiceOnEvent !== handler )
|
||||||
return; // superseded install — never drive a retired module
|
return; // superseded install — never drive a retired module
|
||||||
const sched = globalThis.__wxScheduler;
|
const sched = installingScheduler;
|
||||||
if( !sched || !sched.canTouchNative || !sched.canTouchNative() ) {
|
if( !sched || !sched.canTouchNative || !sched.canTouchNative() ) {
|
||||||
// E-8/M-2: a dead or terminal instance takes no native entry; the
|
// E-8/M-2: a dead or terminal instance takes no native entry; the
|
||||||
// drop is loud, never silent.
|
// drop is loud, never silent.
|
||||||
|
|
@ -173,6 +191,11 @@ EM_JS( void, js_ngspice_install_events, (), {
|
||||||
+ 'dead/terminal module' );
|
+ 'dead/terminal module' );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// E-16: a plain-JS throw between the malloc and the native entry
|
||||||
|
// leaks the line buffer — track it so the non-trap rethrow path can
|
||||||
|
// free it (never free on the trap path: freeing re-enters a trapped
|
||||||
|
// module).
|
||||||
|
let pendingText = 0;
|
||||||
const call = ( kind, text, a, b ) => {
|
const call = ( kind, text, a, b ) => {
|
||||||
let p = 0;
|
let p = 0;
|
||||||
if( text != null ) {
|
if( text != null ) {
|
||||||
|
|
@ -185,7 +208,9 @@ EM_JS( void, js_ngspice_install_events, (), {
|
||||||
p = _malloc( n );
|
p = _malloc( n );
|
||||||
stringToUTF8( text, p, n );
|
stringToUTF8( text, p, n );
|
||||||
}
|
}
|
||||||
|
pendingText = p;
|
||||||
installingModule._pcbjam_ngspice_event( kind, p, a | 0, b | 0 );
|
installingModule._pcbjam_ngspice_event( kind, p, a | 0, b | 0 );
|
||||||
|
pendingText = 0; // the native entry freed it
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if( evt.kind === 'char' || evt.kind === 'stat' ) {
|
if( evt.kind === 'char' || evt.kind === 'stat' ) {
|
||||||
|
|
@ -201,8 +226,11 @@ EM_JS( void, js_ngspice_install_events, (), {
|
||||||
// A trap on this fresh entry poisons the instance: latch the
|
// A trap on this fresh entry poisons the instance: latch the
|
||||||
// terminal gate so no later completion re-enters it.
|
// terminal gate so no later completion re-enters it.
|
||||||
if( !sched._terminalizeNativeTrap
|
if( !sched._terminalizeNativeTrap
|
||||||
|| !sched._terminalizeNativeTrap( 'ngspice event entry', e ) )
|
|| !sched._terminalizeNativeTrap( 'ngspice event entry', e ) ) {
|
||||||
|
if( pendingText )
|
||||||
|
_free( pendingText );
|
||||||
throw e;
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
handler.__pcbjamNgspiceOwnerModule = installingModule;
|
handler.__pcbjamNgspiceOwnerModule = installingModule;
|
||||||
|
|
@ -428,6 +456,33 @@ extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_ngspice_reset_callbacks( void* aUser
|
||||||
s_user = nullptr;
|
s_user = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E-7: the browser harness's final-refresh receipt — called by
|
||||||
|
// SIMULATOR_FRAME::onSimFinished after every final native refresh (one
|
||||||
|
// ifdef'd line there; the JS-side knowledge lives HERE, in the stub layer).
|
||||||
|
// Optional test evidence, no mainline behavior.
|
||||||
|
// clang-format off
|
||||||
|
EM_JS( void, js_ngspice_sim_run_applied, ( uint32_t aGeneration ), {
|
||||||
|
const hook = globalThis.__pcbjamNgspiceFinalRefreshApplied;
|
||||||
|
|
||||||
|
if( typeof hook === 'function' )
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hook( aGeneration >>> 0 );
|
||||||
|
}
|
||||||
|
catch( error )
|
||||||
|
{
|
||||||
|
console.error( '[ngspice] final-refresh hook failed', error );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
// clang-format on
|
||||||
|
|
||||||
|
extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_sim_run_applied( uint32_t aGeneration )
|
||||||
|
{
|
||||||
|
js_ngspice_sim_run_applied( aGeneration );
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// The sharedspice API surface NGSPICE::init_dll binds to
|
// The sharedspice API surface NGSPICE::init_dll binds to
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -506,11 +561,17 @@ pvector_info pcbjam_ngGet_Vec_Info( char* aVecName )
|
||||||
|
|
||||||
js_ngspice_get_vec_start( token, aVecName ? aVecName : "", meta, &real, &comp, &vname );
|
js_ngspice_get_vec_start( token, aVecName ? aVecName : "", meta, &real, &comp, &vname );
|
||||||
|
|
||||||
if( wxWasmYieldUntil( token ) != 0 )
|
// E-11: a plain-JS throw mid-prepare (after some mallocs landed) resolves
|
||||||
return nullptr;
|
// the inertResult without adopting the buffers into the arena — free
|
||||||
|
// whatever was written on EVERY failure path (free(nullptr) is a no-op,
|
||||||
if( !meta[0] )
|
// so this covers all partial orderings).
|
||||||
|
if( wxWasmYieldUntil( token ) != 0 || !meta[0] )
|
||||||
|
{
|
||||||
|
std::free( vname );
|
||||||
|
std::free( real );
|
||||||
|
std::free( comp );
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
s_name = vname;
|
s_name = vname;
|
||||||
s_real = real;
|
s_real = real;
|
||||||
|
|
|
||||||
|
|
@ -679,6 +679,13 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
||||||
onAbort: (what: unknown) => {
|
onAbort: (what: unknown) => {
|
||||||
const msg = what === undefined ? "" : String(what);
|
const msg = what === undefined ? "" : String(what);
|
||||||
log(`[boot] abort: ${msg}`);
|
log(`[boot] abort: ${msg}`);
|
||||||
|
// Authoritative trap notification: latch the scheduler's terminal gate
|
||||||
|
// so no parked frame resumes into the aborted instance (E-8/E-14).
|
||||||
|
// Safe w.r.t. recovery — oom-watch recovers via a full page reload,
|
||||||
|
// never an in-realm module replacement.
|
||||||
|
(globalThis as {
|
||||||
|
__wxScheduler?: { terminalize?: (site: string, e?: unknown) => void };
|
||||||
|
}).__wxScheduler?.terminalize?.("emscripten abort", msg);
|
||||||
onAbort?.(msg);
|
onAbort?.(msg);
|
||||||
},
|
},
|
||||||
monitorRunDependencies: () => {},
|
monitorRunDependencies: () => {},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
runDeferredModelPrescan,
|
runDeferredModelPrescan,
|
||||||
normalizeModelRef,
|
normalizeModelRef,
|
||||||
scanModelRefs,
|
scanModelRefs,
|
||||||
|
type BoardModelFile,
|
||||||
} from "./models-bridge";
|
} from "./models-bridge";
|
||||||
import type { Model3dSource } from "./models-source";
|
import type { Model3dSource } from "./models-source";
|
||||||
|
|
||||||
|
|
@ -172,6 +173,104 @@ describe("collectBoardModelFiles", () => {
|
||||||
expect(source.getModelBody).toHaveBeenCalledTimes(1);
|
expect(source.getModelBody).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("feeds the caller's progress sink as models are accepted", async () => {
|
||||||
|
// E-21: the prefetch caller reads the sink synchronously at its timeout —
|
||||||
|
// the accepted models must be there the moment they are accepted, and the
|
||||||
|
// scan total as soon as it is known.
|
||||||
|
(globalThis as unknown as { window: unknown }).window ??= globalThis;
|
||||||
|
let resolveSecond!: (body: Uint8Array | null) => void;
|
||||||
|
const source: Model3dSource = {
|
||||||
|
getModelBody: vi.fn((ref: string) => {
|
||||||
|
if (ref.startsWith("SinkA")) {
|
||||||
|
return Promise.resolve(new TextEncoder().encode(`body:${ref}`));
|
||||||
|
}
|
||||||
|
return new Promise<Uint8Array | null>((resolve) => {
|
||||||
|
resolveSecond = resolve;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
hasModel: async () => true,
|
||||||
|
};
|
||||||
|
installModel3dHandler(source, () => {});
|
||||||
|
const progress = { totalRefs: 0, models: [] as BoardModelFile[] };
|
||||||
|
const collection = collectBoardModelFiles(
|
||||||
|
'(model "SinkA.3dshapes/A.step")\n(model "SinkB.3dshapes/B.step")',
|
||||||
|
1,
|
||||||
|
undefined,
|
||||||
|
progress,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(progress.models).toHaveLength(1));
|
||||||
|
expect(progress.totalRefs, "scan total known up front").toBe(2);
|
||||||
|
expect(progress.models[0]!.path).toBe("SinkA.3dshapes/A.step");
|
||||||
|
|
||||||
|
resolveSecond(new TextEncoder().encode("body:SinkB.3dshapes/B.step"));
|
||||||
|
const models = await collection;
|
||||||
|
expect(models, "the sink IS the result array").toBe(progress.models);
|
||||||
|
expect(models).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gains no sink entries after abort (in-flight result stays inert)", async () => {
|
||||||
|
// E-4 barrier, restated through the sink: an aborted collection may not
|
||||||
|
// retain a body that resolves after the abort — not in its result, and
|
||||||
|
// not in the caller's progress sink either.
|
||||||
|
(globalThis as unknown as { window: unknown }).window ??= globalThis;
|
||||||
|
let resolveFirst!: (body: Uint8Array | null) => void;
|
||||||
|
const source: Model3dSource = {
|
||||||
|
getModelBody: vi.fn(
|
||||||
|
() => new Promise<Uint8Array | null>((resolve) => {
|
||||||
|
resolveFirst = resolve;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
hasModel: async () => true,
|
||||||
|
};
|
||||||
|
installModel3dHandler(source, () => {});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const retired = new Error("exact OCC prefetch retired");
|
||||||
|
const progress = { totalRefs: 0, models: [] as BoardModelFile[] };
|
||||||
|
const collection = collectBoardModelFiles(
|
||||||
|
'(model "SinkAbortA.3dshapes/A.step")\n'
|
||||||
|
+ '(model "SinkAbortB.3dshapes/B.step")',
|
||||||
|
1,
|
||||||
|
controller.signal,
|
||||||
|
progress,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(source.getModelBody).toHaveBeenCalledTimes(1));
|
||||||
|
controller.abort(retired);
|
||||||
|
resolveFirst(new Uint8Array([1, 2, 3]));
|
||||||
|
|
||||||
|
await expect(collection).rejects.toBe(retired);
|
||||||
|
expect(progress.totalRefs).toBe(2);
|
||||||
|
expect(progress.models, "no post-abort retention through the sink").toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remembers the serving fallback candidate across collects (no re-probes)", async () => {
|
||||||
|
// E-21 memo: a .wrl ref served by its .step fallback re-probed the missing
|
||||||
|
// .wrl on every export (IDB + network round-trips). The serving candidate
|
||||||
|
// is remembered per ref — positive results only, no body caching.
|
||||||
|
(globalThis as unknown as { window: unknown }).window ??= globalThis;
|
||||||
|
const getModelBody = vi.fn(async (ref: string) =>
|
||||||
|
ref.endsWith(".step") ? new TextEncoder().encode(`body:${ref}`) : null);
|
||||||
|
const source: Model3dSource = { getModelBody, hasModel: async () => true };
|
||||||
|
installModel3dHandler(source, () => {});
|
||||||
|
const board = '(model "${KICAD10_3DMODEL_DIR}/MemoLib.3dshapes/M1.wrl")';
|
||||||
|
|
||||||
|
await collectBoardModelFiles(board);
|
||||||
|
expect(getModelBody.mock.calls.map((call) => call[0]),
|
||||||
|
"first collect probes the .wrl miss then the .step hit").toEqual([
|
||||||
|
"MemoLib.3dshapes/M1.wrl",
|
||||||
|
"MemoLib.3dshapes/M1.step",
|
||||||
|
]);
|
||||||
|
|
||||||
|
getModelBody.mockClear();
|
||||||
|
const models = await collectBoardModelFiles(board);
|
||||||
|
expect(getModelBody.mock.calls.map((call) => call[0]),
|
||||||
|
"second collect goes straight to the remembered candidate").toEqual([
|
||||||
|
"MemoLib.3dshapes/M1.step",
|
||||||
|
]);
|
||||||
|
expect(models[0]!.path).toBe("MemoLib.3dshapes/M1.step");
|
||||||
|
});
|
||||||
|
|
||||||
it("never touches the editor MEMFS (pure source/IDB/network path)", async () => {
|
it("never touches the editor MEMFS (pure source/IDB/network path)", async () => {
|
||||||
// repro for E-4: routing bodies through the editor heap added a stale
|
// repro for E-4: routing bodies through the editor heap added a stale
|
||||||
// native-completion tail after a prefetch timeout — the collect path must
|
// native-completion tail after a prefetch timeout — the collect path must
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ const materialized = new Map<string, string>();
|
||||||
/** In-flight ensures, coalesced per ref (prescan and the C++ fallback race). */
|
/** In-flight ensures, coalesced per ref (prescan and the C++ fallback race). */
|
||||||
const ensuring = new Map<string, Promise<string | null>>();
|
const ensuring = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which fallback candidate served each ref on the pure-source collect path
|
||||||
|
* (positive results only). Kills the repeated failed-candidate probes — a
|
||||||
|
* `.wrl` ref served by its `.step` fallback re-probed the missing `.wrl` on
|
||||||
|
* every export — without caching bodies (the source's IDB layer does that) and
|
||||||
|
* without touching the editor MEMFS. Cleared on source replacement.
|
||||||
|
*/
|
||||||
|
const servingCandidate = new Map<string, string>();
|
||||||
|
|
||||||
/** Wire the model source used by the provider dispatch + prescan. */
|
/** Wire the model source used by the provider dispatch + prescan. */
|
||||||
export function installModel3dHandler(
|
export function installModel3dHandler(
|
||||||
source: Model3dSource,
|
source: Model3dSource,
|
||||||
|
|
@ -98,6 +107,7 @@ export function installModel3dHandler(
|
||||||
): void {
|
): void {
|
||||||
installedSource = source;
|
installedSource = source;
|
||||||
installedLog = log;
|
installedLog = log;
|
||||||
|
servingCandidate.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch one model body and write it under MODELS_3D_ROOT. Resolves to the
|
/** Fetch one model body and write it under MODELS_3D_ROOT. Resolves to the
|
||||||
|
|
@ -194,6 +204,18 @@ export interface BoardModelFile {
|
||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller-owned progress sink for collectBoardModelFiles: `models` receives
|
||||||
|
* each accepted body the moment it is accepted, `totalRefs` is set as soon as
|
||||||
|
* the board scan completes. A caller that abandons the collection (prefetch
|
||||||
|
* timeout) reads the partial set synchronously — awaiting the collector after
|
||||||
|
* abort would be unbounded (an in-flight source fetch is not abortable).
|
||||||
|
*/
|
||||||
|
export interface CollectProgress {
|
||||||
|
totalRefs: number;
|
||||||
|
models: BoardModelFile[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch every lib model a board references for an occ_service export. This is
|
* Fetch every lib model a board references for an occ_service export. This is
|
||||||
* deliberately a pure source/IDB/network path (E-4): the OCC worker has a
|
* deliberately a pure source/IDB/network path (E-4): the OCC worker has a
|
||||||
|
|
@ -209,8 +231,10 @@ export async function collectBoardModelFiles(
|
||||||
boardText: string,
|
boardText: string,
|
||||||
concurrency = 6,
|
concurrency = 6,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
progress?: CollectProgress,
|
||||||
): Promise<BoardModelFile[]> {
|
): Promise<BoardModelFile[]> {
|
||||||
if (!installedSource) return [];
|
const out: BoardModelFile[] = progress?.models ?? [];
|
||||||
|
if (!installedSource) return out;
|
||||||
const source = installedSource;
|
const source = installedSource;
|
||||||
const isCurrent = () => source === installedSource;
|
const isCurrent = () => source === installedSource;
|
||||||
const throwIfAborted = (): void => {
|
const throwIfAborted = (): void => {
|
||||||
|
|
@ -219,9 +243,9 @@ export async function collectBoardModelFiles(
|
||||||
};
|
};
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
const refs = scanModelRefs(boardText);
|
const refs = scanModelRefs(boardText);
|
||||||
if (!refs.length) return [];
|
if (progress) progress.totalRefs = refs.length;
|
||||||
|
if (!refs.length) return out;
|
||||||
|
|
||||||
const out: BoardModelFile[] = [];
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
const worker = async (): Promise<void> => {
|
const worker = async (): Promise<void> => {
|
||||||
|
|
@ -229,7 +253,14 @@ export async function collectBoardModelFiles(
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
if (!isCurrent() || idx >= refs.length) return;
|
if (!isCurrent() || idx >= refs.length) return;
|
||||||
const ref = refs[idx++]!;
|
const ref = refs[idx++]!;
|
||||||
for (const candidate of refCandidates(ref)) {
|
// The remembered serving candidate goes first (skips re-probing
|
||||||
|
// fallbacks that failed on an earlier export); the rest stay as backup
|
||||||
|
// in case it can no longer serve (IDB eviction).
|
||||||
|
const remembered = servingCandidate.get(ref);
|
||||||
|
const candidates = remembered
|
||||||
|
? [remembered, ...refCandidates(ref).filter((c) => c !== remembered)]
|
||||||
|
: refCandidates(ref);
|
||||||
|
for (const candidate of candidates) {
|
||||||
let body: Uint8Array | null = null;
|
let body: Uint8Array | null = null;
|
||||||
try {
|
try {
|
||||||
body = await source.getModelBody(candidate);
|
body = await source.getModelBody(candidate);
|
||||||
|
|
@ -241,6 +272,7 @@ export async function collectBoardModelFiles(
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
if (!body) continue;
|
if (!body) continue;
|
||||||
|
|
||||||
|
servingCandidate.set(ref, candidate);
|
||||||
if (!seen.has(candidate)) {
|
if (!seen.has(candidate)) {
|
||||||
seen.add(candidate);
|
seen.add(candidate);
|
||||||
// The OCC worker receives this buffer as a transferable. Keep its
|
// The OCC worker receives this buffer as a transferable. Keep its
|
||||||
|
|
|
||||||
|
|
@ -7,55 +7,18 @@ vi.mock("./wasm-assets", () => ({
|
||||||
|
|
||||||
import {
|
import {
|
||||||
installNgspiceService,
|
installNgspiceService,
|
||||||
|
type NgspiceEvent,
|
||||||
type NgspiceRequest,
|
type NgspiceRequest,
|
||||||
type NgspiceResponse,
|
type NgspiceResponse,
|
||||||
} from "./ngspice-service";
|
} from "./ngspice-service";
|
||||||
import { resolveWasmBase } from "./wasm-assets";
|
import { resolveWasmBase } from "./wasm-assets";
|
||||||
|
import { FakeWorker, waitForWorker } from "./test-utils/fake-worker";
|
||||||
|
|
||||||
const mockedResolveWasmBase = vi.mocked(resolveWasmBase);
|
const mockedResolveWasmBase = vi.mocked(resolveWasmBase);
|
||||||
|
|
||||||
const TEST_BOOT_TIMEOUT_MS = 1_000;
|
const TEST_BOOT_TIMEOUT_MS = 1_000;
|
||||||
const TEST_RESPONSE_TIMEOUT_MS = 5_000;
|
const TEST_RESPONSE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
type MessageListener = (event: MessageEvent) => void;
|
|
||||||
|
|
||||||
class FakeWorker {
|
|
||||||
static instances: FakeWorker[] = [];
|
|
||||||
|
|
||||||
onmessage: MessageListener | null = null;
|
|
||||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
|
||||||
onmessageerror: ((event: MessageEvent) => void) | null = null;
|
|
||||||
readonly postMessage = vi.fn();
|
|
||||||
readonly terminate = vi.fn();
|
|
||||||
private readonly messageListeners = new Set<MessageListener>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
FakeWorker.instances.push(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
|
||||||
if (type === "message") this.messageListeners.add(listener as MessageListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
|
||||||
if (type === "message") this.messageListeners.delete(listener as MessageListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitMessage(data: unknown): void {
|
|
||||||
const event = { data } as MessageEvent;
|
|
||||||
this.onmessage?.(event);
|
|
||||||
for (const listener of [...this.messageListeners]) listener(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitError(message: string): void {
|
|
||||||
this.onerror?.({ message } as ErrorEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitMessageError(): void {
|
|
||||||
this.onmessageerror?.({} as MessageEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandRequest = (cmd = "run"): NgspiceRequest => ({ kind: "command", cmd });
|
const commandRequest = (cmd = "run"): NgspiceRequest => ({ kind: "command", cmd });
|
||||||
|
|
||||||
const service = () => {
|
const service = () => {
|
||||||
|
|
@ -64,11 +27,6 @@ const service = () => {
|
||||||
return installed;
|
return installed;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function waitForWorker(index: number): Promise<FakeWorker> {
|
|
||||||
await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index));
|
|
||||||
return FakeWorker.instances[index]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readyRequest(
|
async function readyRequest(
|
||||||
workerIndex: number,
|
workerIndex: number,
|
||||||
requestBody: NgspiceRequest = commandRequest(),
|
requestBody: NgspiceRequest = commandRequest(),
|
||||||
|
|
@ -454,6 +412,142 @@ describe("ngspice service worker lifetime", () => {
|
||||||
await expect(first.request).resolves.toEqual({ ret: 0 });
|
await expect(first.request).resolves.toEqual({ ret: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("acks a frame whose handler throws — one throw must not leak credit", async () => {
|
||||||
|
// E-19: the host takes transport ownership at onmessage; the sharedspice
|
||||||
|
// client deliberately rethrows non-trap errors, and each throw that
|
||||||
|
// escaped before the ack leaked one unit of the worker's 64-frame credit
|
||||||
|
// window until the stream died with a misattributed overload.
|
||||||
|
const first = await readyRequest(0);
|
||||||
|
globalThis.__ngspiceOnEvent = () => {
|
||||||
|
throw new Error("plot apply bug");
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => first.worker.emitMessage({
|
||||||
|
evt: { kind: "char", lines: ["boom"] },
|
||||||
|
eventSequence: 1,
|
||||||
|
eventBytes: 32,
|
||||||
|
}), "the handler throw keeps propagating (trap machinery must see it)")
|
||||||
|
.toThrow("plot apply bug");
|
||||||
|
|
||||||
|
const acks = () => first.worker.postMessage.mock.calls
|
||||||
|
.map((call) => (call[0] as { eventAck?: { sequence: number; bytes: number } }).eventAck)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(acks(), "the frame is acked despite the throwing handler")
|
||||||
|
.toEqual([{ sequence: 1, bytes: 32 }]);
|
||||||
|
|
||||||
|
// The stream stays live: a later frame delivers and acks normally.
|
||||||
|
const events: string[] = [];
|
||||||
|
globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind);
|
||||||
|
first.worker.emitMessage({
|
||||||
|
evt: { kind: "char", lines: ["after"] },
|
||||||
|
eventSequence: 2,
|
||||||
|
eventBytes: 33,
|
||||||
|
});
|
||||||
|
expect(events).toEqual(["after"]);
|
||||||
|
expect(acks()).toEqual([
|
||||||
|
{ sequence: 1, bytes: 32 },
|
||||||
|
{ sequence: 2, bytes: 33 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
first.worker.emitMessage({ id: first.id, res: { ret: 0 } });
|
||||||
|
await expect(first.request).resolves.toEqual({ ret: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acks the live frame exactly once when the queued-frame drain throws", async () => {
|
||||||
|
// E-19, drain path: a throw while draining the pre-handler queue aborts
|
||||||
|
// delivery, but the live frame's credit was owned at onmessage — its ack
|
||||||
|
// must still go out, and the queued frame (acked at enqueue) not twice.
|
||||||
|
const first = await readyRequest(0);
|
||||||
|
first.worker.emitMessage({
|
||||||
|
evt: { kind: "char", lines: ["early"] },
|
||||||
|
eventSequence: 1,
|
||||||
|
eventBytes: 33,
|
||||||
|
});
|
||||||
|
globalThis.__ngspiceOnEvent = (event) => {
|
||||||
|
if (event.lines?.[0] === "early") throw new Error("drain bug");
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => first.worker.emitMessage({
|
||||||
|
evt: { kind: "char", lines: ["live"] },
|
||||||
|
eventSequence: 2,
|
||||||
|
eventBytes: 32,
|
||||||
|
})).toThrow("drain bug");
|
||||||
|
|
||||||
|
const acks = first.worker.postMessage.mock.calls
|
||||||
|
.map((call) => (call[0] as { eventAck?: { sequence: number; bytes: number } }).eventAck)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(acks, "exactly one ack per owned frame, none doubled").toEqual([
|
||||||
|
{ sequence: 1, bytes: 33 },
|
||||||
|
{ sequence: 2, bytes: 32 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
first.worker.emitMessage({ id: first.id, res: { ret: 0 } });
|
||||||
|
await expect(first.request).resolves.toEqual({ ret: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers the terminal notice's pending events, then retires, then exits", async () => {
|
||||||
|
// E-20: the worker's fatal frame carries the deferred batches it had
|
||||||
|
// already accepted (typically the diagnostics explaining the failure) —
|
||||||
|
// they must reach the handler, in order, without acks; the retirement's
|
||||||
|
// synthetic controlled-exit (E-10) follows them.
|
||||||
|
const first = await readyRequest(0);
|
||||||
|
const events: string[] = [];
|
||||||
|
globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind);
|
||||||
|
|
||||||
|
first.worker.emitMessage({
|
||||||
|
fatal: "ngspice event line exceeds 1048576 UTF-8 bytes",
|
||||||
|
pendingEvents: [
|
||||||
|
{ evt: { kind: "char", lines: ["tail diagnostics"] }, eventBytes: 42 },
|
||||||
|
{ evt: { kind: "bg", finished: true }, eventBytes: 30 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(first.request).resolves.toEqual({
|
||||||
|
error: "ngspice_service crashed: event stream failure: "
|
||||||
|
+ "ngspice event line exceeds 1048576 UTF-8 bytes",
|
||||||
|
});
|
||||||
|
expect(events, "accepted frames first, synthetic exit last").toEqual([
|
||||||
|
"tail diagnostics",
|
||||||
|
"bg",
|
||||||
|
"exit",
|
||||||
|
]);
|
||||||
|
const acks = first.worker.postMessage.mock.calls
|
||||||
|
.map((call) => (call[0] as { eventAck?: unknown }).eventAck)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(acks, "terminal delivery is outside the credit protocol").toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synthesizes one controlled exit per retirement so the run mirror unlatches", async () => {
|
||||||
|
// E-10: a retired worker emits no bg/exit frame of its own; without the
|
||||||
|
// synthetic exit the sharedspice s_bgRunning mirror stays latched true
|
||||||
|
// and the simulator's Run action is disabled for the session.
|
||||||
|
const first = await readyRequest(0);
|
||||||
|
const events: NgspiceEvent[] = [];
|
||||||
|
globalThis.__ngspiceOnEvent = (event) => events.push(event);
|
||||||
|
|
||||||
|
first.worker.emitError("wasm trap");
|
||||||
|
await expect(first.request).resolves.toEqual({
|
||||||
|
error: "ngspice_service crashed: wasm trap",
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ kind: "exit", status: 1, immediate: true, quit: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Retirement is idempotent — a late second fault emits nothing more.
|
||||||
|
first.worker.emitError("late echo");
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
// And a throwing handler must not break the retirement itself.
|
||||||
|
const second = await readyRequest(1);
|
||||||
|
globalThis.__ngspiceOnEvent = () => {
|
||||||
|
throw new Error("exit handler bug");
|
||||||
|
};
|
||||||
|
second.worker.emitError("second trap");
|
||||||
|
await expect(second.request).resolves.toEqual({
|
||||||
|
error: "ngspice_service crashed: second trap",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("retires a worker whose bounded event stream reports a fatal line", async () => {
|
it("retires a worker whose bounded event stream reports a fatal line", async () => {
|
||||||
const first = await readyRequest(0, commandRequest("oversize output"));
|
const first = await readyRequest(0, commandRequest("oversize output"));
|
||||||
first.worker.emitMessage({
|
first.worker.emitMessage({
|
||||||
|
|
|
||||||
|
|
@ -169,15 +169,24 @@ export function installNgspiceService(
|
||||||
const frame = { generation: slot.generation, evt, sequence, bytes };
|
const frame = { generation: slot.generation, evt, sequence, bytes };
|
||||||
const handler = globalThis.__ngspiceOnEvent;
|
const handler = globalThis.__ngspiceOnEvent;
|
||||||
if (handler) {
|
if (handler) {
|
||||||
while (evtQueue.length) {
|
// The host took transport ownership of this frame the moment it arrived
|
||||||
const queued = evtQueue.shift()!;
|
// in onmessage, so the ack must survive a throwing handler (the
|
||||||
evtQueueBytes -= queued.bytes;
|
// sharedspice client deliberately rethrows non-trap errors) — otherwise
|
||||||
if (queued.generation !== slot.generation) continue;
|
// each throw leaks one unit of the worker's credit window until the
|
||||||
// Queued frames were acked at enqueue (ownership was taken then).
|
// stream dies with a misattributed overload. The throw itself keeps
|
||||||
handler(queued.evt);
|
// propagating: the client's trap-latch machinery needs to see it.
|
||||||
|
try {
|
||||||
|
while (evtQueue.length) {
|
||||||
|
const queued = evtQueue.shift()!;
|
||||||
|
evtQueueBytes -= queued.bytes;
|
||||||
|
if (queued.generation !== slot.generation) continue;
|
||||||
|
// Queued frames were acked at enqueue (ownership was taken then).
|
||||||
|
handler(queued.evt);
|
||||||
|
}
|
||||||
|
handler(evt);
|
||||||
|
} finally {
|
||||||
|
ackEvent(slot, frame);
|
||||||
}
|
}
|
||||||
handler(evt);
|
|
||||||
ackEvent(slot, frame);
|
|
||||||
} else {
|
} else {
|
||||||
if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES
|
if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES
|
||||||
|| evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) {
|
|| evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) {
|
||||||
|
|
@ -195,6 +204,31 @@ export function installNgspiceService(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The worker's terminal notice carries the deferred frames it had already
|
||||||
|
// accepted (its accepted-prefix contract — typically the last diagnostics
|
||||||
|
// explaining why the run died): deliver them best-effort, in order, WITHOUT
|
||||||
|
// acking — the fatal frame lives outside the credit protocol and the stream
|
||||||
|
// is gone. Best-effort: a throwing handler must not block later frames or
|
||||||
|
// the retirement that follows.
|
||||||
|
const deliverTerminalEvents = (entries: unknown): void => {
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) return;
|
||||||
|
const handler = globalThis.__ngspiceOnEvent;
|
||||||
|
if (!handler) {
|
||||||
|
log(`[ngspice] dropping ${entries.length} undelivered event frame(s) `
|
||||||
|
+ "from a failed stream (no handler installed)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const evt = (entry as { evt?: NgspiceEvent } | null)?.evt;
|
||||||
|
if (!evt) continue;
|
||||||
|
try {
|
||||||
|
handler(evt);
|
||||||
|
} catch (error) {
|
||||||
|
log(`[ngspice] terminal event delivery failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const failPending = (generation: number, why: string): void => {
|
const failPending = (generation: number, why: string): void => {
|
||||||
for (const [id, request] of pending) {
|
for (const [id, request] of pending) {
|
||||||
if (request.generation !== generation) continue;
|
if (request.generation !== generation) continue;
|
||||||
|
|
@ -237,6 +271,24 @@ export function installNgspiceService(
|
||||||
const reject = slot.rejectBoot;
|
const reject = slot.rejectBoot;
|
||||||
slot.rejectBoot = undefined;
|
slot.rejectBoot = undefined;
|
||||||
reject?.(new Error(why));
|
reject?.(new Error(why));
|
||||||
|
|
||||||
|
// A retired worker emits no bg/exit frame of its own, so the sharedspice
|
||||||
|
// client's s_bgRunning mirror would stay latched true after a mid-run
|
||||||
|
// death — Run stays disabled and the promised fresh-worker restart is
|
||||||
|
// unreachable for the whole session. Synthesize the controlled-exit the
|
||||||
|
// crashed engine could not send. Dispatch straight to the installed
|
||||||
|
// handler, NOT through dispatchEvt: a fabricated frame must never touch
|
||||||
|
// the transport credit ledger. The client handler routes it through
|
||||||
|
// cbControlledExit (clears the mirror, reports, delivers SIM_IDLE) and
|
||||||
|
// self-drops on a dead/terminal instance.
|
||||||
|
const handler = globalThis.__ngspiceOnEvent;
|
||||||
|
if (handler) {
|
||||||
|
try {
|
||||||
|
handler({ kind: "exit", status: 1, immediate: true, quit: false });
|
||||||
|
} catch (error) {
|
||||||
|
log(`[ngspice] synthetic exit dispatch failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureWorker = (): Promise<WorkerSlot> => {
|
const ensureWorker = (): Promise<WorkerSlot> => {
|
||||||
|
|
@ -284,6 +336,9 @@ export function installNgspiceService(
|
||||||
if (slot.failed || workerSlot !== slot) return;
|
if (slot.failed || workerSlot !== slot) return;
|
||||||
const data = e.data ?? {};
|
const data = e.data ?? {};
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
|
// Deliver the accepted-but-undelivered frames the terminal
|
||||||
|
// notice carries before retiring the generation.
|
||||||
|
deliverTerminalEvents(data.pendingEvents);
|
||||||
failWorker(`event stream failure: ${String(data.fatal)}`);
|
failWorker(`event stream failure: ${String(data.fatal)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -12,6 +12,7 @@ vi.mock("./wasm-assets", () => ({
|
||||||
import { installOccService, type OccResponse } from "./occ-service";
|
import { installOccService, type OccResponse } from "./occ-service";
|
||||||
import { collectBoardModelFiles } from "./libs/models-bridge";
|
import { collectBoardModelFiles } from "./libs/models-bridge";
|
||||||
import { resolveWasmBase } from "./wasm-assets";
|
import { resolveWasmBase } from "./wasm-assets";
|
||||||
|
import { FakeWorker, waitForWorker } from "./test-utils/fake-worker";
|
||||||
|
|
||||||
const mockedCollectBoardModelFiles = vi.mocked(collectBoardModelFiles);
|
const mockedCollectBoardModelFiles = vi.mocked(collectBoardModelFiles);
|
||||||
const mockedResolveWasmBase = vi.mocked(resolveWasmBase);
|
const mockedResolveWasmBase = vi.mocked(resolveWasmBase);
|
||||||
|
|
@ -20,45 +21,6 @@ const TEST_MODEL_PREFETCH_TIMEOUT_MS = 500;
|
||||||
const TEST_BOOT_TIMEOUT_MS = 1_000;
|
const TEST_BOOT_TIMEOUT_MS = 1_000;
|
||||||
const TEST_RESPONSE_TIMEOUT_MS = 5_000;
|
const TEST_RESPONSE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
type MessageListener = (event: MessageEvent) => void;
|
|
||||||
|
|
||||||
class FakeWorker {
|
|
||||||
static instances: FakeWorker[] = [];
|
|
||||||
|
|
||||||
onmessage: MessageListener | null = null;
|
|
||||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
|
||||||
onmessageerror: ((event: MessageEvent) => void) | null = null;
|
|
||||||
readonly postMessage = vi.fn();
|
|
||||||
readonly terminate = vi.fn();
|
|
||||||
private readonly messageListeners = new Set<MessageListener>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
FakeWorker.instances.push(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
|
||||||
if (type === "message") this.messageListeners.add(listener as MessageListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
|
||||||
if (type === "message") this.messageListeners.delete(listener as MessageListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitMessage(data: unknown): void {
|
|
||||||
const event = { data } as MessageEvent;
|
|
||||||
this.onmessage?.(event);
|
|
||||||
for (const listener of [...this.messageListeners]) listener(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitError(message: string): void {
|
|
||||||
this.onerror?.({ message } as ErrorEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitMessageError(): void {
|
|
||||||
this.onmessageerror?.({} as MessageEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadRequest = () => ({
|
const loadRequest = () => ({
|
||||||
kind: "loadModel" as const,
|
kind: "loadModel" as const,
|
||||||
bytes: new Uint8Array([1, 2, 3]),
|
bytes: new Uint8Array([1, 2, 3]),
|
||||||
|
|
@ -78,11 +40,6 @@ const service = () => {
|
||||||
return installed;
|
return installed;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function waitForWorker(index: number): Promise<FakeWorker> {
|
|
||||||
await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index));
|
|
||||||
return FakeWorker.instances[index]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readyRequest(workerIndex: number): Promise<{
|
async function readyRequest(workerIndex: number): Promise<{
|
||||||
worker: FakeWorker;
|
worker: FakeWorker;
|
||||||
request: Promise<OccResponse>;
|
request: Promise<OccResponse>;
|
||||||
|
|
@ -255,9 +212,55 @@ describe("OCC service worker lifetime", () => {
|
||||||
expect(worker.postMessage).toHaveBeenCalledTimes(1);
|
expect(worker.postMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
worker.emitMessage({ id, res: { ok: true, report: "exported" } });
|
worker.emitMessage({ id, res: { ok: true, report: "exported" } });
|
||||||
|
// The timeout is no longer silent at the headline level: the export
|
||||||
|
// report carries the omission note. (The mock feeds no progress sink,
|
||||||
|
// hence the 0-of-0 counts here.)
|
||||||
await expect(request).resolves.toEqual({
|
await expect(request).resolves.toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
report: "exported",
|
report: "exported\nmodel prefetch timed out after "
|
||||||
|
+ `${TEST_MODEL_PREFETCH_TIMEOUT_MS} ms — 0 of 0 model(s) omitted`,
|
||||||
|
fileName: undefined,
|
||||||
|
});
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships the partial prefetch on timeout and reports the omission", async () => {
|
||||||
|
// E-21: a slow-but-alive prefetch used to be all-or-nothing — the 30s
|
||||||
|
// deadline discarded every model already collected and the export
|
||||||
|
// completed under a bare "Export complete." A timeout must ship the
|
||||||
|
// accepted partials and surface the omission in the report.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockedCollectBoardModelFiles.mockImplementationOnce(
|
||||||
|
(_board, _concurrency, _signal, progress) => {
|
||||||
|
if (progress) {
|
||||||
|
progress.totalRefs = 3;
|
||||||
|
progress.models.push(
|
||||||
|
{ path: "PartialA.3dshapes/a.step", bytes: new Uint8Array([1]) },
|
||||||
|
{ path: "PartialB.3dshapes/b.step", bytes: new Uint8Array([2]) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Promise(() => undefined); // hangs past the deadline
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = service().request(exportRequest());
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(TEST_MODEL_PREFETCH_TIMEOUT_MS);
|
||||||
|
const worker = FakeWorker.instances[0]!;
|
||||||
|
expect(worker).toBeDefined();
|
||||||
|
worker.emitMessage({ ready: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
const [{ id, req: dispatched }] = worker.postMessage.mock.calls[0] as [
|
||||||
|
{ id: number; req: { models: Array<{ path: string }> } },
|
||||||
|
];
|
||||||
|
expect(dispatched.models.map((m) => m.path), "the accepted partials ship")
|
||||||
|
.toEqual(["PartialA.3dshapes/a.step", "PartialB.3dshapes/b.step"]);
|
||||||
|
|
||||||
|
worker.emitMessage({ id, res: { ok: true, report: "Export complete." } });
|
||||||
|
await expect(request).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
report: "Export complete.\nmodel prefetch timed out after "
|
||||||
|
+ `${TEST_MODEL_PREFETCH_TIMEOUT_MS} ms — 1 of 3 model(s) omitted`,
|
||||||
fileName: undefined,
|
fileName: undefined,
|
||||||
});
|
});
|
||||||
expect(vi.getTimerCount()).toBe(0);
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { downloadBytes } from "@/lib/download";
|
import { downloadBytes } from "@/lib/download";
|
||||||
import { collectBoardModelFiles, type BoardModelFile } from "./libs/models-bridge";
|
import { collectBoardModelFiles, type BoardModelFile, type CollectProgress } from "./libs/models-bridge";
|
||||||
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
|
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
|
||||||
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
|
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
|
||||||
import occWorkerSource from "./occ-worker.js?raw";
|
import occWorkerSource from "./occ-worker.js?raw";
|
||||||
|
|
@ -291,7 +291,7 @@ export function installOccService(
|
||||||
|
|
||||||
const prefetchBoardModels = async (
|
const prefetchBoardModels = async (
|
||||||
board: Uint8Array,
|
board: Uint8Array,
|
||||||
): Promise<BoardModelFile[]> => {
|
): Promise<{ models: BoardModelFile[]; note?: string }> => {
|
||||||
type Outcome =
|
type Outcome =
|
||||||
| { kind: "ready"; models: BoardModelFile[] }
|
| { kind: "ready"; models: BoardModelFile[] }
|
||||||
| { kind: "failed"; error: unknown }
|
| { kind: "failed"; error: unknown }
|
||||||
|
|
@ -300,12 +300,18 @@ export function installOccService(
|
||||||
// collectBoardModelFiles keeps its own bounded network parallelism and does
|
// collectBoardModelFiles keeps its own bounded network parallelism and does
|
||||||
// no editor-native work. The controller owns this exact optional
|
// no editor-native work. The controller owns this exact optional
|
||||||
// collection: a timeout stops it from selecting more models and makes its
|
// collection: a timeout stops it from selecting more models and makes its
|
||||||
// already-started source results inert.
|
// already-started source results inert. The progress sink receives every
|
||||||
|
// accepted model as it lands — on timeout the partial set still ships
|
||||||
|
// (an aborted collection can never be awaited: an in-flight source fetch
|
||||||
|
// is not abortable), and the omission is surfaced in the export report
|
||||||
|
// instead of silently exporting without models.
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
const progress: CollectProgress = { totalRefs: 0, models: [] };
|
||||||
const collected: Promise<Outcome> = collectBoardModelFiles(
|
const collected: Promise<Outcome> = collectBoardModelFiles(
|
||||||
new TextDecoder().decode(board),
|
new TextDecoder().decode(board),
|
||||||
6,
|
6,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
|
progress,
|
||||||
).then(
|
).then(
|
||||||
(models) => ({ kind: "ready", models }),
|
(models) => ({ kind: "ready", models }),
|
||||||
(error) => ({ kind: "failed", error }),
|
(error) => ({ kind: "failed", error }),
|
||||||
|
|
@ -327,25 +333,29 @@ export function installOccService(
|
||||||
const outcome = await Promise.race([collected, deadline]);
|
const outcome = await Promise.race([collected, deadline]);
|
||||||
if (timer !== undefined) clearTimeout(timer);
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
|
||||||
if (outcome.kind === "ready") return outcome.models;
|
if (outcome.kind === "ready") return { models: outcome.models };
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
controller.abort(
|
controller.abort(
|
||||||
new DOMException("OCC model prefetch retired", "AbortError"),
|
new DOMException("OCC model prefetch retired", "AbortError"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (outcome.kind === "failed") {
|
if (outcome.kind === "failed") {
|
||||||
log(`[occ] model prefetch failed (exporting without models): ${outcome.error}`);
|
const note =
|
||||||
} else {
|
`model prefetch failed — exported without models: ${String(outcome.error)}`;
|
||||||
log(
|
log(`[occ] ${note}`);
|
||||||
`[occ] model prefetch timed out after ${modelPrefetchTimeoutMs} ms ` +
|
return { models: [], note };
|
||||||
"(exporting without models)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return [];
|
const models = [...progress.models];
|
||||||
|
const note =
|
||||||
|
`model prefetch timed out after ${modelPrefetchTimeoutMs} ms — ` +
|
||||||
|
`${progress.totalRefs - models.length} of ${progress.totalRefs} model(s) omitted`;
|
||||||
|
log(`[occ] ${note}`);
|
||||||
|
return { models, note };
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = async (req: OccRequest): Promise<OccResponse> => {
|
const request = async (req: OccRequest): Promise<OccResponse> => {
|
||||||
let prepared: OccRequest;
|
let prepared: OccRequest;
|
||||||
|
let prefetchNote: string | undefined;
|
||||||
if (req.kind === "export") {
|
if (req.kind === "export") {
|
||||||
// Capture the caller-owned request fields before the first await and
|
// Capture the caller-owned request fields before the first await and
|
||||||
// build a private dispatch object. A late optional prefetch can then
|
// build a private dispatch object. A late optional prefetch can then
|
||||||
|
|
@ -356,8 +366,10 @@ export function installOccService(
|
||||||
// Ship the board's lib model bodies with the request: the worker's
|
// Ship the board's lib model bodies with the request: the worker's
|
||||||
// EXPORTER_STEP resolves them from its own MEMFS (delivery gap doc:
|
// EXPORTER_STEP resolves them from its own MEMFS (delivery gap doc:
|
||||||
// docs/features/3d-models/0007). Best-effort — an export without
|
// docs/features/3d-models/0007). Best-effort — an export without
|
||||||
// models still succeeds, each miss reported by the exporter.
|
// models still succeeds, each miss reported by the exporter — but a
|
||||||
const models = await prefetchBoardModels(board);
|
// curtailed prefetch is surfaced in the export report (E-21).
|
||||||
|
const { models, note } = await prefetchBoardModels(board);
|
||||||
|
prefetchNote = note;
|
||||||
if (models.length)
|
if (models.length)
|
||||||
log(`[occ] shipping ${models.length} board model(s) with the export`);
|
log(`[occ] shipping ${models.length} board model(s) with the export`);
|
||||||
prepared = { kind: "export", board, jobJson, fileName, models };
|
prepared = { kind: "export", board, jobJson, fileName, models };
|
||||||
|
|
@ -387,7 +399,12 @@ export function installOccService(
|
||||||
downloadBytes(name, res.bytes);
|
downloadBytes(name, res.bytes);
|
||||||
log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`);
|
log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`);
|
||||||
}
|
}
|
||||||
return { ok: res.ok, report: res.report, fileName: res.fileName };
|
// A curtailed prefetch reaches the user through the export report
|
||||||
|
// dialog, not only the console.
|
||||||
|
const report = prefetchNote
|
||||||
|
? (res.report ? `${res.report}\n${prefetchNote}` : prefetchNote)
|
||||||
|
: res.report;
|
||||||
|
return { ok: res.ok, report, fileName: res.fileName };
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ function loadShim(opts: { busy: () => boolean }) {
|
||||||
g.Module = {
|
g.Module = {
|
||||||
kicadOpenFileBusy: opts.busy,
|
kicadOpenFileBusy: opts.busy,
|
||||||
kicadCollabApplyItems: (x: unknown) => `applied:${String(x)}`,
|
kicadCollabApplyItems: (x: unknown) => `applied:${String(x)}`,
|
||||||
|
// Headless stack ops so parked waits and the resume pump run under vitest.
|
||||||
|
stackSave: () => 0,
|
||||||
|
stackRestore: () => {},
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line no-eval
|
// eslint-disable-next-line no-eval
|
||||||
(0, eval)(readFileSync(SHIM_PATH, "utf8"));
|
(0, eval)(readFileSync(SHIM_PATH, "utf8"));
|
||||||
|
|
@ -312,13 +315,18 @@ describe("E-8: runWaitCompletion admission gate for worker completions", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies cross-realm trap strings as terminal too", () => {
|
it("classifies a realm-crossed trap by its RuntimeError name", () => {
|
||||||
|
// An error object relayed across a realm loses its instanceof identity
|
||||||
|
// but keeps its name. (The old message-substring sniff is gone — see the
|
||||||
|
// false-positive gate below.)
|
||||||
const S = loadShim({ busy: () => false });
|
const S = loadShim({ busy: () => false });
|
||||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
try {
|
try {
|
||||||
const token = S.beginWait("occ");
|
const token = S.beginWait("occ");
|
||||||
|
const crossed = new Error("unreachable");
|
||||||
|
crossed.name = "RuntimeError";
|
||||||
S.runWaitCompletion("cross-realm trap", token, () => {
|
S.runWaitCompletion("cross-realm trap", token, () => {
|
||||||
throw new Error("RuntimeError: unreachable");
|
throw crossed;
|
||||||
});
|
});
|
||||||
expect(S.terminal).toBe(true);
|
expect(S.terminal).toBe(true);
|
||||||
expect(S.waitEarlyResolved(token)).toBe(0);
|
expect(S.waitEarlyResolved(token)).toBe(0);
|
||||||
|
|
@ -327,6 +335,89 @@ describe("E-8: runWaitCompletion admission gate for worker completions", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a plain error QUOTING trap text does not terminalize (E-14)", async () => {
|
||||||
|
// The old classifier matched message substrings ('Aborted(', 'index out
|
||||||
|
// of bounds', …) — any plain JS error whose text merely QUOTED such
|
||||||
|
// wording permanently bricked a healthy instance. Structural signals
|
||||||
|
// only: RuntimeError instance or name.
|
||||||
|
const S = loadShim({ busy: () => false });
|
||||||
|
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const token = S.beginWait("ngspice");
|
||||||
|
expect(
|
||||||
|
S.runWaitCompletion("relay bug", token, () => {
|
||||||
|
throw new Error(
|
||||||
|
"copy failed: memory access out of bounds in worker payload (Aborted(…))",
|
||||||
|
);
|
||||||
|
}, 1),
|
||||||
|
).toBe(false);
|
||||||
|
expect(S.terminal, "a message-only match must not brick the instance").toBe(false);
|
||||||
|
expect(S.canTouchNative()).toBe(true);
|
||||||
|
await expect(S.waitPromise(token), "the wait fails with inertResult").resolves.toBe(1);
|
||||||
|
} finally {
|
||||||
|
err.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bare resolveWait after a terminal latch does not resume the parked waiter (E-15)", async () => {
|
||||||
|
const S = loadShim({ busy: () => false });
|
||||||
|
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const token = S.beginWait("3d");
|
||||||
|
let settled = false;
|
||||||
|
void S.waitPromise(token).then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const trapToken = S.beginWait("occ");
|
||||||
|
S.runWaitCompletion("trap", trapToken, () => {
|
||||||
|
throw new WebAssembly.RuntimeError("unreachable");
|
||||||
|
});
|
||||||
|
expect(S.terminal).toBe(true);
|
||||||
|
|
||||||
|
// The ten bare finishers (fontenum/clipboard/3d/fp-lib/…) all route
|
||||||
|
// through resolveWait — on a terminal instance it must refuse WITHOUT
|
||||||
|
// consuming the entry (the frame stays visibly parked in dump()).
|
||||||
|
expect(S.resolveWait(token, 7), "bare resolve refused on terminal").toBe(false);
|
||||||
|
expect(S.pendingWaits("3d"), "the entry is not consumed").toBe(1);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(settled, "the parked frame must not resume into the trapped module")
|
||||||
|
.toBe(false);
|
||||||
|
} finally {
|
||||||
|
err.mockRestore();
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wake already queued when terminal latches is never delivered (E-15)", async () => {
|
||||||
|
const S = loadShim({ busy: () => false });
|
||||||
|
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const token = S.beginWait("sleep");
|
||||||
|
let settled = false;
|
||||||
|
void S.waitPromise(token).then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
// Resolve on a HEALTHY instance — the wake is now queued behind a
|
||||||
|
// microtask — then latch terminal before the pump can run it.
|
||||||
|
expect(S.resolveWait(token, 1)).toBe(true);
|
||||||
|
const trapToken = S.beginWait("occ");
|
||||||
|
S.runWaitCompletion("trap", trapToken, () => {
|
||||||
|
throw new WebAssembly.RuntimeError("unreachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(settled, "the queued wake must not re-enter the trapped module")
|
||||||
|
.toBe(false);
|
||||||
|
} finally {
|
||||||
|
err.mockRestore();
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("a plain JS bug fails the wait with inertResult instead of stranding it", async () => {
|
it("a plain JS bug fails the wait with inertResult instead of stranding it", async () => {
|
||||||
const S = loadShim({ busy: () => false });
|
const S = loadShim({ busy: () => false });
|
||||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
|
||||||
57
web/standalone/src/wasm/test-utils/fake-worker.ts
Normal file
57
web/standalone/src/wasm/test-utils/fake-worker.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { expect, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Worker test double for the service-lifetime suites (one copy —
|
||||||
|
* previously duplicated verbatim in occ-service.test.ts and
|
||||||
|
* ngspice-service.test.ts, where a fix to its event semantics applied to one
|
||||||
|
* file left the other suite validating different fake-worker behavior).
|
||||||
|
*
|
||||||
|
* Honors J-4: no synthetic `dispatchEvent` — emit* invoke the exact functions
|
||||||
|
* the service assigned to the handler attributes.
|
||||||
|
*
|
||||||
|
* (Not named *.test.ts: the vitest include glob must not collect it.)
|
||||||
|
*/
|
||||||
|
export type MessageListener = (event: MessageEvent) => void;
|
||||||
|
|
||||||
|
export class FakeWorker {
|
||||||
|
static instances: FakeWorker[] = [];
|
||||||
|
|
||||||
|
onmessage: MessageListener | null = null;
|
||||||
|
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||||
|
onmessageerror: ((event: MessageEvent) => void) | null = null;
|
||||||
|
readonly postMessage = vi.fn();
|
||||||
|
readonly terminate = vi.fn();
|
||||||
|
private readonly messageListeners = new Set<MessageListener>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
FakeWorker.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
||||||
|
if (type === "message") this.messageListeners.add(listener as MessageListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
||||||
|
if (type === "message") this.messageListeners.delete(listener as MessageListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitMessage(data: unknown): void {
|
||||||
|
const event = { data } as MessageEvent;
|
||||||
|
this.onmessage?.(event);
|
||||||
|
for (const listener of [...this.messageListeners]) listener(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitError(message: string): void {
|
||||||
|
this.onerror?.({ message } as ErrorEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitMessageError(): void {
|
||||||
|
this.onmessageerror?.({} as MessageEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Await the service's Nth Worker construction. */
|
||||||
|
export async function waitForWorker(index: number): Promise<FakeWorker> {
|
||||||
|
await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index));
|
||||||
|
return FakeWorker.instances[index]!;
|
||||||
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit a7436d105271815592943aebfc91f42295a74b48
|
Subproject commit 4e6cc5a441e46ac07b242caa3adacfd8e7c7ec50
|
||||||
Loading…
Reference in a new issue