diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 7c5ba80bb6a..79b814a3de1 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -68,6 +68,29 @@ class ResolvedSourceCodeHolder { extern "C" BunLoaderType Bun__getDefaultLoader(JSC::JSGlobalObject*, BunString* specifier); +#if ENABLE(WEBASSEMBLY) +// Rebuild the raw wasm bytes packed into source_code (Latin-1) into a WebAssemblySourceProvider. +static JSC::SourceCode sourceCodeForWasm(ResolvedSource& resolved, BunString* specifier) +{ + WTF::String wasmSource = resolved.source_code.toWTFString(BunString::NonNull); + Vector wasmBytes; + if (wasmSource.is8Bit()) { + wasmBytes.append(wasmSource.span8()); + } else { + auto span = wasmSource.span16(); + wasmBytes.reserveInitialCapacity(span.size()); + for (auto ch : span) + wasmBytes.append(static_cast(ch & 0xff)); + } + + auto moduleKey = specifier->toWTFString(BunString::ZeroCopy); + auto sourceUrlString = resolved.source_url.toWTFString(BunString::ZeroCopy); + auto sourceURL = !sourceUrlString.isEmpty() ? WTF::URL::fileURLWithFileSystemPath(sourceUrlString) : WTF::URL(); + auto provider = JSC::WebAssemblySourceProvider::create(WTF::move(wasmBytes), JSC::SourceOrigin(sourceURL), WTF::move(moduleKey)); + return JSC::SourceCode(WTF::move(provider)); +} +#endif + static JSC::JSPromise* rejectedInternalPromise(JSC::JSGlobalObject* globalObject, JSC::JSValue value) { auto& vm = JSC::getVM(globalObject); @@ -391,6 +414,11 @@ static JSValue handleVirtualModuleResult( if (!res->success) { RELEASE_AND_RETURN(scope, reject(JSValue::decode(res->result.err.value))); } +#if ENABLE(WEBASSEMBLY) + if (res->result.value.tag == SyntheticModuleType::Wasm) { + return resolve(JSC::JSSourceCode::create(vm, sourceCodeForWasm(res->result.value, specifier))); + } +#endif auto provider = Zig::SourceProvider::create(globalObject, res->result.value); return resolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(provider))); @@ -1176,6 +1204,11 @@ static JSValue fetchESMSourceCode( JSC::ensureStillAliveHere(value); RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); } +#if ENABLE(WEBASSEMBLY) + else if (res->result.value.tag == SyntheticModuleType::Wasm) { + RELEASE_AND_RETURN(scope, rejectOrResolve(JSC::JSSourceCode::create(vm, sourceCodeForWasm(res->result.value, specifier)))); + } +#endif auto provider = Zig::SourceProvider::create(globalObject, res->result.value); if (useIsolationCache) { diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index d8905ec4115..1052da6f987 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -943,6 +943,8 @@ pub mod resolved_source_tag { pub const Javascript: Self = Self(0); pub const PackageJsonTypeModule: Self = Self(1); pub const PackageJsonTypeCommonjs: Self = Self(2); + /// Raw wasm bytes in `source_code` (Latin-1) → `JSC::WebAssemblySourceProvider`. + pub const Wasm: Self = Self(3); pub const File: Self = Self(5); pub const Esm: Self = Self(6); pub const JsonForObjectLoader: Self = Self(7); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 0c719212132..3142f09c5ab 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -3243,13 +3243,64 @@ fn transpile_source_code_inner( })); } } - // Recurse as `.file`. - // SAFETY: per fn contract — `extra` is live for the call. - unsafe { - (*extra).loader = L::File; - (*extra).module_type = ModuleType::Unknown; + + // `?query` specifiers are asset/cache-bust URLs (#16476), not real wasm imports. + if bun_core::strings::contains_char(specifier, b'?') { + // SAFETY: per fn contract — `extra` is live for the call. + unsafe { + (*extra).loader = L::File; + (*extra).module_type = ModuleType::Unknown; + } + return transpile_source_code_inner(jsc_vm, args, extra); } - transpile_source_code_inner(jsc_vm, args, extra) + + let check_magic = |bytes: &[u8]| -> crate::Result<()> { + if bytes.len() < 4 || &bytes[0..4] != b"\x00asm" { + // SAFETY: per fn contract — `args.log` is live for the call. + unsafe { &mut *args.log }.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "Invalid wasm file {} (missing magic header)", + bun_core::fmt::quote(path.text), + ), + ); + return Err(crate::Error::ParseError); + } + Ok(()) + }; + + let source_code = if let Some(source) = args.virtual_source { + check_magic(&source.contents)?; + bun_core::String::clone_latin1(&source.contents) + } else { + // Register with the watcher even on read/validate failure so fixing the file triggers a reload. + auto_watch_asset(jsc_vm, path, L::Wasm); + match bun_sys::File::read_from(bun_sys::Fd::cwd(), path.text) { + Ok(bytes) => { + check_magic(&bytes)?; + bun_core::String::create_external_globally_allocated_latin1(bytes) + } + Err(err) => { + // SAFETY: per fn contract — `args.log` is live for the call. + unsafe { &mut *args.log }.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!("{} reading {}", err, bun_core::fmt::quote(path.text)), + ); + return Err(crate::Error::ParseError); + } + } + }; + + use bun_jsc::resolved_source::Tag as ResolvedSourceTag; + Ok(OwnedResolvedSource::from(ResolvedSource { + source_code, + specifier: input_specifier.dupe_ref(), + source_url: create_if_different(input_specifier, path.text), + tag: ResolvedSourceTag::Wasm, + ..Default::default() + })) } // ──────────────────────────────────────────────────────────────────── @@ -3323,60 +3374,8 @@ fn transpile_source_code_inner( })); } - // auto-watch for non-virtual absolute paths. - 'auto_watch: { - if args.virtual_source.is_some() { - break 'auto_watch; - } - // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. - if !unsafe { &*jsc_vm }.is_watcher_enabled() { - break 'auto_watch; - } - if !bun_paths::is_absolute(path.text) - || bun_core::strings::contains(path.text, b"node_modules") - { - break 'auto_watch; - } - // kqueue watchers need a file descriptor to receive event - // notifications on it; inotify/win32 watch by path. - let input_fd = if bun_watcher::REQUIRES_FILE_DESCRIPTORS { - let mut buf = bun_paths::path_buffer_pool::get(); - if path.text.len() >= buf.len() { - break 'auto_watch; - } - let z = bun_paths::resolve_path::z(path.text, &mut buf); - match bun_sys::open(z, bun_watcher::WATCH_OPEN_FLAGS, 0) { - Ok(fd) => fd, - Err(_) => break 'auto_watch, - } - } else { - bun_sys::Fd::INVALID - }; - let hash = bun_watcher::Watcher::get_hash(path.text); - // SAFETY: `bun_watcher` is the `*mut ImportWatcher` - // set when `is_watcher_enabled()`; cast recovers the concrete - // type. - let watcher = - unsafe { &mut *(*jsc_vm).bun_watcher.cast::() }; - if watcher - .add_file::( - input_fd, - path.text, - hash, - loader, - bun_sys::Fd::INVALID, - None, - ) - .is_err() - { - // Close the fd we just opened on macOS; - // not a transpile failure (the user didn't open it). - #[cfg(target_os = "macos")] - if input_fd.is_valid() { - use bun_sys::FdExt as _; - input_fd.close(); - } - } + if args.virtual_source.is_none() { + auto_watch_asset(jsc_vm, path, loader); } // `export default `. @@ -3442,6 +3441,52 @@ fn transpile_source_code_inner( } } +/// Register a non-virtual asset path with the watcher. Opens its own watch fd on kqueue platforms. +fn auto_watch_asset(jsc_vm: *mut VirtualMachine, path: &Fs::Path, loader: Loader) { + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + if !unsafe { &*jsc_vm }.is_watcher_enabled() { + return; + } + if !bun_paths::is_absolute(path.text) || bun_core::strings::contains(path.text, b"node_modules") + { + return; + } + // kqueue watchers need a file descriptor; inotify/win32 watch by path. + let input_fd = if bun_watcher::REQUIRES_FILE_DESCRIPTORS { + let mut buf = bun_paths::path_buffer_pool::get(); + if path.text.len() >= buf.len() { + return; + } + let z = bun_paths::resolve_path::z(path.text, &mut buf); + match bun_sys::open(z, bun_watcher::WATCH_OPEN_FLAGS, 0) { + Ok(fd) => fd, + Err(_) => return, + } + } else { + bun_sys::Fd::INVALID + }; + let hash = bun_watcher::Watcher::get_hash(path.text); + // SAFETY: `bun_watcher` is the `*mut ImportWatcher` set when + // `is_watcher_enabled()`; cast recovers the concrete type. + let watcher = unsafe { &mut *(*jsc_vm).bun_watcher.cast::() }; + if watcher + .add_file::( + input_fd, + path.text, + hash, + loader, + bun_sys::Fd::INVALID, + None, + ) + .is_err() + && bun_watcher::REQUIRES_FILE_DESCRIPTORS + && input_fd.is_valid() + { + use bun_sys::FdExt as _; + input_fd.close(); + } +} + /// Register the just-opened file /// with the dev-server watcher (if enabled, absolute, and not in /// `node_modules`). Factored out of the two call sites. @@ -4263,6 +4308,11 @@ unsafe fn transpile_file( } } + // WebAssembly/ESM integration is ESM-only; `require('./x.wasm')` keeps returning the path. + if is_commonjs_require && lr.loader == Some(Loader::Wasm) { + lr.loader = Some(Loader::File); + } + // ── module_type sniff from extension / package.json ───────────────────── let module_type: ModuleType = 'brk: { let ext = lr.path.name().ext; @@ -4572,11 +4622,8 @@ unsafe fn transpile_virtual_module( let specifier_slice = unsafe { &*specifier_ptr }.to_utf8(); let specifier = specifier_slice.slice(); // SAFETY: per fn contract. - let source_code_slice = unsafe { &*source_code }.to_slice(); - // SAFETY: per fn contract. let referrer_slice = unsafe { &*referrer_ptr }.to_utf8(); - let virtual_source = bun_ast::Source::init_path_string(specifier, source_code_slice.slice()); let mut log = bun_ast::Log::init(); // SAFETY: `TranspileExtra::path` is typed `'static` for the cross-crate // fn-ptr ABI; the borrow actually lives only for this call (the `extra` @@ -4606,6 +4653,18 @@ unsafe fn transpile_virtual_module( }) }; + // SAFETY: per fn contract. + let source_code_ref = unsafe { &*source_code }; + // Wasm is binary: read the raw bytes instead of Latin-1→UTF-8 transcoding. + let source_code_slice; + let source_bytes: &[u8] = if loader == Loader::Wasm { + source_code_ref.byte_slice() + } else { + source_code_slice = source_code_ref.to_slice(); + source_code_slice.slice() + }; + let virtual_source = bun_ast::Source::init_path_string(specifier, source_bytes); + // Reset the module loader's arena on scope exit. // `jsc_vm` is the live per-thread VM (BackRef invariant). let _reset_arena = ArenaResetGuard::new(jsc_vm); diff --git a/test/bundler/bun-build-compile-wasm.test.ts b/test/bundler/bun-build-compile-wasm.test.ts index 9708be2dab5..995bfb76550 100644 --- a/test/bundler/bun-build-compile-wasm.test.ts +++ b/test/bundler/bun-build-compile-wasm.test.ts @@ -10,7 +10,7 @@ describe("Bun.build compile with wasm", () => { const dir = tempDirWithFiles("build-compile-wasm", { "app.js": ` // Import a wasm module and properly instantiate it - import wasmPath from "./test.wasm"; + import wasmPath from "./test.wasm" with { type: "file" }; async function main() { try { @@ -122,5 +122,5 @@ describe("Bun.build compile with wasm", () => { expect(stdout).toContain("WASM result: 5"); expect(stdout).toContain("WASM module loaded successfully"); expect(stderr).toBe(""); - }); + }, 60_000); }); diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index daeccfa1acf..f884a964c70 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -257,7 +257,7 @@ describe("bundler", async () => { files: { "/entry.ts": /* js */ ` - import wasm from './add.wasm'; + import wasm from './add.wasm' with { type: "file" }; import { join } from 'path'; const { instance } = await WebAssembly.instantiate(await Bun.file(join(import.meta.dir, wasm)).arrayBuffer()); console.log(instance.exports.add(1, 2)); diff --git a/test/js/bun/resolve/import-empty.test.js b/test/js/bun/resolve/import-empty.test.js index 2150bd7838f..c668f4a3acb 100644 --- a/test/js/bun/resolve/import-empty.test.js +++ b/test/js/bun/resolve/import-empty.test.js @@ -73,7 +73,6 @@ it("importing empty jsonc/toml file returns module with empty object as default it("importing empty file returns module with path as default export", async () => { const other_types = [ - "wasm", // "napi", // marked unreachable in src/jsc/ModuleLoader.zig:1956:22 "base64", "dataurl", @@ -87,6 +86,21 @@ it("importing empty file returns module with path as default export", async () = } }); +// WebAssembly/ESM integration instantiates the module at import time; an +// empty file has no magic header so it fails to load. Node with +// --experimental-wasm-modules behaves the same way. +it("importing empty file with type wasm throws a magic-header error", async () => { + delete require.cache[require.resolve(`./empty-file`)]; + let err; + try { + await import("./empty-file", { with: { type: "wasm" } }); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(String(err?.message ?? "")).toMatch(/magic header/i); +}); + // MARK: - sqlite it("importing empty sqlite files returns database object", async () => { diff --git a/test/js/bun/wasm/add.wasm b/test/js/bun/wasm/add.wasm new file mode 100644 index 00000000000..e077f5aa8a8 Binary files /dev/null and b/test/js/bun/wasm/add.wasm differ diff --git a/test/js/bun/wasm/esm-integration.test.ts b/test/js/bun/wasm/esm-integration.test.ts new file mode 100644 index 00000000000..b84b0ce412e --- /dev/null +++ b/test/js/bun/wasm/esm-integration.test.ts @@ -0,0 +1,282 @@ +// https://github.com/oven-sh/bun/issues/12434 +// https://github.com/oven-sh/bun/issues/30369 +// +// `import * as m from "./file.wasm"` (and `await import("./file.wasm")`) used +// to resolve to `{ __esModule: true, default: "" }` because the .wasm +// loader fell through to the .file loader. Node with --experimental-wasm-modules +// (the WebAssembly/ESM integration proposal) instantiates the module and +// exposes its exports as named ES module exports. +// +// Existing asset-path behaviour is preserved for `?query` specifiers (see +// #16476) and for `require("./x.wasm")`. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Exports `add(i32,i32)->i32` and `memory`. No imports, so ESM integration +// instantiates it with an empty import object. +const addWasmBytes = readFileSync(join(import.meta.dir, "add.wasm")); + +// Imports `jsFn`/`jsInitFn` from "./wasm-dep.mjs"; exports `add`/`addImported`. +// Exercises the wasm → JS module dependency path (Bun resolving a .wasm-keyed +// referrer and linking JS exports into wasm import bindings). +const simpleWasmBytes = readFileSync( + join(import.meta.dir, "..", "..", "node", "test", "fixtures", "es-modules", "simple.wasm"), +); + +describe("wasm ES module integration (#12434)", () => { + test.concurrent("dynamic import exposes wasm exports as named ES module exports", async () => { + using dir = tempDir("wasm-esm-dynamic", { + "add.wasm": addWasmBytes, + "index.js": ` + const m = await import("./add.wasm"); + console.log(JSON.stringify({ + add: typeof m.add, + memory: m.memory?.constructor?.name, + addResult: m.add(2, 3), + hasDefault: "default" in m, + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + add: "function", + memory: "Memory", + addResult: 5, + hasDefault: false, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("static `import * as` exposes wasm exports as named ES module exports", async () => { + using dir = tempDir("wasm-esm-static", { + "add.wasm": addWasmBytes, + "index.js": ` + import * as wasm from "./add.wasm"; + console.log(JSON.stringify({ + keys: Object.keys(wasm).sort(), + add: typeof wasm.add, + memory: wasm.memory?.constructor?.name, + result: wasm.add(10, 32), + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + keys: ["add", "memory"], + add: "function", + memory: "Memory", + result: 42, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("wasm modules can import from JS modules", async () => { + using dir = tempDir("wasm-esm-imports", { + "simple.wasm": simpleWasmBytes, + "wasm-dep.mjs": ` + export function jsFn() { return 42; } + export function jsInitFn() {} + `, + "index.js": ` + import * as m from "./simple.wasm"; + console.log(JSON.stringify({ + keys: Object.keys(m).sort(), + add: m.add(3, 4), + addImported: m.addImported(10), + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + keys: ["add", "addImported"], + add: 7, + addImported: 52, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("named imports from a wasm module work", async () => { + using dir = tempDir("wasm-esm-named", { + "add.wasm": addWasmBytes, + "index.js": ` + import { add } from "./add.wasm"; + console.log(add(100, 23)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("123"); + expect(exitCode).toBe(0); + }); + + test.concurrent("`?query` on a .wasm specifier keeps the legacy path-as-default behaviour (#16476)", async () => { + using dir = tempDir("wasm-query-path", { + "add.wasm": addWasmBytes, + "index.js": ` + const m = await import("./add.wasm?1"); + console.log(JSON.stringify({ + default: m.default, + __esModule: m.__esModule, + hasAdd: typeof m.add, + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const parsed = JSON.parse(stdout); + expect(parsed.__esModule).toBe(true); + expect(parsed.hasAdd).toBe("undefined"); + expect(parsed.default).toMatch(/add\.wasm$/); + expect(exitCode).toBe(0); + }); + + test.concurrent( + "`with { type: 'file' }` on a .wasm specifier keeps the legacy path-as-default behaviour", + async () => { + using dir = tempDir("wasm-type-file", { + "add.wasm": addWasmBytes, + "index.js": ` + const m = await import("./add.wasm", { with: { type: "file" } }); + console.log(JSON.stringify({ + default: m.default, + hasAdd: typeof m.add, + })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const parsed = JSON.parse(stdout); + expect(parsed.hasAdd).toBe("undefined"); + expect(parsed.default).toMatch(/add\.wasm$/); + expect(exitCode).toBe(0); + }, + ); + + test.concurrent("require('./x.wasm') keeps the legacy path-as-value behaviour", async () => { + using dir = tempDir("wasm-require-path", { + "add.wasm": addWasmBytes, + "index.cjs": `console.log(require("./add.wasm"));`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.cjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toMatch(/add\.wasm$/); + expect(exitCode).toBe(0); + }); + + test.concurrent("Bun.plugin virtual module with a .wasm specifier exposes wasm exports", async () => { + using dir = tempDir("wasm-esm-plugin", { + "add.wasm": addWasmBytes, + "preload.js": ` + import { readFileSync } from "node:fs"; + Bun.plugin({ + name: "virtual-wasm", + setup(build) { + build.module("virtual-add.wasm", () => ({ + contents: readFileSync(import.meta.dir + "/add.wasm"), + })); + }, + }); + `, + "index.js": ` + const m = await import("virtual-add.wasm"); + console.log(JSON.stringify({ add: typeof m.add, result: m.add(7, 8) })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preload", "./preload.js", "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ add: "function", result: 15 }); + expect(exitCode).toBe(0); + }); + + test.concurrent("importing a file with a bad wasm magic header throws a load error", async () => { + using dir = tempDir("wasm-bad-magic", { + "bad.wasm": "not a wasm module", + "index.js": ` + try { + await import("./bad.wasm"); + console.log(JSON.stringify({ threw: false })); + } catch (e) { + console.log(JSON.stringify({ + threw: true, + name: e?.name ?? "", + message: String(e?.message ?? ""), + })); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const parsed = JSON.parse(stdout); + expect(parsed.threw).toBe(true); + expect(parsed.message).toMatch(/magic header/i); + expect(exitCode).toBe(0); + }); +});