pcbjam/tests/kicad/utils/ngspice-service.ts
Viktor Vaczi db819850ee jspi: fix the dead-tools ownership bug, emscripten-6 fallout, and green the full suite on Playwright 1.62
Live-app fix (Place Footprints / routing dead in Chrome): submodule
bumps carry the coroutine ownership fix (kicad 012d95ecb4) and the
handler-exception survival fix (wxwidgets 1b5f0e31f4).

Emscripten-6 fallout:
- occ/ngspice worker wrappers: mainScriptUrlOrBlob was removed
  upstream; pthread children re-run the wrapper blob, so an em-pthread
  realm now importScripts the glue and gets out of the way (before:
  recursive service boots, pool never fills, silent 180s boot hangs —
  every occ spec and ngspice bg_run).
- Makefile.wasm: -sASYNCIFY frankenlinks on the no-wx coroutine repro
  targets ported to -sJSPI (the JSPI-only libcontext crashed at first
  yield under them); mainloop/gl repro pages drive their tick through a
  promising export (emscripten_set_main_loop callbacks cannot suspend);
  retired inject-dyncall-shims lines removed (targets were unbuildable
  since Phase 8); $stringToNewUTF8 force-included (the EM_ASM value
  bridge aborted the runtime on the first decoded exception).
- fiber-park levers: neither embind shape can drive suspending levers
  (plain throws on strict-JSPI Firefox; emscripten::async() re-executes
  its invoker on settle) — kept sync for manual Chromium probing, spec
  coverage moved to the jspi-coroutine harness (18 cases).

Suite work:
- Playwright 1.61.1 -> 1.62.1 (Firefox 153: JSPI on by default).
- fiber-resume-park.spec retired -> coroutine-lifecycle.spec: census
  gate over boot / board load / chooser open / cancel (deterministically
  red on the pre-fix build).
- Blind asyncify-era pins re-keyed: quasimodal-strand + wait-beacons
  beacon regexes, footprint-chooser-close liveness -> wx parking-timer
  heartbeat (scheduler counters idle flat on Firefox).
- occ/ngspice test providers: 60s boot timeout + worker error
  surfacing (a worker death used to be a silent 180s timeout).
- Harness pages: stale 9.99 config dir -> 10.0 (library_manager wxCHECK
  noise, chooser had no libraries).
- gal-webgl harness: missing artifacts rebuilt (boost/glm extracted to
  the host sysroot), PgmOrNull stub added for the rebased GAL.
- jspi-scheduler: clean-shutdown console line restored (app-quit
  contract), quarantine never yanks SP from a live window.

Gates: test:e2e 699 passed / 0 failed (wx-chromium, kicad-firefox,
kicad-chromium, jspi-firefox, coroutine-firefox); web ff/cr/mobile 71
passed; lint:ci-coverage 166, lint:determinism 163, screenshots
manifest 492 current, corpus 7/7, tools:contract green. Offline
screenshot baselines show expected mass drift from the engine bump —
re-baseline (screenshots:noise -> promote) is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-13 17:41:28 +02:00

145 lines
6.6 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
/**
* Install a REAL `globalThis.ngspiceService` provider into a harness page —
* the same worker-backed ngspice_service boot the standalone app does
* (web/standalone/src/wasm/ngspice-service.ts), minus the CDN manifest
* resolution: the harness serves ngspice_service.{js,wasm} same-origin next
* to the tool page (tests/scripts/setup-kicad-wasm.sh copies them from
* output/).
*
* The worker-side wrapper is the SHARED source of truth
* (web/standalone/src/wasm/ngspice-worker.js — the standalone imports it via
* vite `?raw`; the harness reads it off disk and injects it verbatim), so the
* boot logic cannot drift between app and tests.
*
* Additions for assertability:
* - every `{ evt }` frame is appended to window.__ngspiceEvents
* ({ kind, lines?, finished?, status?, t: ms-since-install }) BEFORE being
* forwarded to globalThis.__ngspiceOnEvent (the editor client stub's
* dispatcher, when integrated) — specs assert live streaming by comparing
* event timestamps against run boundaries;
* - request/response summaries are appended to window.__ngspiceLog.
*
* The worker fetches ngspice_service.js lazily on the FIRST request — specs
* assert the lazy-load boundary by watching network requests.
*/
const NGSPICE_WORKER_SRC = fs.readFileSync(
path.resolve(__dirname, '..', '..', '..',
'web', 'standalone', 'src', 'wasm', 'ngspice-worker.js'),
'utf8');
export async function installNgspiceServiceStub(page: Page): Promise<void> {
await page.addInitScript((workerSrc: string) => {
if ((globalThis as any).ngspiceService) return;
const t0 = Date.now();
(window as any).__ngspiceEvents = [];
(window as any).__ngspiceLog = [];
let workerP: Promise<Worker> | null = null;
const pending = new Map<number, (res: any) => void>();
let nextId = 1;
const evtQueue: any[] = [];
const dispatchEvt = (evt: any) => {
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
const handler = (globalThis as any).__ngspiceOnEvent;
if (handler) {
while (evtQueue.length) handler(evtQueue.shift());
handler(evt);
} else {
evtQueue.push(evt);
}
};
const failAllPending = (why: string) => {
for (const [, resolve] of pending) resolve({ error: why });
pending.clear();
};
const ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (async () => {
const glue = new URL('ngspice_service.js', window.location.href).href;
console.log(`[TEST-NGSPICE] booting ngspice_service from ${glue}`);
const worker = new Worker(URL.createObjectURL(new Blob(
[`self.NGSPICE_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
{ type: 'text/javascript' })));
worker.onmessage = (e) => {
const data = e.data ?? {};
if (data.evt) { dispatchEvt(data.evt); return; }
if (typeof data.id !== 'number') return;
const resolve = pending.get(data.id);
if (resolve) { pending.delete(data.id); resolve(data.res); }
};
worker.onerror = (e) => {
console.log(`[TEST-NGSPICE] worker error: ${e.message} — resetting service`);
failAllPending(`ngspice_service crashed: ${e.message}`);
workerP = null;
try { worker.terminate(); } catch { /* already gone */ }
};
// Legible boot: bound the handshake and surface worker
// death — the bare version hung to the spec timeout with
// zero evidence (occ-service.ts has the same guard).
await new Promise<void>((resolve, reject) => {
const fail = (msg: string) => {
clearTimeout(timer);
reject(new Error(msg));
};
const timer = setTimeout(
() => fail('[TEST-NGSPICE] ngspice_service boot timed out after '
+ '60s (no ready/bootError from the worker)'), 60000);
const onFirst = (e: MessageEvent) => {
if (e.data?.ready) {
worker.removeEventListener('message', onFirst);
clearTimeout(timer);
resolve();
} else if (e.data?.bootError) {
fail(`[TEST-NGSPICE] ngspice_service bootError: ${e.data.bootError}`);
}
};
worker.addEventListener('message', onFirst);
worker.addEventListener('error', (e: any) => fail(
`[TEST-NGSPICE] ngspice_service worker error: ${e?.message ?? e} `
+ `(${e?.filename ?? '?'}:${e?.lineno ?? '?'})`));
worker.addEventListener('messageerror', () => fail(
'[TEST-NGSPICE] ngspice_service worker messageerror (structured clone failed)'));
});
console.log('[TEST-NGSPICE] ngspice_service ready');
return worker;
})().catch((e) => { workerP = null; throw e; });
}
return workerP;
};
const request = async (req: any) => {
let worker: Worker;
try {
worker = await ensureWorker();
} catch (e) {
return { error: `ngspice_service unavailable: ${e}` };
}
const id = nextId++;
const res: any = await new Promise((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, req });
});
(window as any).__ngspiceLog.push({
kind: req.kind,
cmd: req.cmd,
name: req.name,
ret: res?.ret,
error: res?.error,
length: res?.length,
t: Date.now() - t0,
});
return res;
};
(globalThis as any).ngspiceService = { request };
}, NGSPICE_WORKER_SRC);
}