npm: surface readable diagnostics on WASM test failures

The example/suite runners discarded e.stack via console.error(e.message ?? e),
hiding the wasm-function offsets that emsymbolizer needs to map an abort back
to C source. A failing test only printed a terse message like "memory access
out of bounds" with no trace.

- Add reportError() to helpers.js/helpers-tcl.js; print the full stack
  (falling back to the message).
- Wrap command execution in runScript() to name the command that aborted
  before the error propagates.
- Use reportError() in all standalone runners and in all.js/all-tcl.js
  (full stack to stderr, one-line PASS/FAIL summary kept; tests still run
  independently).
- build.sh: run the --test step in a subshell so its cd does not leak into
  the --pack step.
This commit is contained in:
Enno Schnackenberg 2026-06-06 22:01:33 +02:00 committed by R. Timothy Edwards
parent d37793e7d0
commit 50320a055a
14 changed files with 63 additions and 23 deletions

View File

@ -202,10 +202,10 @@ esac
# --- optional test -----------------------------------------------------------
# Runs the same smoke test that CI runs (see .github/workflows/main-wasm.yml).
# Run in a subshell so the cd does not leak into the --pack step below, which
# relies on the script's working directory being unchanged.
if [ $OPT_TEST -eq 1 ]; then
cd "$SCRIPT_DIR"
npm run test
npm run test:tcl
( cd "$SCRIPT_DIR" && npm run test && npm run test:tcl )
fi
# --- optional pack -----------------------------------------------------------

View File

@ -9,6 +9,7 @@ import { run as runGds } from './gds-tcl.js';
import { run as runDrc } from './drc-tcl.js';
import { run as runCif } from './cif-tcl.js';
import { run as runPcell } from './pcell.js';
import { reportError } from './helpers-tcl.js';
const PAD = 9;
@ -20,6 +21,9 @@ async function test(name, fn) {
return true;
} catch (e) {
console.log(`FAIL ${e.message ?? e}`);
// Full stack to stderr so CI shows the wasm-function offsets, while the
// one-line summary above stays readable. Other tests still run.
reportError(e);
return false;
}
}

View File

@ -5,6 +5,7 @@ import { run as runExtract } from './extract.js';
import { run as runGds } from './gds.js';
import { run as runDrc } from './drc.js';
import { run as runCif } from './cif.js';
import { reportError } from './helpers.js';
const PAD = 9;
@ -16,6 +17,9 @@ async function test(name, fn) {
return true;
} catch (e) {
console.log(`FAIL ${e.message ?? e}`);
// Full stack to stderr so CI shows the wasm-function offsets, while the
// one-line summary above stays readable. Other tests still run.
reportError(e);
return false;
}
}

View File

@ -1,6 +1,6 @@
// cif-tcl.js — CIF export via the TCL variant.
import { createMagic, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers-tcl.js';
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT, reportError } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -22,7 +22,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDi
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { outPath, bytes } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { outPath, bytes } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\ncif: ${outPath} (${bytes} bytes)`);
console.log('Done.');
}

View File

@ -2,7 +2,7 @@
//
// Usage: node examples/cif.js [magFile [tech [outputDir]]]
import { createMagic, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers.js';
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT, reportError } from './helpers.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -24,7 +24,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDi
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { outPath, bytes } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { outPath, bytes } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\ncif: ${outPath} (${bytes} bytes)`);
console.log('Done.');
}

View File

@ -1,6 +1,6 @@
// drc-tcl.js — DRC check via the TCL variant.
import { createMagic, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG } from './helpers-tcl.js';
DEFAULT_TECH, DEFAULT_MAG, reportError } from './helpers-tcl.js';
import { fileURLToPath } from 'node:url';
export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH } = {}) {
@ -22,7 +22,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH } = {}) {
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { violations } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { violations } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\nDRC violations: ${violations ?? '(count not found in output)'}`);
console.log('Done.');
}

View File

@ -2,7 +2,7 @@
//
// Usage: node examples/drc.js [magFile [tech]]
import { createMagic, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG } from './helpers.js';
DEFAULT_TECH, DEFAULT_MAG, reportError } from './helpers.js';
import { fileURLToPath } from 'node:url';
export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH } = {}) {
@ -25,7 +25,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH } = {}) {
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { violations } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { violations } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\nDRC violations: ${violations ?? '(count not found in output)'}`);
console.log('Done.');
}

View File

@ -1,6 +1,6 @@
// extract-tcl.js — RC extraction via the TCL variant.
import { createMagic, vfsWrite, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers-tcl.js';
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT, reportError } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -32,7 +32,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDi
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { ext, spice } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { ext, spice } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\next: ${ext}`);
if (spice) console.log(`spice: ${spice}`);
console.log('Done.');

View File

@ -2,7 +2,7 @@
//
// Usage: node examples/extract.js [magFile [tech [outputDir]]]
import { createMagic, vfsWrite, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers.js';
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT, reportError } from './helpers.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -34,7 +34,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDi
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { ext, spice } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { ext, spice } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\next: ${ext}`);
if (spice) console.log(`spice: ${spice}`);
console.log('Done.');

View File

@ -1,6 +1,6 @@
// gds-tcl.js — GDS export via the TCL variant.
import { createMagic, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers-tcl.js';
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT, reportError } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -22,7 +22,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDi
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { outPath, bytes } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { outPath, bytes } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\ngds: ${outPath} (${bytes} bytes)`);
console.log('Done.');
}

View File

@ -2,7 +2,7 @@
//
// Usage: node examples/gds.js [magFile [tech [outputDir]]]
import { createMagic, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers.js';
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT, reportError } from './helpers.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -24,7 +24,7 @@ export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDi
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { outPath, bytes } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const { outPath, bytes } = await run().catch(e => { reportError(e); process.exit(1); });
console.log(`\ngds: ${outPath} (${bytes} bytes)`);
console.log('Done.');
}

View File

@ -43,7 +43,14 @@ export class MagicWasm {
const path = '/tmp/_magic_script.tcl';
this.FS.mkdirTree('/tmp');
this.FS.writeFile(path, text);
this._sourceFile(path);
try {
this._sourceFile(path);
} catch (e) {
// A WASM trap during Tcl evaluation otherwise gives no hint of the
// source; flag it before the error propagates.
console.error('[magic-tcl] script evaluation failed');
throw e;
}
}
// Evaluate a Tcl expression directly (needed for proc definitions in PCell).
runTcl(text) {
@ -74,3 +81,11 @@ export function loadScript(name, tech, cell) {
.replaceAll('__TECH__', tech)
.replaceAll('__CELL__', cell);
}
// Print a failure with enough detail to be actionable in CI. WASM aborts
// surface as a RuntimeError whose .message is terse while .stack carries the
// wasm-function offsets that emsymbolizer maps back to C source — so always
// prefer the stack.
export function reportError(e) {
console.error(e?.stack ?? String(e?.message ?? e));
}

View File

@ -38,11 +38,28 @@ export class MagicWasm {
runScript(text) {
for (const line of text.split('\n')) {
const l = line.trim();
if (l && !l.startsWith('#')) this._run(l);
if (l && !l.startsWith('#')) {
try {
this._run(l);
} catch (e) {
// Name the command that aborted before the error propagates — a
// WASM trap otherwise gives no hint which step failed.
console.error(`[magic] command failed: ${l}`);
throw e;
}
}
}
}
}
// Print a failure with enough detail to be actionable in CI. WASM aborts
// surface as a RuntimeError whose .message is terse ("memory access out of
// bounds") while .stack carries the wasm-function offsets that emsymbolizer
// maps back to C source — so always prefer the stack.
export function reportError(e) {
console.error(e?.stack ?? String(e?.message ?? e));
}
// Creates a fresh Magic WASM instance and calls init().
// onPrint / onPrintErr default to console.log/error with a [magic] prefix.
// All output lines are also collected into the returned `lines` array.

View File

@ -4,7 +4,7 @@
// and verifies that both GDS outputs are non-empty.
//
// Usage: node examples/pcell.js
import { createMagic, vfsRead, loadScript, DEFAULT_TECH, DEFAULT_OUT } from './helpers-tcl.js';
import { createMagic, vfsRead, loadScript, DEFAULT_TECH, DEFAULT_OUT, reportError } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -32,7 +32,7 @@ export async function run({ tech = DEFAULT_TECH, outputDir = DEFAULT_OUT } = {})
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const results = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
const results = await run().catch(e => { reportError(e); process.exit(1); });
for (const [name, { outPath, bytes }] of Object.entries(results))
console.log(` ${name}.gds: ${outPath} (${bytes} bytes)`);
console.log('Done.');