toThrow doesn't catch exceptions thrown from called functions inside the closure (only direct throws)
Summary
expect((): void => { ... }).toThrow() catches a direct throw/abort written inside the closure, but not an exception raised by a function the closure calls (especially across a module boundary). The callee's throw/abort is left un-rewritten, so at runtime it hits the real abort and fatally traps the module instead of being caught.
This makes toThrow unusable for testing that a library rejects bad input, since real libraries reject deep in called code (e.g. a parser's JSON.parse(...) → internal validator → throw/abort), not with a literal throw in the test closure.
Reproduction
Two files compiled with as-test + --enable try-as (WASI target):
// shapes.ts
export function deep(): void { throw new Error("boom"); }
export function mid(): void { deep(); }
export function abortDeep(): void { abort("boom"); }
export function abortMid(): void { abortDeep(); }
// repro.spec.ts
import { mid, abortMid } from "./shapes";
import { describe, expect } from "as-test";
describe("toThrow", () => {
// ✅ PASSES — direct throw in the closure is rewritten
expect((): void => { throw new Error("boom"); }).toThrow();
// ❌ FAILS (fatal trap: "abort: boom", uncaught) — cross-module throw chain
expect((): void => { mid(); }).toThrow();
// ❌ FAILS (fatal trap) — cross-module abort chain
expect((): void => { abortMid(); }).toThrow();
});
Expected: all three are caught (toThrow passes).
Actual: only the direct-throw case is caught; the two cross-module cases trap fatally (abort: boom in shapes.ts(...) → ~lib/wasi_internal/wasi_abort), aborting the test process.
A single hop is enough to trigger it (closure → mid → deep); it is not specific to deep call graphs.
Root cause
In transform/src/passes/source.ts, the call-expression gate only follows calls when a tracked function or active try is in scope:
// visitCallExpression (~line 237)
if (this.state != "postprocess" && !Globals.lastFn && !Globals.lastTry)
return super.visitCallExpression(node, ref);
An anonymous arrow (the toThrow callback) intentionally sets Globals.parentFn but not Globals.lastFn (see the arrow handling at ~line 147–179, which leaves lastFn unset for unnamed arrows). So inside the closure, lastFn and lastTry are both null, and this gate returns early for every call — mid() is never resolved/followed, so mid/deep's throw/abort are never rewritten to bump __ExceptionState.Failures. At runtime they hit the real abort.
Notably the throw-statement gate just below already allows parentFn:
// visitThrowStatement (~line 328)
if (this.state != "postprocess" && !Globals.lastTry && !Globals.parentFn)
return super.visitThrowStatement(node, ref);
— which is why a direct throw in the closure is rewritten but a called function's throw is not. The call gate and the throw gate are inconsistent.
Why the obvious one-line fix isn't enough
Adding && !Globals.parentFn to the call gate (line 237) does make closure-body calls get followed — but it then also follows the enclosing expect(...).toThrow() chain, marks expect exception-capable, and renames it to __try_expect, which is never generated:
ERROR TS2304: Cannot find name '__try_expect'.
So a correct fix needs to:
- Follow calls inside the closure body (the gate change), but
- keep the matcher chain / external (
node_modules) calls opaque — expect/toThrow must not be followed or renamed (they invoke the closure and consume the failure via the Failures delta themselves), and
- resolve the harder call-target forms real libraries use — namespace methods (
JSON.parse), generics (<T>), and instance/struct method dispatch (obj.method(...)) — which findFn/resolveMethodRef don't currently fully trace into.
Impact
Any project using expect(() => lib.doThing(bad)).toThrow() where the rejection happens inside the library (rather than as a literal throw in the test closure) cannot test rejection — the test process traps. Discovered while wiring an RFC 8259 conformance suite for json-as (which rejects malformed JSON via throw/abort deep in its deserializers).
Environment
- try-as:
1.1.0
- Harness:
as-test with --enable try-as (AS_TEST_TRY_AS=1, AS_TEST_WASI=1), WASI target via @assemblyscript/wasi-shim, run under wasmtime.
- Modes: reproduces in all (naive/swar/simd) json-as builds.
toThrowdoesn't catch exceptions thrown from called functions inside the closure (only directthrows)Summary
expect((): void => { ... }).toThrow()catches a directthrow/abortwritten inside the closure, but not an exception raised by a function the closure calls (especially across a module boundary). The callee'sthrow/abortis left un-rewritten, so at runtime it hits the realabortand fatally traps the module instead of being caught.This makes
toThrowunusable for testing that a library rejects bad input, since real libraries reject deep in called code (e.g. a parser'sJSON.parse(...)→ internal validator →throw/abort), not with a literalthrowin the test closure.Reproduction
Two files compiled with
as-test+--enable try-as(WASI target):Expected: all three are caught (
toThrowpasses).Actual: only the direct-throw case is caught; the two cross-module cases trap fatally (
abort: boom in shapes.ts(...)→~lib/wasi_internal/wasi_abort), aborting the test process.A single hop is enough to trigger it (
closure → mid → deep); it is not specific to deep call graphs.Root cause
In
transform/src/passes/source.ts, the call-expression gate only follows calls when a tracked function or active try is in scope:An anonymous arrow (the
toThrowcallback) intentionally setsGlobals.parentFnbut notGlobals.lastFn(see the arrow handling at ~line 147–179, which leaveslastFnunset for unnamed arrows). So inside the closure,lastFnandlastTryare both null, and this gate returns early for every call —mid()is never resolved/followed, somid/deep'sthrow/abortare never rewritten to bump__ExceptionState.Failures. At runtime they hit the realabort.Notably the throw-statement gate just below already allows
parentFn:— which is why a direct
throwin the closure is rewritten but a called function's throw is not. The call gate and the throw gate are inconsistent.Why the obvious one-line fix isn't enough
Adding
&& !Globals.parentFnto the call gate (line 237) does make closure-body calls get followed — but it then also follows the enclosingexpect(...).toThrow()chain, marksexpectexception-capable, and renames it to__try_expect, which is never generated:So a correct fix needs to:
node_modules) calls opaque —expect/toThrowmust not be followed or renamed (they invoke the closure and consume the failure via theFailuresdelta themselves), andJSON.parse), generics (<T>), and instance/struct method dispatch (obj.method(...)) — whichfindFn/resolveMethodRefdon't currently fully trace into.Impact
Any project using
expect(() => lib.doThing(bad)).toThrow()where the rejection happens inside the library (rather than as a literalthrowin the test closure) cannot test rejection — the test process traps. Discovered while wiring an RFC 8259 conformance suite forjson-as(which rejects malformed JSON viathrow/abortdeep in its deserializers).Environment
1.1.0as-testwith--enable try-as(AS_TEST_TRY_AS=1,AS_TEST_WASI=1), WASI target via@assemblyscript/wasi-shim, run underwasmtime.