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:
Viktor Vaczi 2026-07-19 15:59:21 +02:00
commit 703cb010b7
30 changed files with 2872 additions and 82 deletions

View 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);
});
});

View file

@ -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);
},

View 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);
});
});

View 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);
}

View file

@ -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