eeschema simulator: lazy ngspice_service worker — static sharedspice (XSPICE registry + CIDER), init_dll ifdef, e2e both engines
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
004412c53d
commit
703cb010b7
30 changed files with 2872 additions and 82 deletions
204
tests/kicad/eeschema-sim.spec.ts
Normal file
204
tests/kicad/eeschema-sim.spec.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { test, expect } from './fixtures';
|
||||
import * as path from 'path';
|
||||
import { PNG } from 'pngjs';
|
||||
import {
|
||||
clickByTooltip,
|
||||
clickMenuBarItem,
|
||||
clickMenuItemByText,
|
||||
findByTooltip,
|
||||
stableShot,
|
||||
waitForEditorReady,
|
||||
} from '../e2e/utils/element-tracker';
|
||||
import { injectFileIntoMemfs } from './utils/fs-inject';
|
||||
|
||||
/**
|
||||
* eeschema simulator end-to-end (docs/features/ngspice-split/): the historic
|
||||
* kill-point was SIMULATOR_FRAME never opening (no dlopen for libngspice);
|
||||
* now NGSPICE binds the sharedspice client stub and the engine runs in the
|
||||
* lazy ngspice_service worker. These specs drive the REAL UI path:
|
||||
* project open → Inspect → Simulator → Run → plot, asserting the RPC/event
|
||||
* plumbing (window.__ngspiceEvents / __ngspiceLog from the harness provider,
|
||||
* tests/kicad/utils/ngspice-service.ts) and the rendered result.
|
||||
*
|
||||
* Fixture: the complete kicad demo rectifier project — its 1N4148 lives in a
|
||||
* sibling diode.mod pulled in via `.include`, so a passing transient also
|
||||
* proves the client stub's netlist file shipping (a missing model fails the
|
||||
* 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((sch: string) => {
|
||||
(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 wait for the background run to
|
||||
// finish (the bg 'finished' event lands after ngspice's thread joins).
|
||||
async function runSimulation(page: import('@playwright/test').Page): Promise<void> {
|
||||
const evtsBefore = await page.evaluate(
|
||||
() => (window as any).__ngspiceEvents.length as 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 expect
|
||||
.poll(async () => {
|
||||
const el = await findByTooltip(page, 'Run Simulation', { elementType: 'tool' });
|
||||
return !!el && el.enabled;
|
||||
}, { timeout: 60000 })
|
||||
.toBe(true);
|
||||
|
||||
expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }),
|
||||
'Run tool').toBe(true);
|
||||
|
||||
await page.waitForFunction((n: number) => {
|
||||
const evts = (window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>;
|
||||
return evts.slice(n).some((e) => e.kind === 'bg' && e.finished === true);
|
||||
}, evtsBefore, { timeout: 120000 });
|
||||
}
|
||||
|
||||
function distinctColors(png: PNG): number {
|
||||
const colors = new Set<number>();
|
||||
// 8x8 grid sampling, same spirit as the 3d-viewer render check.
|
||||
const stepX = Math.max(1, Math.floor(png.width / 8));
|
||||
const stepY = Math.max(1, Math.floor(png.height / 8));
|
||||
|
||||
for (let y = 0; y < png.height; y += stepY) {
|
||||
for (let x = 0; x < png.width; x += stepX) {
|
||||
const i = (png.width * y + x) << 2;
|
||||
colors.add((png.data[i] << 16) | (png.data[i + 1] << 8) | png.data[i + 2]);
|
||||
}
|
||||
}
|
||||
return colors.size;
|
||||
}
|
||||
|
||||
test.describe('eeschema simulator', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(300000);
|
||||
|
||||
test('Inspect → Simulator opens the frame; service fetches lazily', async ({ page, testLogger }) => {
|
||||
const ngspiceFetches: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('ngspice_service')) ngspiceFetches.push(r.url());
|
||||
});
|
||||
|
||||
await page.goto('/kicad/eeschema.html');
|
||||
await waitForEditorReady(page);
|
||||
await loadRectifier(page);
|
||||
|
||||
expect(ngspiceFetches,
|
||||
'ngspice_service must NOT be fetched before the simulator opens')
|
||||
.toHaveLength(0);
|
||||
|
||||
await openSimulator(page);
|
||||
await stableShot(page, 'eeschema-sim-frame.png');
|
||||
|
||||
// NGSPICE::init_dll ran inside the frame ctor → the client stub's init
|
||||
// RPC booted the worker.
|
||||
expect(ngspiceFetches.length,
|
||||
'ngspice_service fetched lazily by the simulator open')
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
});
|
||||
|
||||
test('transient run: live console stream, vectors reach the plot, plot renders', async ({ page, testLogger }) => {
|
||||
await page.goto('/kicad/eeschema.html');
|
||||
await waitForEditorReady(page);
|
||||
await loadRectifier(page);
|
||||
const simWin = await openSimulator(page);
|
||||
|
||||
await runSimulation(page);
|
||||
|
||||
const evts = await page.evaluate(() => (window as any).__ngspiceEvents as Array<{
|
||||
kind: string; lines?: string[]; finished?: boolean; t: number }>);
|
||||
|
||||
// Live streaming: console/status output must precede the finish event.
|
||||
const finishT = evts.filter((e) => e.kind === 'bg' && e.finished).map((e) => e.t)[0];
|
||||
const streamed = evts.filter(
|
||||
(e) => (e.kind === 'char' || e.kind === 'stat') && e.t <= finishT);
|
||||
expect(streamed.length, 'ngspice output streamed during the run')
|
||||
.toBeGreaterThan(3);
|
||||
|
||||
// The model shipped via .include resolved (a miss fails the run with
|
||||
// "unable to find definition" and produces no transient).
|
||||
const charText = evts.flatMap((e) => e.lines ?? []).join('\n');
|
||||
expect(charText, 'no missing-model errors').not.toMatch(/unable to find definition/i);
|
||||
|
||||
// The plot pulled real vector data through get_vec_info.
|
||||
const vecPulls = await page.evaluate(() =>
|
||||
((window as any).__ngspiceLog as Array<{ kind: string; length?: number }>)
|
||||
.filter((l) => l.kind === 'get_vec_info' && (l.length ?? 0) > 100).length);
|
||||
expect(vecPulls, 'plot fetched transient vectors').toBeGreaterThan(0);
|
||||
|
||||
// The plot area rendered something beyond a flat background.
|
||||
const shot = await page.locator(`#${simWin}`).screenshot({
|
||||
scale: 'css', animations: 'disabled' });
|
||||
const png = PNG.sync.read(shot);
|
||||
expect(distinctColors(png), 'plot window shows structure (axes/trace)')
|
||||
.toBeGreaterThan(6);
|
||||
|
||||
await stableShot(page, 'eeschema-sim-plot.png');
|
||||
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
const corruption = all.filter((l) =>
|
||||
l.includes('index out of bounds') || l.includes('indirect call to null')
|
||||
|| l.includes('uncaught exception: unwind'));
|
||||
expect(corruption, 'no asyncify corruption').toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a second run after the first succeeds (engine reset path)', async ({ page, testLogger }) => {
|
||||
await page.goto('/kicad/eeschema.html');
|
||||
await waitForEditorReady(page);
|
||||
await loadRectifier(page);
|
||||
await openSimulator(page);
|
||||
|
||||
await runSimulation(page);
|
||||
await runSimulation(page);
|
||||
|
||||
const finishCount = await page.evaluate(() =>
|
||||
((window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>)
|
||||
.filter((e) => e.kind === 'bg' && e.finished === true).length);
|
||||
expect(finishCount, 'two completed runs').toBeGreaterThanOrEqual(2);
|
||||
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { test as base } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
import { setupTestLogger, writeTestLogs, TestLogger, MAIN_CANVAS, waitForApp, tryLoadApp, getCanvasBox, KICAD_LOGS_DIR, getTestFileName } from '../e2e/utils/test-utils';
|
||||
import { installNgspiceServiceStub } from './utils/ngspice-service';
|
||||
import { installOccServiceStub } from './utils/occ-service';
|
||||
|
||||
// Extend base test with automatic logging
|
||||
|
|
@ -14,6 +15,10 @@ export const test = base.extend<{
|
|||
// the lazy-load boundary.
|
||||
page: async ({ page }, use) => {
|
||||
await installOccServiceStub(page);
|
||||
// The ngspice_service provider follows the same ambient pattern (the
|
||||
// standalone installs it for every kicad_editor boot); the worker is only
|
||||
// fetched on the first simulator request.
|
||||
await installNgspiceServiceStub(page);
|
||||
await use(page);
|
||||
},
|
||||
|
||||
|
|
|
|||
176
tests/kicad/ngspice-probe.spec.ts
Normal file
176
tests/kicad/ngspice-probe.spec.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { test, expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* Minimal ngspice_service probe (no eeschema boot): drive the sharedspice RPC
|
||||
* surface directly from the page. Isolates worker/module behavior from the
|
||||
* editor entirely — when the simulator breaks, this answers "service module
|
||||
* or editor-side bridge?" in seconds. The provider arrives via the fixtures'
|
||||
* ambient init script (tests/kicad/utils/ngspice-service.ts), which also
|
||||
* captures every event frame into window.__ngspiceEvents.
|
||||
*
|
||||
* Covers Gate 2 of docs/features/ngspice-split/: browser-side parity of the
|
||||
* Gate-1 node smoke — foreground transient numerics, XSPICE via the static
|
||||
* code-model registry, CIDER, live event streaming during bg_run, mid-run
|
||||
* bg_halt, and the lazy-load boundary.
|
||||
*/
|
||||
|
||||
type SvcRes = {
|
||||
ret?: number; error?: string; found?: boolean; length?: number;
|
||||
real?: number[] | Float64Array | null; name?: string; names?: string[];
|
||||
running?: boolean;
|
||||
};
|
||||
|
||||
async function svcRequest(page: import('@playwright/test').Page, req: unknown): Promise<SvcRes> {
|
||||
return await page.evaluate(async (r: any) => {
|
||||
const res = await (globalThis as any).ngspiceService.request(r);
|
||||
// Float64Array doesn't survive evaluate serialization on all engines —
|
||||
// flatten to a plain array (probe vectors are small).
|
||||
if (res && res.real) res.real = Array.from(res.real);
|
||||
if (res && res.comp) res.comp = Array.from(res.comp);
|
||||
return res;
|
||||
}, req as any);
|
||||
}
|
||||
|
||||
test.describe('ngspice_service probe', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(240000);
|
||||
|
||||
test('lazy boundary + RC transient + vector readback', async ({ page }) => {
|
||||
const ngspiceFetches: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('ngspice_service')) ngspiceFetches.push(r.url());
|
||||
});
|
||||
|
||||
// Any harness page gives the COI (COOP/COEP) context; don't wait for wasm.
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
expect(ngspiceFetches, 'ngspice_service must NOT be fetched before first use')
|
||||
.toHaveLength(0);
|
||||
|
||||
const init = await svcRequest(page, { kind: 'init' });
|
||||
expect(init.error, 'init error').toBeUndefined();
|
||||
expect(init.ret, 'ngSpice_Init').toBe(0);
|
||||
|
||||
expect(ngspiceFetches.length, 'ngspice_service was fetched lazily by init')
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const circ = await svcRequest(page, {
|
||||
kind: 'circ',
|
||||
lines: ['rc probe', 'V1 in 0 1', 'R1 in out 1k', 'C1 out 0 1u',
|
||||
'.tran 10u 5m', '.end'],
|
||||
});
|
||||
expect(circ.ret, 'ngSpice_Circ').toBe(0);
|
||||
|
||||
const run = await svcRequest(page, { kind: 'command', cmd: 'run' });
|
||||
expect(run.ret, 'run command').toBe(0);
|
||||
|
||||
const plot = await svcRequest(page, { kind: 'cur_plot' });
|
||||
expect(plot.name, 'a tran plot exists').toMatch(/^tran/);
|
||||
|
||||
const vecs = await svcRequest(page, { kind: 'all_vecs', plot: plot.name! });
|
||||
expect(vecs.names, 'tran vectors').toContain('out');
|
||||
|
||||
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'out' });
|
||||
expect(vi.found, 'v(out) found').toBe(true);
|
||||
expect(vi.length ?? 0, 'plausible point count').toBeGreaterThan(100);
|
||||
const last = (vi.real as number[])[(vi.length ?? 1) - 1];
|
||||
// DC operating point seeds the transient at the steady state: flat 1V.
|
||||
expect(last, 'v(out) end value').toBeGreaterThan(0.98);
|
||||
expect(last, 'v(out) end value').toBeLessThanOrEqual(1.0);
|
||||
});
|
||||
|
||||
test('XSPICE code model resolves through the static registry', async ({ page }) => {
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await svcRequest(page, { kind: 'init' });
|
||||
const circ = await svcRequest(page, {
|
||||
kind: 'circ',
|
||||
lines: ['xspice probe', 'V1 in 0 2', 'A1 in aout gainblk',
|
||||
'.model gainblk gain(gain=3)', 'R1 aout 0 1k', '.op', '.end'],
|
||||
});
|
||||
expect(circ.ret, 'circ with a-device').toBe(0);
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
|
||||
|
||||
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'aout' });
|
||||
expect(vi.found, 'v(aout) found').toBe(true);
|
||||
expect((vi.real as number[])[0], 'gain block output 2*3').toBeCloseTo(6.0, 3);
|
||||
});
|
||||
|
||||
test('CIDER numd device simulates', async ({ page }) => {
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await svcRequest(page, { kind: 'init' });
|
||||
const circ = await svcRequest(page, {
|
||||
kind: 'circ',
|
||||
lines: ['cider probe - silicon resistor',
|
||||
'VPP 1 0 2v', 'VNN 2 0 0.0v', 'D1 1 2 M_RES AREA=1',
|
||||
'.MODEL M_RES numd level=1',
|
||||
'+ options resistor defa=1p',
|
||||
'+ x.mesh loc=0.0 num=1', '+ x.mesh loc=1.0 num=21',
|
||||
'+ domain num=1 material=1', '+ material num=1 silicon',
|
||||
'+ doping unif n.type conc=2.5e16',
|
||||
'+ models bgn srh conctau auger concmob fieldmob',
|
||||
'.DC VPP 0.0v 2.01v 0.5v', '.END'],
|
||||
});
|
||||
expect(circ.ret, 'circ with numd model').toBe(0);
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
|
||||
|
||||
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'vpp#branch' });
|
||||
expect(vi.found, 'sweep current vector found').toBe(true);
|
||||
expect(vi.length ?? 0, 'DC sweep points').toBeGreaterThanOrEqual(4);
|
||||
const iLast = (vi.real as number[])[(vi.length ?? 1) - 1];
|
||||
expect(iLast, 'resistor draws current (negative through VPP)').toBeLessThan(0);
|
||||
expect(Math.abs(iLast), 'plausible magnitude').toBeLessThan(1.0);
|
||||
});
|
||||
|
||||
test('bg_run streams events live and bg_halt stops mid-run', async ({ page, testLogger }) => {
|
||||
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await svcRequest(page, { kind: 'init' });
|
||||
|
||||
// Heavy enough that the halt lands mid-run: 150-stage nonlinear
|
||||
// RC/diode ladder, 20s transient, storage bounded via .save.
|
||||
const deck = ['halt probe', 'V1 n0 0 SIN(0 5 10k)'];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
deck.push(`R${i + 1} n${i} n${i + 1} 100`);
|
||||
deck.push(`C${i + 1} n${i + 1} 0 10n`);
|
||||
deck.push(`D${i + 1} n${i + 1} 0 dmod`);
|
||||
}
|
||||
deck.push('.model dmod d(is=1e-14)', '.save v(n150)', '.tran 100n 20', '.end');
|
||||
|
||||
expect((await svcRequest(page, { kind: 'circ', lines: deck })).ret).toBe(0);
|
||||
|
||||
const evtsBefore = await page.evaluate(
|
||||
() => (window as any).__ngspiceEvents.length as number);
|
||||
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_run' })).ret,
|
||||
'bg_run accepted').toBe(0);
|
||||
|
||||
// Live streaming: char/stat frames must arrive WHILE the background
|
||||
// thread simulates (not only after completion).
|
||||
await page.waitForFunction((n: number) => {
|
||||
const evts = (window as any).__ngspiceEvents as Array<{ kind: string }>;
|
||||
return evts.slice(n).filter((e) => e.kind === 'char' || e.kind === 'stat').length >= 3;
|
||||
}, evtsBefore, { timeout: 60000 });
|
||||
|
||||
const midRunning = await svcRequest(page, { kind: 'running' });
|
||||
expect(midRunning.running,
|
||||
'still running while events streamed (deck heavy enough)').toBe(true);
|
||||
|
||||
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_halt' })).ret,
|
||||
'bg_halt accepted').toBe(0);
|
||||
|
||||
// BGThreadRunning(finished) must arrive after the halt joins the thread.
|
||||
await page.waitForFunction((n: number) => {
|
||||
const evts = (window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>;
|
||||
return evts.slice(n).some((e) => e.kind === 'bg' && e.finished === true);
|
||||
}, evtsBefore, { timeout: 60000 });
|
||||
|
||||
const after = await svcRequest(page, { kind: 'running' });
|
||||
expect(after.running, 'stopped after bg_halt').toBe(false);
|
||||
|
||||
// Standard corruption gate.
|
||||
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
|
||||
});
|
||||
});
|
||||
125
tests/kicad/utils/ngspice-service.ts
Normal file
125
tests/kicad/utils/ngspice-service.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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 */ }
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onFirst = (e: MessageEvent) => {
|
||||
if (e.data?.ready) { worker.removeEventListener('message', onFirst); resolve(); }
|
||||
else if (e.data?.bootError) reject(new Error(e.data.bootError));
|
||||
};
|
||||
worker.addEventListener('message', onFirst);
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ copy_app pl_editor && found_any=1
|
|||
copy_app gerbview && found_any=1
|
||||
# OCC 3D service (lazy worker module; pcbnew's STEP export + model parsing)
|
||||
copy_app occ_service || true
|
||||
# ngspice simulation service (lazy worker module; eeschema's simulator)
|
||||
copy_app ngspice_service || true
|
||||
|
||||
if [ "$found_any" -eq 0 ]; then
|
||||
echo "Error: no kicad_editor/calculator/pl_editor/gerbview artifacts found in output/ or docker volume" >&2
|
||||
|
|
|
|||
Loading…
Reference in a new issue