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
|
|
@ -1,7 +1,9 @@
|
|||
import type { Page } from '@playwright/test';
|
||||
import { waitForCanvasStable } from '../../e2e/utils/element-tracker';
|
||||
import { assertNoNativeFailure, findNativeFailure } from './native-failure';
|
||||
|
||||
export type RuntimeLogger = { consoleLogs: string[]; errors: string[] };
|
||||
export type { RuntimeLogger } from './native-failure';
|
||||
import type { RuntimeLogger } from './native-failure';
|
||||
|
||||
/**
|
||||
* Wait for pcbnew to finish opening a board.
|
||||
|
|
@ -34,10 +36,7 @@ export async function waitForBoardLoaded(
|
|||
// goes away and we'd burn the full timeout. KiCad logs the abort
|
||||
// line through Module.printErr, which our test logger captures as
|
||||
// a console error. We re-read the live arrays each tick.
|
||||
const allLines = [...logger.consoleLogs, ...logger.errors];
|
||||
const abort = allLines.find((l) =>
|
||||
l.includes('Aborted(') || l.includes('RuntimeError: unreachable')
|
||||
);
|
||||
const abort = findNativeFailure([...logger.consoleLogs, ...logger.errors]);
|
||||
if (abort) {
|
||||
throw new Error(`WASM aborted during LoadBoard:\n${abort}`);
|
||||
}
|
||||
|
|
@ -79,16 +78,6 @@ function assertExpectedBoard(expectedBoard: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function assertNoNativeFailure(logger: RuntimeLogger | undefined, phase: string): void {
|
||||
if (!logger) return;
|
||||
const failure = [...logger.consoleLogs, ...logger.errors].find((line) =>
|
||||
line.includes('Aborted(')
|
||||
|| line.includes('RuntimeError: unreachable')
|
||||
|| line.includes('memory access out of bounds')
|
||||
);
|
||||
if (failure) throw new Error(`WASM failed during ${phase}:\n${failure}`);
|
||||
}
|
||||
|
||||
async function waitForBoardIdentityAndPaint(
|
||||
page: Page,
|
||||
expectedBoard: string,
|
||||
|
|
|
|||
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;
|
||||
minimumLength?: number;
|
||||
}
|
||||
interface RequestWaiter {
|
||||
after: number;
|
||||
criteria: RequestCriteria;
|
||||
resolve: (summary: RequestSummary) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
interface AppliedGenerationReceipt {
|
||||
generation: number;
|
||||
t: number;
|
||||
}
|
||||
interface AppliedGenerationWaiter {
|
||||
after: number;
|
||||
resolve: (receipt: AppliedGenerationReceipt) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
interface CancellableReceipt<T> extends Promise<T> {
|
||||
cancel: (reason?: string) => void;
|
||||
}
|
||||
|
||||
const RECEIPT_TIMEOUT_MS = 2 * 60_000;
|
||||
const MAX_RECEIPT_TIMEOUT_MS = 5 * 60_000;
|
||||
|
|
@ -140,11 +124,11 @@ export async function installNgspiceServiceStub(
|
|||
let maxPending = 0;
|
||||
let bootMessageErrorArmed = false;
|
||||
let runtimeMessageErrorThreshold: number | null = null;
|
||||
let dieOnNextBgRunArmed = false;
|
||||
let corruptNextGetVecArmed = false;
|
||||
const retiredGenerations: number[] = [];
|
||||
let nextRequestSequence = 1;
|
||||
const requestWaiters = new Set<RequestWaiter>();
|
||||
const appliedGenerations: AppliedGenerationReceipt[] = [];
|
||||
const appliedGenerationWaiters = new Set<AppliedGenerationWaiter>();
|
||||
let disposed = false;
|
||||
|
||||
const validateReceiptTimeout = (timeoutMs: number): string | undefined => {
|
||||
|
|
@ -155,15 +139,6 @@ export async function installNgspiceServiceStub(
|
|||
return undefined;
|
||||
};
|
||||
|
||||
const cancellable = <T>(
|
||||
promise: Promise<T>,
|
||||
cancel: (reason?: string) => void,
|
||||
): CancellableReceipt<T> => {
|
||||
const receipt = promise as CancellableReceipt<T>;
|
||||
Object.defineProperty(receipt, 'cancel', { value: cancel });
|
||||
return receipt;
|
||||
};
|
||||
|
||||
const requestMatches = (
|
||||
summary: RequestSummary,
|
||||
after: number,
|
||||
|
|
@ -175,115 +150,130 @@ export async function installNgspiceServiceStub(
|
|||
&& (criteria.minimumLength === undefined
|
||||
|| (summary.length ?? -1) >= criteria.minimumLength);
|
||||
|
||||
const rejectRequestWaiter = (waiter: RequestWaiter, reason: Error): void => {
|
||||
if (!requestWaiters.delete(waiter)) return;
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(reason);
|
||||
interface ReceiptWaiter<TReceipt, TCriteria> {
|
||||
after: number;
|
||||
criteria: TCriteria;
|
||||
resolve: (receipt: TReceipt) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scan-then-subscribe receipt channel (shared by the request and
|
||||
* applied-generation receipts, which differ only in their match
|
||||
* predicate, existing-receipt scan, and error strings): scan the
|
||||
* already-published receipts first, otherwise subscribe a bounded,
|
||||
* timed waiter — so a receipt cannot land in the gap between an array
|
||||
* scan and listener installation.
|
||||
*/
|
||||
const makeReceiptChannel = <TReceipt, TCriteria>(channel: {
|
||||
validate: (after: number, criteria: TCriteria) => string | undefined;
|
||||
/** Defensive copy of the criteria, taken only after validation. */
|
||||
snapshotCriteria?: (criteria: TCriteria) => TCriteria;
|
||||
scanExisting: (after: number, criteria: TCriteria) => TReceipt | undefined;
|
||||
matches: (receipt: TReceipt, after: number, criteria: TCriteria) => boolean;
|
||||
capacityError: string;
|
||||
timeoutError: (timeoutMs: number) => string;
|
||||
}) => {
|
||||
const waiters = new Set<ReceiptWaiter<TReceipt, TCriteria>>();
|
||||
const rejectWaiter = (
|
||||
waiter: ReceiptWaiter<TReceipt, TCriteria>,
|
||||
reason: Error,
|
||||
): void => {
|
||||
if (!waiters.delete(waiter)) return;
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(reason);
|
||||
};
|
||||
return {
|
||||
wait(after: number, criteria: TCriteria, timeoutMs: number): Promise<TReceipt> {
|
||||
if (disposed)
|
||||
return Promise.reject(new Error('ngspice receipt service was disposed'));
|
||||
const invalid = channel.validate(after, criteria)
|
||||
?? validateReceiptTimeout(timeoutMs);
|
||||
if (invalid) return Promise.reject(new Error(invalid));
|
||||
const existing = channel.scanExisting(after, criteria);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
if (waiters.size >= MAX_RECEIPT_WAITERS)
|
||||
return Promise.reject(new Error(channel.capacityError));
|
||||
const held = channel.snapshotCriteria
|
||||
? channel.snapshotCriteria(criteria) : criteria;
|
||||
return new Promise<TReceipt>((resolve, reject) => {
|
||||
const waiter: ReceiptWaiter<TReceipt, TCriteria> = {
|
||||
after,
|
||||
criteria: held,
|
||||
resolve,
|
||||
reject,
|
||||
timer: setTimeout(() => rejectWaiter(
|
||||
waiter,
|
||||
new Error(channel.timeoutError(timeoutMs)),
|
||||
), timeoutMs),
|
||||
};
|
||||
waiters.add(waiter);
|
||||
});
|
||||
},
|
||||
publish(receipt: TReceipt): void {
|
||||
for (const waiter of [...waiters]) {
|
||||
if (!channel.matches(receipt, waiter.after, waiter.criteria)) continue;
|
||||
waiters.delete(waiter);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(receipt);
|
||||
}
|
||||
},
|
||||
drain(reason: string): void {
|
||||
for (const waiter of [...waiters]) rejectWaiter(waiter, new Error(reason));
|
||||
},
|
||||
size: () => waiters.size,
|
||||
};
|
||||
};
|
||||
|
||||
const requestReceipts = makeReceiptChannel<RequestSummary, RequestCriteria>({
|
||||
validate: (after, criteria) => {
|
||||
if (!Number.isSafeInteger(after) || after < 0)
|
||||
return 'request checkpoint must be a non-negative integer';
|
||||
if (!criteria || typeof criteria !== 'object')
|
||||
return 'request receipt criteria must be an object';
|
||||
if (criteria.minimumLength !== undefined
|
||||
&& (!Number.isSafeInteger(criteria.minimumLength)
|
||||
|| criteria.minimumLength < 0)) {
|
||||
return 'minimumLength must be a non-negative integer';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
snapshotCriteria: (criteria) => ({ ...criteria }),
|
||||
scanExisting: (after, criteria) => ((window as any).__ngspiceLog as RequestSummary[])
|
||||
.find((entry) => requestMatches(entry, after, criteria)),
|
||||
matches: requestMatches,
|
||||
capacityError: 'ngspice request receipt waiter capacity exceeded',
|
||||
timeoutError: (timeoutMs) =>
|
||||
`ngspice request receipt timed out after ${timeoutMs} ms`,
|
||||
});
|
||||
|
||||
const appliedReceipts = makeReceiptChannel<AppliedGenerationReceipt, undefined>({
|
||||
validate: (after) => (!Number.isSafeInteger(after) || after < 0)
|
||||
? 'applied generation checkpoint must be a non-negative integer'
|
||||
: undefined,
|
||||
scanExisting: (after) => appliedGenerations.find((entry) => entry.generation > after),
|
||||
matches: (receipt, after) => receipt.generation > after,
|
||||
capacityError: 'ngspice applied-generation waiter capacity exceeded',
|
||||
timeoutError: (timeoutMs) =>
|
||||
`ngspice applied generation timed out after ${timeoutMs} ms`,
|
||||
});
|
||||
|
||||
const publishRequestReceipt = (summary: RequestSummary) => {
|
||||
(window as any).__ngspiceLog.push(summary);
|
||||
for (const waiter of [...requestWaiters]) {
|
||||
if (!requestMatches(summary, waiter.after, waiter.criteria)) continue;
|
||||
requestWaiters.delete(waiter);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(summary);
|
||||
}
|
||||
requestReceipts.publish(summary);
|
||||
};
|
||||
|
||||
const waitForRequestAfter = (
|
||||
after: number,
|
||||
criteria: RequestCriteria,
|
||||
timeoutMs = RECEIPT_TIMEOUT_MS,
|
||||
): CancellableReceipt<RequestSummary> => {
|
||||
const rejected = (message: string) => cancellable(
|
||||
Promise.reject(new Error(message)),
|
||||
() => undefined,
|
||||
);
|
||||
if (disposed) return rejected('ngspice receipt service was disposed');
|
||||
if (!Number.isSafeInteger(after) || after < 0)
|
||||
return rejected('request checkpoint must be a non-negative integer');
|
||||
if (!criteria || typeof criteria !== 'object')
|
||||
return rejected('request receipt criteria must be an object');
|
||||
if (criteria.minimumLength !== undefined
|
||||
&& (!Number.isSafeInteger(criteria.minimumLength)
|
||||
|| criteria.minimumLength < 0)) {
|
||||
return rejected('minimumLength must be a non-negative integer');
|
||||
}
|
||||
const timeoutError = validateReceiptTimeout(timeoutMs);
|
||||
if (timeoutError) return rejected(timeoutError);
|
||||
const log = (window as any).__ngspiceLog as RequestSummary[];
|
||||
const existing = log.find((entry) =>
|
||||
requestMatches(entry, after, criteria));
|
||||
if (existing) return cancellable(Promise.resolve(existing), () => undefined);
|
||||
if (requestWaiters.size >= MAX_RECEIPT_WAITERS)
|
||||
return rejected('ngspice request receipt waiter capacity exceeded');
|
||||
|
||||
let waiter!: RequestWaiter;
|
||||
const promise = new Promise<RequestSummary>((resolve, reject) => {
|
||||
waiter = {
|
||||
after,
|
||||
criteria: { ...criteria },
|
||||
resolve,
|
||||
reject,
|
||||
timer: setTimeout(() => rejectRequestWaiter(
|
||||
waiter,
|
||||
new Error(`ngspice request receipt timed out after ${timeoutMs} ms`),
|
||||
), timeoutMs),
|
||||
};
|
||||
requestWaiters.add(waiter);
|
||||
});
|
||||
return cancellable(promise, (reason = 'canceled') => rejectRequestWaiter(
|
||||
waiter,
|
||||
new Error(`ngspice request receipt ${reason}`),
|
||||
));
|
||||
};
|
||||
|
||||
const rejectAppliedGenerationWaiter = (
|
||||
waiter: AppliedGenerationWaiter,
|
||||
reason: Error,
|
||||
): void => {
|
||||
if (!appliedGenerationWaiters.delete(waiter)) return;
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(reason);
|
||||
};
|
||||
): Promise<RequestSummary> => requestReceipts.wait(after, criteria, timeoutMs);
|
||||
|
||||
const waitForAppliedGenerationAfter = (
|
||||
after: number,
|
||||
timeoutMs = RECEIPT_TIMEOUT_MS,
|
||||
): CancellableReceipt<AppliedGenerationReceipt> => {
|
||||
const rejected = (message: string) => cancellable(
|
||||
Promise.reject(new Error(message)),
|
||||
() => undefined,
|
||||
);
|
||||
if (disposed) return rejected('ngspice receipt service was disposed');
|
||||
if (!Number.isSafeInteger(after) || after < 0)
|
||||
return rejected('applied generation checkpoint must be a non-negative integer');
|
||||
const timeoutError = validateReceiptTimeout(timeoutMs);
|
||||
if (timeoutError) return rejected(timeoutError);
|
||||
const existing = appliedGenerations.find((entry) => entry.generation > after);
|
||||
if (existing) return cancellable(Promise.resolve(existing), () => undefined);
|
||||
if (appliedGenerationWaiters.size >= MAX_RECEIPT_WAITERS)
|
||||
return rejected('ngspice applied-generation waiter capacity exceeded');
|
||||
|
||||
let waiter!: AppliedGenerationWaiter;
|
||||
const promise = new Promise<AppliedGenerationReceipt>((resolve, reject) => {
|
||||
waiter = {
|
||||
after,
|
||||
resolve,
|
||||
reject,
|
||||
timer: setTimeout(() => rejectAppliedGenerationWaiter(
|
||||
waiter,
|
||||
new Error(`ngspice applied generation timed out after ${timeoutMs} ms`),
|
||||
), timeoutMs),
|
||||
};
|
||||
appliedGenerationWaiters.add(waiter);
|
||||
});
|
||||
return cancellable(promise, (reason = 'canceled') => rejectAppliedGenerationWaiter(
|
||||
waiter,
|
||||
new Error(`ngspice applied-generation receipt ${reason}`),
|
||||
));
|
||||
};
|
||||
): Promise<AppliedGenerationReceipt> => appliedReceipts.wait(after, undefined, timeoutMs);
|
||||
|
||||
const previousAppliedHook = (globalThis as any).__pcbjamNgspiceFinalRefreshApplied;
|
||||
const publishAppliedGeneration = (generation: number): void => {
|
||||
|
|
@ -301,12 +291,7 @@ export async function installNgspiceServiceStub(
|
|||
}
|
||||
const receipt = { generation, t: Date.now() - t0 };
|
||||
appliedGenerations.push(receipt);
|
||||
for (const waiter of [...appliedGenerationWaiters]) {
|
||||
if (generation <= waiter.after) continue;
|
||||
appliedGenerationWaiters.delete(waiter);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(receipt);
|
||||
}
|
||||
appliedReceipts.publish(receipt);
|
||||
if (typeof previousAppliedHook === 'function') previousAppliedHook(generation);
|
||||
};
|
||||
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = publishAppliedGeneration;
|
||||
|
|
@ -353,15 +338,21 @@ export async function installNgspiceServiceStub(
|
|||
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
|
||||
const handler = (globalThis as any).__ngspiceOnEvent;
|
||||
if (handler) {
|
||||
while (evtQueue.length) {
|
||||
const queued = evtQueue.shift()!;
|
||||
evtQueueBytes -= queued.bytes;
|
||||
if (queued.generation !== slot.generation) continue;
|
||||
// Queued frames were acked at enqueue (ownership taken then).
|
||||
handler(queued.evt);
|
||||
// Mirrors the production service: the host owns the frame's
|
||||
// credit from onmessage on, so the ack survives a throwing
|
||||
// handler (which keeps propagating).
|
||||
try {
|
||||
while (evtQueue.length) {
|
||||
const queued = evtQueue.shift()!;
|
||||
evtQueueBytes -= queued.bytes;
|
||||
if (queued.generation !== slot.generation) continue;
|
||||
// Queued frames were acked at enqueue (ownership taken then).
|
||||
handler(queued.evt);
|
||||
}
|
||||
handler(evt);
|
||||
} finally {
|
||||
ackEvent(slot, frame);
|
||||
}
|
||||
handler(evt);
|
||||
ackEvent(slot, frame);
|
||||
} else {
|
||||
if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES
|
||||
|| evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) {
|
||||
|
|
@ -377,6 +368,26 @@ export async function installNgspiceServiceStub(
|
|||
}
|
||||
};
|
||||
|
||||
// Mirrors the production service: the worker's terminal notice carries
|
||||
// the deferred frames it had already accepted — deliver best-effort,
|
||||
// in order, WITHOUT acking (the fatal frame is outside the credit
|
||||
// protocol), and record them for the specs like any live frame.
|
||||
const deliverTerminalEvents = (entries: unknown): void => {
|
||||
if (!Array.isArray(entries) || entries.length === 0) return;
|
||||
const handler = (globalThis as any).__ngspiceOnEvent;
|
||||
for (const entry of entries) {
|
||||
const evt = (entry as { evt?: any } | null)?.evt;
|
||||
if (!evt) continue;
|
||||
(window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 });
|
||||
if (!handler) continue;
|
||||
try {
|
||||
handler(evt);
|
||||
} catch (error) {
|
||||
console.log(`[TEST-NGSPICE] terminal event delivery failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const failPending = (generation: number, why: string) => {
|
||||
for (const [id, request] of pending) {
|
||||
if (request.generation !== generation) continue;
|
||||
|
|
@ -389,6 +400,7 @@ export async function installNgspiceServiceStub(
|
|||
const retireWorker = (slot: WorkerSlot, why: string) => {
|
||||
if (slot.failed) return;
|
||||
slot.failed = true;
|
||||
console.log(`[TEST-NGSPICE] retiring generation ${slot.generation}: ${why}`);
|
||||
retiredGenerations.push(slot.generation);
|
||||
if (slot.bootTimer !== undefined) {
|
||||
clearTimeout(slot.bootTimer);
|
||||
|
|
@ -412,6 +424,23 @@ export async function installNgspiceServiceStub(
|
|||
const reject = slot.rejectBoot;
|
||||
slot.rejectBoot = undefined;
|
||||
reject?.(new Error(why));
|
||||
|
||||
// Mirrors the production service (E-10): a retired worker emits
|
||||
// no bg/exit frame of its own, so synthesize the controlled-exit
|
||||
// the crashed engine could not send — straight to the installed
|
||||
// handler, never through dispatchEvt (no fabricated credit).
|
||||
const handler = (globalThis as any).__ngspiceOnEvent;
|
||||
if (handler) {
|
||||
(window as any).__ngspiceEvents.push({
|
||||
kind: 'exit', status: 1, immediate: true, quit: false,
|
||||
t: Date.now() - t0,
|
||||
});
|
||||
try {
|
||||
handler({ kind: 'exit', status: 1, immediate: true, quit: false });
|
||||
} catch (error) {
|
||||
console.log(`[TEST-NGSPICE] synthetic exit dispatch failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureWorker = (): Promise<WorkerSlot> => {
|
||||
|
|
@ -446,6 +475,7 @@ export async function installNgspiceServiceStub(
|
|||
if (slot.failed || workerSlot !== slot) return;
|
||||
const data = e.data ?? {};
|
||||
if (data.fatal) {
|
||||
deliverTerminalEvents(data.pendingEvents);
|
||||
failWorker(`event stream failure: ${String(data.fatal)}`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -555,6 +585,27 @@ export async function installNgspiceServiceStub(
|
|||
// checkpoint can therefore never satisfy that simulation.
|
||||
if (disposed) return { error: 'ngspice service was disposed' };
|
||||
const requestSequence = nextRequestSequence++;
|
||||
// Armed fault: the transport dies on the bg_run launch itself —
|
||||
// AFTER the native side published its run generation, BEFORE any
|
||||
// RUNNING transition could fire (the E-12 window). Retirement
|
||||
// runs the production funnel (and its synthetic exit).
|
||||
if (dieOnNextBgRunArmed && req.kind === 'command'
|
||||
&& typeof req.cmd === 'string' && req.cmd.startsWith('bg_run')) {
|
||||
dieOnNextBgRunArmed = false;
|
||||
console.log('[TEST-NGSPICE] armed fault: transport death on bg_run');
|
||||
const res = {
|
||||
error: 'ngspice_service crashed: transport died on bg_run (armed fault)',
|
||||
};
|
||||
if (workerSlot) retireWorker(workerSlot, res.error);
|
||||
publishRequestReceipt({
|
||||
sequence: requestSequence,
|
||||
kind: req.kind,
|
||||
cmd: req.cmd,
|
||||
error: res.error,
|
||||
t: Date.now() - t0,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
let slot: WorkerSlot;
|
||||
try {
|
||||
slot = await ensureWorker();
|
||||
|
|
@ -571,6 +622,15 @@ export async function installNgspiceServiceStub(
|
|||
return res;
|
||||
}
|
||||
const res: any = await post(slot, req);
|
||||
// Armed fault: corrupt the next get_vec_info answer's LENGTH field
|
||||
// only (the arrays stay small) — the corrupted-worker shape the
|
||||
// sharedspice client must clamp against.
|
||||
if (corruptNextGetVecArmed && req.kind === 'get_vec_info'
|
||||
&& res && !res.error) {
|
||||
corruptNextGetVecArmed = false;
|
||||
console.log('[TEST-NGSPICE] armed fault: inflating get_vec_info length');
|
||||
res.length = 1 << 29;
|
||||
}
|
||||
publishRequestReceipt({
|
||||
sequence: requestSequence,
|
||||
kind: req.kind,
|
||||
|
|
@ -588,15 +648,8 @@ export async function installNgspiceServiceStub(
|
|||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (workerSlot) retireWorker(workerSlot, 'ngspice service was disposed');
|
||||
for (const waiter of [...requestWaiters]) {
|
||||
rejectRequestWaiter(waiter, new Error('ngspice request receipt canceled by teardown'));
|
||||
}
|
||||
for (const waiter of [...appliedGenerationWaiters]) {
|
||||
rejectAppliedGenerationWaiter(
|
||||
waiter,
|
||||
new Error('ngspice applied-generation receipt canceled by teardown'),
|
||||
);
|
||||
}
|
||||
requestReceipts.drain('ngspice request receipt canceled by teardown');
|
||||
appliedReceipts.drain('ngspice applied-generation receipt canceled by teardown');
|
||||
if ((globalThis as any).__pcbjamNgspiceFinalRefreshApplied
|
||||
=== publishAppliedGeneration) {
|
||||
(globalThis as any).__pcbjamNgspiceFinalRefreshApplied = previousAppliedHook;
|
||||
|
|
@ -623,6 +676,25 @@ export async function installNgspiceServiceStub(
|
|||
throw new Error('pending threshold must be a positive safe integer');
|
||||
runtimeMessageErrorThreshold = count;
|
||||
},
|
||||
/** Retire the active generation through the production funnel
|
||||
* (the same retireWorker every watchdog/onerror path uses). */
|
||||
forceRetire(reason: string): boolean {
|
||||
const slot = workerSlot;
|
||||
if (!slot) return false;
|
||||
retireWorker(slot, String(reason || 'forced retirement'));
|
||||
return true;
|
||||
},
|
||||
/** One-shot: the transport dies on the next bg_run launch (the
|
||||
* generation retires through the production funnel before any
|
||||
* RUNNING transition can fire). */
|
||||
dieOnNextBgRun() {
|
||||
dieOnNextBgRunArmed = true;
|
||||
},
|
||||
/** One-shot: the next successful get_vec_info answer reports a
|
||||
* huge vector length while its arrays stay small. */
|
||||
corruptNextGetVec() {
|
||||
corruptNextGetVecArmed = true;
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
activeGeneration: workerSlot?.generation ?? null,
|
||||
|
|
@ -632,9 +704,9 @@ export async function installNgspiceServiceStub(
|
|||
bootFaultArmed: bootMessageErrorArmed,
|
||||
runtimeFaultArmed: runtimeMessageErrorThreshold !== null,
|
||||
lastRequestSequence: nextRequestSequence - 1,
|
||||
requestReceiptWaiters: requestWaiters.size,
|
||||
requestReceiptWaiters: requestReceipts.size(),
|
||||
appliedGenerations: appliedGenerations.map((entry) => entry.generation),
|
||||
appliedGenerationWaiters: appliedGenerationWaiters.size,
|
||||
appliedGenerationWaiters: appliedReceipts.size(),
|
||||
disposed,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,10 +39,23 @@ const OCC_WORKER_SRC = fs.readFileSync(
|
|||
'web', 'standalone', 'src', 'wasm', 'occ-worker.js'),
|
||||
'utf8');
|
||||
|
||||
export async function installOccServiceStub(page: Page): Promise<void> {
|
||||
await page.addInitScript((workerSrc: string) => {
|
||||
export interface OccHarnessWatchdogs {
|
||||
bootTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function installOccServiceStub(
|
||||
page: Page,
|
||||
watchdogs: OccHarnessWatchdogs = {},
|
||||
): Promise<void> {
|
||||
const bootTimeoutMs = watchdogs.bootTimeoutMs ?? 2 * 60_000;
|
||||
if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1)
|
||||
throw new Error('bootTimeoutMs must be a positive safe integer');
|
||||
|
||||
await page.addInitScript((options: { workerSrc: string; bootTimeoutMs: number }) => {
|
||||
if ((globalThis as any).occService) return;
|
||||
|
||||
const { workerSrc, bootTimeoutMs } = options;
|
||||
|
||||
(window as any).__occExports = [];
|
||||
|
||||
interface WorkerSlot {
|
||||
|
|
@ -50,6 +63,9 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
worker?: Worker;
|
||||
failed: boolean;
|
||||
ready: Promise<WorkerSlot>;
|
||||
bootTimer?: ReturnType<typeof setTimeout>;
|
||||
rejectBoot?: (reason?: unknown) => void;
|
||||
removeBootListener?: () => void;
|
||||
/** The exact lifecycle transition used by Worker.onmessageerror. */
|
||||
failDecode: () => void;
|
||||
}
|
||||
|
|
@ -67,11 +83,9 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
let requestsStarted = 0;
|
||||
let requestsPosted = 0;
|
||||
const workerGenerationsStarted: number[] = [];
|
||||
let armedFault: {
|
||||
count: number;
|
||||
kind: 'terminate' | 'messageerror';
|
||||
report: string;
|
||||
} | null = null;
|
||||
let armedFault: { count: number; report: string } | null = null;
|
||||
/** One-shot: the next boot uses a worker that never answers. */
|
||||
let wedgeNextBootArmed: { bootTimeoutMs?: number } | null = null;
|
||||
const retiredGenerations: number[] = [];
|
||||
|
||||
const pendingInGeneration = (generation: number): number => {
|
||||
|
|
@ -94,6 +108,12 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
if (slot.failed) return;
|
||||
slot.failed = true;
|
||||
retiredGenerations.push(slot.generation);
|
||||
if (slot.bootTimer !== undefined) {
|
||||
clearTimeout(slot.bootTimer);
|
||||
slot.bootTimer = undefined;
|
||||
}
|
||||
slot.removeBootListener?.();
|
||||
slot.removeBootListener = undefined;
|
||||
failPending(slot.generation, report);
|
||||
if (workerSlot === slot) workerSlot = null;
|
||||
try {
|
||||
|
|
@ -101,21 +121,22 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
const reject = slot.rejectBoot;
|
||||
slot.rejectBoot = undefined;
|
||||
reject?.(new Error(report));
|
||||
};
|
||||
|
||||
const maybeTriggerArmedFault = (slot: WorkerSlot): void => {
|
||||
if (!armedFault || slot.failed) return;
|
||||
if (pendingInGeneration(slot.generation) < armedFault.count) return;
|
||||
const { kind, report } = armedFault;
|
||||
const { report } = armedFault;
|
||||
armedFault = null;
|
||||
console.log(`[TEST-OCC] faulting generation ${slot.generation} (${kind}): ${report}`);
|
||||
if (kind === 'messageerror' && slot.worker) {
|
||||
console.log(`[TEST-OCC] faulting generation ${slot.generation} (messageerror): ${report}`);
|
||||
if (slot.worker) {
|
||||
// Synthetic dispatch on Worker is engine-dependent. Invoke
|
||||
// the exact transition installed as the real event handler.
|
||||
slot.failDecode();
|
||||
} else {
|
||||
// Worker.terminate() intentionally emits no error event, so
|
||||
// the hook supplies the fatal lifecycle transition explicitly.
|
||||
retireWorker(slot, report);
|
||||
}
|
||||
};
|
||||
|
|
@ -127,36 +148,53 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
failed: false,
|
||||
} as WorkerSlot;
|
||||
workerGenerationsStarted.push(slot.generation);
|
||||
workerSlot = slot;
|
||||
|
||||
slot.ready = (async () => {
|
||||
const wedge = wedgeNextBootArmed;
|
||||
wedgeNextBootArmed = null;
|
||||
const bootDeadlineMs = wedge?.bootTimeoutMs ?? bootTimeoutMs;
|
||||
|
||||
// Legible boot (E-22): a worker DEATH shape that never posts
|
||||
// ready OR bootError (importScripts hang, pthread spawn
|
||||
// wedge, OOM-kill) used to hang every request until the
|
||||
// spec's timeout with zero evidence. Bound the boot — same
|
||||
// shape as the ngspice stub and the production service.
|
||||
const bootDeadline = new Promise<never>((_resolve, reject) => {
|
||||
slot.rejectBoot = reject;
|
||||
slot.bootTimer = setTimeout(() => {
|
||||
if (slot.failed || workerSlot !== slot) return;
|
||||
const why = `occ_service boot timed out after ${bootDeadlineMs} ms`;
|
||||
console.log(`[TEST-OCC] ${why} — resetting service`);
|
||||
retireWorker(slot, why);
|
||||
}, bootDeadlineMs);
|
||||
});
|
||||
|
||||
const boot = (async () => {
|
||||
const glue = new URL('occ_service.js', window.location.href).href;
|
||||
console.log(`[TEST-OCC] booting occ_service from ${glue}`);
|
||||
const worker = new Worker(URL.createObjectURL(new Blob(
|
||||
[`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
|
||||
{ type: 'text/javascript' })));
|
||||
// A wedged boot is a REAL silent Worker (an empty module:
|
||||
// it boots, runs nothing, never posts ready/bootError) —
|
||||
// the importScripts-hang / pthread-wedge shape, engine
|
||||
// independent.
|
||||
const worker = wedge
|
||||
? new Worker('data:text/javascript,/* [TEST-OCC] wedged boot */')
|
||||
: new Worker(URL.createObjectURL(new Blob(
|
||||
[`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
|
||||
{ type: 'text/javascript' })));
|
||||
slot.worker = worker;
|
||||
let rejectBoot: ((reason?: unknown) => void) | undefined;
|
||||
let removeBootListener: (() => void) | undefined;
|
||||
|
||||
// All fatal transitions settle the boot through the ONE
|
||||
// retirement funnel (which clears the deadline, removes
|
||||
// the boot listener, and rejects the raced promise).
|
||||
worker.onerror = (e) => {
|
||||
const report = `occ_service crashed: ${e.message || 'worker error'}`;
|
||||
console.error(`[TEST-OCC] ${report}; resetting service`);
|
||||
retireWorker(slot, report);
|
||||
removeBootListener?.();
|
||||
removeBootListener = undefined;
|
||||
const reject = rejectBoot;
|
||||
rejectBoot = undefined;
|
||||
reject?.(new Error(report));
|
||||
};
|
||||
slot.failDecode = () => {
|
||||
const report = 'occ_service transport failed: message decode failed';
|
||||
console.error(`[TEST-OCC] ${report}; resetting service`);
|
||||
retireWorker(slot, report);
|
||||
removeBootListener?.();
|
||||
removeBootListener = undefined;
|
||||
const reject = rejectBoot;
|
||||
rejectBoot = undefined;
|
||||
reject?.(new Error(report));
|
||||
};
|
||||
worker.onmessageerror = slot.failDecode;
|
||||
worker.onmessage = (e) => {
|
||||
|
|
@ -169,38 +207,37 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
request.resolve(res);
|
||||
}
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
rejectBoot = reject;
|
||||
await new Promise<void>((resolve) => {
|
||||
const onFirst = (e: MessageEvent) => {
|
||||
if (e.data?.ready) {
|
||||
removeBootListener?.();
|
||||
removeBootListener = undefined;
|
||||
rejectBoot = undefined;
|
||||
if (slot.bootTimer !== undefined) {
|
||||
clearTimeout(slot.bootTimer);
|
||||
slot.bootTimer = undefined;
|
||||
}
|
||||
slot.removeBootListener?.();
|
||||
slot.removeBootListener = undefined;
|
||||
slot.rejectBoot = undefined;
|
||||
resolve();
|
||||
} else if (e.data?.bootError) {
|
||||
removeBootListener?.();
|
||||
removeBootListener = undefined;
|
||||
rejectBoot = undefined;
|
||||
const report = `occ_service boot failed: ${String(e.data.bootError)}`;
|
||||
retireWorker(slot, report);
|
||||
reject(new Error(report));
|
||||
retireWorker(slot,
|
||||
`occ_service boot failed: ${String(e.data.bootError)}`);
|
||||
}
|
||||
};
|
||||
worker.addEventListener('message', onFirst);
|
||||
removeBootListener = () => worker.removeEventListener('message', onFirst);
|
||||
slot.removeBootListener = () => worker.removeEventListener('message', onFirst);
|
||||
});
|
||||
if (slot.failed || workerSlot !== slot)
|
||||
throw new Error('occ_service worker retired during boot');
|
||||
console.log('[TEST-OCC] occ_service ready');
|
||||
return slot;
|
||||
})().catch((e) => {
|
||||
})();
|
||||
|
||||
slot.ready = Promise.race([boot, bootDeadline]).catch((e) => {
|
||||
// A late rejection from a retired generation cannot clear
|
||||
// the replacement slot created by a new request.
|
||||
retireWorker(slot, `occ_service unavailable: ${String(e)}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
workerSlot = slot;
|
||||
}
|
||||
return workerSlot.ready;
|
||||
};
|
||||
|
|
@ -291,12 +328,13 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
};
|
||||
|
||||
(globalThis as any).__occServiceTestHooks = {
|
||||
/** Arm one deterministic host-side fault after N real posts. */
|
||||
terminateWhenPendingAtLeast(count: number, report = 'occ_service test fault') {
|
||||
if (!Number.isSafeInteger(count) || count < 1)
|
||||
throw new Error('pending threshold must be a positive safe integer');
|
||||
armedFault = { count, kind: 'terminate', report };
|
||||
if (workerSlot) maybeTriggerArmedFault(workerSlot);
|
||||
/** One-shot: wedge the next boot (silent worker, no ready and no
|
||||
* bootError), optionally shortening that boot's deadline. */
|
||||
wedgeNextBoot(bootTimeoutMs?: number) {
|
||||
if (bootTimeoutMs !== undefined
|
||||
&& (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1))
|
||||
throw new Error('bootTimeoutMs must be a positive safe integer');
|
||||
wedgeNextBootArmed = { bootTimeoutMs };
|
||||
},
|
||||
/** Arm the real Worker's production-parity messageerror handler. */
|
||||
messageErrorWhenPendingAtLeast(count: number) {
|
||||
|
|
@ -304,7 +342,6 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
throw new Error('pending threshold must be a positive safe integer');
|
||||
armedFault = {
|
||||
count,
|
||||
kind: 'messageerror',
|
||||
report: 'occ_service transport failed: message decode failed',
|
||||
};
|
||||
if (workerSlot) maybeTriggerArmedFault(workerSlot);
|
||||
|
|
@ -324,5 +361,5 @@ export async function installOccServiceStub(page: Page): Promise<void> {
|
|||
};
|
||||
|
||||
(globalThis as any).occService = { request };
|
||||
}, OCC_WORKER_SRC);
|
||||
}, { workerSrc: OCC_WORKER_SRC, bootTimeoutMs });
|
||||
}
|
||||
|
|
|
|||
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. */
|
||||
export const TRIO_DOC = "trio";
|
||||
|
||||
import { hasNativeFailure } from "./native-failure";
|
||||
|
||||
export function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
|
||||
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
|
||||
return hasNativeFailure(l);
|
||||
}
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
Loading…
Reference in a new issue