diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..d7a514e3 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,68 @@ +name: CI +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + test_wasm32: + name: wasm32 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + # llvm-mc used + - name: Install LLVM + uses: KyleMayes/install-llvm-action@v2 + with: + # rustc 1.93.0 + version: "21.1.8" + - name: Test js-sys + working-directory: client + run: cargo test -p js-sys --target wasm32-unknown-unknown + - name: Test web-sys + working-directory: client + run: cargo test -p web-sys --target wasm32-unknown-unknown + + test_wasm64: + name: wasm64 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + # llvm-mc used + - name: Install LLVM + uses: KyleMayes/install-llvm-action@v2 + with: + # rustc 1.93.0 + version: "21.1.8" + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 25 + - name: Test js-sys + working-directory: client + run: cargo +nightly test -p js-sys --target wasm64-unknown-unknown -Zbuild-std=panic_abort,std + - name: Test web-sys + working-directory: client + run: cargo +nightly test -p web-sys --target wasm64-unknown-unknown -Zbuild-std=panic_abort,std + diff --git a/TEST.md b/TEST.md index 336be5ae..a0039ed5 100644 --- a/TEST.md +++ b/TEST.md @@ -1,17 +1,100 @@ -Manual testing commands until CI is set up: +Manual testing commands (CI not set up yet). + +Quick build (from repo root): + +```sh +cd client +cargo build --example basic +cargo build --example basic --release --timings +``` + +Test examples: + +```rust +#[js_bindgen_test::test] +fn test_wasm() {} + +#[js_bindgen_test::test] +#[ignore = "hah, it works"] +fn test_ignore() { + panic!("kaboom"); +} + +#[js_bindgen_test::test] +#[should_panic(expected = "kaboom")] +fn test_should_panic() { + panic!("kaboom"); +} +``` + +Wasm tests: Node runner (default): + +```sh +cargo test --target wasm32-unknown-unknown +``` + +Wasm tests: Browser runner (Playwright headless): + +```sh +JBTEST_BROWSER=chromium cargo test --target wasm32-unknown-unknown +``` + +Wasm tests: Browser server mode (open URL in a browser): ```sh -cargo build --example basic --release --timings && wasm-tools print -o examples/basic.wat target/wasm32-unknown-unknown/release/examples/basic.wasm && wasm-tools validate examples/basic.wat +JBTEST_SERVER=1 cargo test --target wasm32-unknown-unknown ``` +Runner options: + +- `JBTEST_BROWSER=chromium|firefox|webkit` +- `JBTEST_WORKER=dedicated|shared|service` +- `JBTEST_SERVER=1` (serve browser runner and print URL) +- `JBTEST_SERVER_ADDRESS=127.0.0.1:8000` (defaults to 8000; falls back to a random port if busy) + +List and filter tests: + +```sh +cargo test --target wasm32-unknown-unknown -- --list +cargo test --target wasm32-unknown-unknown -- --list --format terse +cargo test --target wasm32-unknown-unknown -- some_substring +cargo test --target wasm32-unknown-unknown -- --exact full::test::name +cargo test --target wasm32-unknown-unknown -- --ignored +``` + +Other test flags: + +```sh +cargo test --target wasm32-unknown-unknown -- --nocapture +``` + +Wasm tooling checks: + ```sh -CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-Ctarget-feature=+atomics -Clink-arg=--shared-memory -Clink-arg=--max-memory=4294967296" cargo +nightly build --example basic -Zbuild-std=panic_abort,std --release --timings && wasm-tools print -o examples/basic.wat target/wasm32-unknown-unknown/release/examples/basic.wasm && wasm-tools validate examples/basic.wat +cd client +wasm-tools print -o examples/basic.wat target/wasm32-unknown-unknown/debug/examples/basic.wasm +wasm-tools validate examples/basic.wat ``` +Shared-memory build (nightly): + ```sh -cargo +nightly build --example basic --target wasm64-unknown-unknown -Zbuild-std=panic_abort,std --release --timings && wasm-tools print -o examples/basic.wat target/wasm64-unknown-unknown/release/examples/basic.wasm && wasm-tools validate examples/basic.wat +cd client +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-Ctarget-feature=+atomics -Clink-arg=--shared-memory -Clink-arg=--max-memory=4294967296" \ + cargo +nightly build --example basic -Zbuild-std=panic_abort,std --release --timings ``` +Wasm64 builds (nightly): + ```sh -CARGO_TARGET_WASM64_UNKNOWN_UNKNOWN_RUSTFLAGS="-Ctarget-feature=+atomics -Clink-arg=--shared-memory -Clink-arg=--max-memory=17179869184" cargo +nightly build --example basic --target wasm64-unknown-unknown -Zbuild-std=panic_abort,std --release --timings && wasm-tools print -o examples/basic.wat target/wasm64-unknown-unknown/release/examples/basic.wasm && wasm-tools validate examples/basic.wat +cd client +cargo +nightly build --example basic --target wasm64-unknown-unknown -Zbuild-std=panic_abort,std --release --timings ``` + +```sh +cd client +CARGO_TARGET_WASM64_UNKNOWN_UNKNOWN_RUSTFLAGS="-Ctarget-feature=+atomics -Clink-arg=--shared-memory -Clink-arg=--max-memory=17179869184" \ + cargo +nightly build --example basic --target wasm64-unknown-unknown -Zbuild-std=panic_abort,std --release --timings +``` + + diff --git a/client/.cargo/config.toml b/client/.cargo/config.toml index 5df86c2c..9c9c8e6e 100644 --- a/client/.cargo/config.toml +++ b/client/.cargo/config.toml @@ -3,3 +3,4 @@ target = "wasm32-unknown-unknown" [target.'cfg(all(target_family = "wasm", any(target_os = "none", target_os = "unknown")))'] linker = "../linker-shim/linker" +runner = "../linker-shim/test-runner" diff --git a/client/Cargo.toml b/client/Cargo.toml index 37199e5c..4329ce14 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["js-sys", "web-sys"] +members = ["js-bindgen-test", "js-sys", "web-sys"] resolver = "2" [workspace.package] @@ -8,6 +8,8 @@ rust-version = "1.85" [workspace.dependencies] js-bindgen = { path = "../host/js-bindgen" } +js-bindgen-test = { path = "js-bindgen-test" } +js-bindgen-test-macro = { path = "../host/js-bindgen-test-macro" } js-sys = { path = "js-sys" } js-sys-macro = { path = "../host/js-sys-macro" } mini-alloc = "1" diff --git a/client/js-bindgen-test/Cargo.toml b/client/js-bindgen-test/Cargo.toml new file mode 100644 index 00000000..dd392edd --- /dev/null +++ b/client/js-bindgen-test/Cargo.toml @@ -0,0 +1,12 @@ +[package] +edition.workspace = true +name = "js-bindgen-test" +rust-version.workspace = true +version = "0.1.0" + +[dependencies] +js-bindgen-test-macro = { workspace = true } +js-sys = { workspace = true } + +[lints] +workspace = true diff --git a/client/js-bindgen-test/src/lib.rs b/client/js-bindgen-test/src/lib.rs new file mode 100644 index 00000000..3d8e4a1f --- /dev/null +++ b/client/js-bindgen-test/src/lib.rs @@ -0,0 +1,57 @@ +pub use js_bindgen_test_macro::test; +use js_sys::JsString; +use std::{cell::RefCell, panic::PanicHookInfo}; + +struct LastPanic { + payload: Option, + info: Option, +} + +thread_local! { + static LAST_PANIC: RefCell = const { RefCell::new(LastPanic { + payload: None, + info: None + }) }; +} + +pub fn set_panic_hook() { + // TODO: Bump rustc to 1.91.0 and remove this func + fn payload_as_str<'a>(info: &'a PanicHookInfo) -> Option<&'a str> { + if let Some(s) = info.payload().downcast_ref::<&str>() { + Some(s) + } else if let Some(s) = info.payload().downcast_ref::() { + Some(s) + } else { + None + } + } + + static HOOK: std::sync::Once = std::sync::Once::new(); + + HOOK.call_once(|| { + std::panic::set_hook(Box::new(|info| { + LAST_PANIC.with(|cell| { + *cell.borrow_mut() = LastPanic { + payload: payload_as_str(info).map(|s| s.to_owned()), + info: Some(info.to_string()), + } + }); + })); + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn last_panic_message() -> JsString { + match LAST_PANIC.with(|cell| cell.borrow_mut().info.take()) { + Some(info) => JsString::from_str(&info), + None => JsString::from_str(""), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn last_panic_payload() -> JsString { + match LAST_PANIC.with(|cell| cell.borrow_mut().payload.take()) { + Some(payload) => JsString::from_str(&payload), + None => JsString::from_str(""), + } +} diff --git a/client/web-sys/Cargo.toml b/client/web-sys/Cargo.toml index 3dfae883..d47b4737 100644 --- a/client/web-sys/Cargo.toml +++ b/client/web-sys/Cargo.toml @@ -5,3 +5,6 @@ rust-version = { workspace = true } [dependencies] js-sys = { workspace = true } + +[dev-dependencies] +js-bindgen-test = { workspace = true } diff --git a/client/web-sys/src/lib.rs b/client/web-sys/src/lib.rs index 4a344f09..5ef62182 100644 --- a/client/web-sys/src/lib.rs +++ b/client/web-sys/src/lib.rs @@ -17,3 +17,28 @@ pub mod console { pub fn log2(data1: &JsValue, data2: &JsValue); } } + +#[cfg(test)] +mod tests { + use super::console; + use js_bindgen_test::test; + use js_sys::JsString; + + #[test] + fn test_console_log() { + let value = JsString::from_str("hello world"); + console::log(&value); + } + + #[test] + #[ignore = "hah, it works"] + fn test_ignore() { + panic!("kaboom"); + } + + #[test] + #[should_panic(expected = "kaboom")] + fn test_should_panic() { + panic!("kaboom"); + } +} diff --git a/host/Cargo.toml b/host/Cargo.toml index f49c9f69..909a6760 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -1,5 +1,14 @@ [workspace] -members = ["js-bindgen", "js-sys-macro", "ld", "ld-shared", "macro-shared", "wasm-ld-opt"] +members = [ + "js-bindgen", + "js-sys-macro", + "js-bindgen-test-macro", + "js-bindgen-test-runner", + "ld", + "ld-shared", + "macro-shared", + "wasm-ld-opt", +] resolver = "2" [workspace.package] @@ -20,6 +29,7 @@ prettyplease = "0.2" proc-macro2 = { version = "1", default-features = false } quote = { version = "1", default-features = false } rand = { version = "0.9", default-features = false, features = ["thread_rng"] } +serde = { version = "1", default-features = false } serde_json = { version = "1", default-features = false, features = ["alloc"] } similar-asserts = { version = "1", default-features = false } syn = { version = "2", default-features = false, features = ["parsing", "printing"] } diff --git a/host/js-bindgen-test-macro/Cargo.toml b/host/js-bindgen-test-macro/Cargo.toml new file mode 100644 index 00000000..b7f7b7ae --- /dev/null +++ b/host/js-bindgen-test-macro/Cargo.toml @@ -0,0 +1,14 @@ +[package] +edition.workspace = true +name = "js-bindgen-test-macro" +rust-version.workspace = true +version = "0.1.0" + +[lib] +proc-macro = true + +[dependencies] +js-bindgen-macro-shared = { workspace = true } + +[lints] +workspace = true diff --git a/host/js-bindgen-test-macro/src/lib.rs b/host/js-bindgen-test-macro/src/lib.rs new file mode 100644 index 00000000..9b877971 --- /dev/null +++ b/host/js-bindgen-test-macro/src/lib.rs @@ -0,0 +1,309 @@ +use std::iter::{self, Peekable}; + +use js_bindgen_macro_shared::*; +use proc_macro::{ + Delimiter, Group, Ident, Literal, Punct, Spacing, Span, TokenStream, TokenTree, token_stream, +}; + +struct TestAttributes { + ignore: Option>, + should_panic: Option>, +} + +impl TestAttributes { + fn new() -> Self { + Self { + ignore: None, + should_panic: None, + } + } +} + +#[proc_macro_attribute] +pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { + test_internal(attr, item).unwrap_or_else(|e| e) +} + +fn test_internal(attr: TokenStream, item: TokenStream) -> Result { + let mut attr = attr.into_iter(); + if let Some(tok) = attr.next() { + return Err(compile_error(tok.span(), "expected empty attribute")); + } + + let (item, attrs) = strip_test_attributes(item)?; + let (ident, is_async) = find_test_ident(&item)?; + if is_async { + return Err(compile_error(ident.span(), "async tests are not supported")); + } + + let test_name_tokens = test_name_tokens(&ident); + + let section_data = [ + Argument { + cfg: None, + kind: ArgumentKind::Bytes(option_option_string(&attrs.ignore)), + }, + Argument { + cfg: None, + kind: ArgumentKind::Bytes(option_option_string(&attrs.should_panic)), + }, + Argument { + cfg: None, + kind: ArgumentKind::Interpolate(test_name_tokens), + }, + ]; + + let mut output = TokenStream::new(); + output.extend(item); + + let section = custom_section("js_bindgen.test", None, §ion_data); + output.extend(section); + + let wrapper = format!( + r#" +const _: () = {{ + #[unsafe(export_name = ::core::concat!(::core::module_path!(), "::", ::core::stringify!({ident})))] + extern "C" fn jbg_test() {{ + js_bindgen_test::set_panic_hook(); + {ident}(); + }} +}}; +"# + ); + output.extend(wrapper.parse::().unwrap()); + + Ok(output) +} + +fn option_option_string(option: &Option>) -> Vec { + match option { + Some(s) => { + if let Some(s) = s { + let len = u32::to_le_bytes(s.len() as u32); + [2].iter() + .chain(&len) + .chain(s.as_bytes()) + .copied() + .collect::>() + } else { + vec![1] + } + } + None => vec![0], + } +} + +fn find_test_ident(item: &TokenStream) -> Result<(Ident, bool), TokenStream> { + let mut iter = item.clone().into_iter().peekable(); + let mut saw_async = false; + let mut last_span = Span::mixed_site(); + + while let Some(tok) = iter.next() { + last_span = tok.span(); + if let TokenTree::Ident(ident) = &tok { + let name = ident.to_string(); + if name == "async" { + saw_async = true; + } + if name == "fn" { + let ident = parse_ident(&mut iter, ident.span(), "a function name")?; + return Ok((ident, saw_async)); + } + } + } + + Err(compile_error(last_span, "expected a function")) +} + +enum TestAttribute { + Ignore(Option), + ShouldPanic(Option), +} + +fn strip_test_attributes(item: TokenStream) -> Result<(TokenStream, TestAttributes), TokenStream> { + let mut iter = item.into_iter().peekable(); + let mut output = Vec::new(); + let mut attrs = TestAttributes::new(); + + while let Some(tok) = iter.next() { + let TokenTree::Punct(punct) = &tok else { + output.push(tok); + continue; + }; + + if punct.as_char() != '#' { + output.push(tok); + continue; + } + + let Some(TokenTree::Group(group)) = iter.peek() else { + output.push(tok); + continue; + }; + if group.delimiter() != Delimiter::Bracket { + output.push(tok); + continue; + } + + match parse_test_attribute(group)? { + Some(TestAttribute::Ignore(reason)) => { + if attrs.ignore.replace(reason).is_some() { + return Err(compile_error(group.span(), "duplicate `ignore` attribute")); + } + iter.next(); + } + Some(TestAttribute::ShouldPanic(message)) => { + if attrs.should_panic.replace(message).is_some() { + return Err(compile_error( + group.span(), + "duplicate `should_panic` attribute", + )); + } + iter.next(); + } + None => { + output.push(tok); + output.push(TokenTree::Group(group.clone())); + iter.next(); + } + } + } + + Ok((TokenStream::from_iter(output), attrs)) +} + +fn parse_test_attribute(group: &Group) -> Result, TokenStream> { + let mut stream = group.stream().into_iter().peekable(); + let Some(TokenTree::Ident(ident)) = stream.next() else { + return Ok(None); + }; + + match ident.to_string().as_str() { + "ignore" => { + let reason = parse_optional_reason(&mut stream, ident.span())?; + Ok(Some(TestAttribute::Ignore(reason))) + } + "should_panic" => { + let reason = parse_should_panic_reason(&mut stream, ident.span())?; + Ok(Some(TestAttribute::ShouldPanic(reason))) + } + _ => Ok(None), + } +} + +fn parse_optional_reason( + stream: &mut Peekable, + span: Span, +) -> Result, TokenStream> { + if let Some(TokenTree::Punct(punct)) = stream.peek() { + if punct.as_char() == '=' { + let punct = expect_punct(&mut *stream, '=', span, "`=`", false)?; + let (_, reason) = + parse_string_literal(&mut *stream, punct.span(), "a string literal", false)?; + if stream.peek().is_some() { + return Err(compile_error(span, "unexpected tokens")); + } + return Ok(Some(reason)); + } + } + if stream.peek().is_some() { + return Err(compile_error(span, "unexpected tokens")); + } + Ok(None) +} + +fn parse_should_panic_reason( + stream: &mut Peekable, + span: Span, +) -> Result, TokenStream> { + // Support `#[should_panic = "..."]` and `#[should_panic(expected = "...")]`. + if let Some(TokenTree::Punct(punct)) = stream.peek() { + if punct.as_char() == '=' { + let punct = expect_punct(&mut *stream, '=', span, "`=`", false)?; + let (_, reason) = + parse_string_literal(&mut *stream, punct.span(), "a string literal", false)?; + if stream.peek().is_some() { + return Err(compile_error(span, "unexpected tokens")); + } + return Ok(Some(reason)); + } + } + + if let Some(TokenTree::Group(group)) = stream.peek() { + if group.delimiter() != Delimiter::Parenthesis { + return Err(compile_error(span, "unexpected tokens")); + } + let group = match stream.next() { + Some(TokenTree::Group(group)) => group, + _ => return Err(compile_error(span, "unexpected tokens")), + }; + let mut inner = group.stream().into_iter().peekable(); + let Some(TokenTree::Ident(ident)) = inner.next() else { + return Err(compile_error(group.span(), "expected `expected`")); + }; + let name = ident.to_string(); + if name.as_str() != "expected" { + return Err(compile_error(ident.span(), "expected `expected = \"...\"`")); + } + let punct = expect_punct(&mut inner, '=', ident.span(), "`=`", false)?; + let (_, reason) = + parse_string_literal(&mut inner, punct.span(), "a string literal", false)?; + if inner.peek().is_some() || stream.peek().is_some() { + return Err(compile_error(span, "unexpected tokens")); + } + return Ok(Some(reason)); + } + + if stream.peek().is_some() { + return Err(compile_error(span, "unexpected tokens")); + } + + Ok(None) +} + +fn test_name_tokens(ident: &Ident) -> Vec { + let mut args = Vec::new(); + args.extend(path_tokens(&["core", "module_path"])); + args.push(Punct::new('!', Spacing::Alone).into()); + args.push(group(Delimiter::Parenthesis, iter::empty())); + args.push(Punct::new(',', Spacing::Alone).into()); + args.push(Literal::string("::").into()); + args.push(Punct::new(',', Spacing::Alone).into()); + args.extend(path_tokens(&["core", "stringify"])); + args.push(Punct::new('!', Spacing::Alone).into()); + args.push(group( + Delimiter::Parenthesis, + iter::once(ident.clone().into()), + )); + + let mut concat = Vec::new(); + concat.extend(path_tokens(&["core", "concat"])); + concat.push(Punct::new('!', Spacing::Alone).into()); + concat.push(group(Delimiter::Parenthesis, args)); + + concat +} + +fn path_tokens(segments: &[&str]) -> Vec { + let mut tokens = Vec::new(); + tokens.push(Punct::new(':', Spacing::Joint).into()); + tokens.push(Punct::new(':', Spacing::Alone).into()); + + for (index, segment) in segments.iter().enumerate() { + tokens.push(ident(segment)); + if index + 1 < segments.len() { + tokens.push(Punct::new(':', Spacing::Joint).into()); + tokens.push(Punct::new(':', Spacing::Alone).into()); + } + } + + tokens +} + +fn group(delimiter: Delimiter, inner: impl IntoIterator) -> TokenTree { + Group::new(delimiter, inner.into_iter().collect()).into() +} + +fn ident(string: &str) -> TokenTree { + Ident::new(string, Span::mixed_site()).into() +} diff --git a/host/js-bindgen-test-runner/Cargo.toml b/host/js-bindgen-test-runner/Cargo.toml new file mode 100644 index 00000000..d99383ae --- /dev/null +++ b/host/js-bindgen-test-runner/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition = { workspace = true } +name = "js-bindgen-test-runner" +rust-version = { workspace = true } + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +wasmparser = { workspace = true } + +[lints] +workspace = true diff --git a/host/js-bindgen-test-runner/js/browser-runner.mjs b/host/js-bindgen-test-runner/js/browser-runner.mjs new file mode 100644 index 00000000..75ecd804 --- /dev/null +++ b/host/js-bindgen-test-runner/js/browser-runner.mjs @@ -0,0 +1,249 @@ +import { createTextFormatter } from "./shared.mjs"; +import { runTests } from "./runner-core.mjs"; +import consoleHook, { withConsoleCapture } from "./console-hook.mjs"; + +export async function runBrowser({ nocapture, filtered, worker }) { + const baseLog = consoleHook.base.log; + const baseError = consoleHook.base.error; + const baseWarn = consoleHook.base.warn; + const baseInfo = consoleHook.base.info; + const baseDebug = consoleHook.base.debug; + + let result = worker + ? await runInWorker({ + nocapture, + filtered, + worker, + baseLog, + baseError, + baseWarn, + baseInfo, + baseDebug, + }) + : await runInWindow({ nocapture, filtered, consoleHook }); + + if (typeof window !== "undefined") { + window.__jbtestDone = true; + window.__jbtestFailed = result.failed; + } + + return result; +} + +async function runInWindow({ nocapture, filtered, consoleHook }) { + const tests = await (await fetch("/tests.json")).json(); + const wasmBytes = await (await fetch("/wasm")).arrayBuffer(); + const { importObject } = await import("/import.js"); + + const lines = []; + const formatter = createTextFormatter({ + nocapture, + write(line) { + lines.push(line); + appendOutput(line); + }, + }); + + const testInputs = tests.map(test => ({ + ...test, + run(testFn) { + return withConsoleCapture({ + name: test.name, + run: () => testFn(), + emit: event => formatter.onEvent(event), + consoleHook, + forwardToConsole: true, + }); + }, + })); + + const result = await runTests({ + wasmBytes, + importObject, + tests: testInputs, + filtered, + emit: event => formatter.onEvent(event), + }); + + return { lines, failed: result.failed }; +} + +async function runInWorker({ + nocapture, + filtered, + worker, + baseLog, + baseError, + baseWarn, + baseInfo, + baseDebug, +}) { + const lines = []; + + function handleMessage(event) { + const data = event.data || {}; + if (data.type === "user-output") { + switch (data.level) { + case "error": + baseError(data.line); + break; + case "warn": + baseWarn(data.line); + break; + case "info": + baseInfo(data.line); + break; + case "debug": + baseDebug(data.line); + break; + default: + baseLog(data.line); + } + return null; + } + if (data.type === "line") { + lines.push(data.line); + appendOutput(data.line); + return null; + } + if (data.type === "report") { + return { lines: data.lines || lines, failed: data.failed || 0 }; + } + return null; + } + + const workerRunners = { + dedicated: runDedicatedWorker, + shared: runSharedWorker, + service: runServiceWorker, + }; + const runWorker = workerRunners[worker]; + if (!runWorker) { + throw new Error(`unsupported worker worker: ${worker}`); + } + const reportPromise = runWorker({ filtered, nocapture, handleMessage }); + + const report = await reportPromise; + return report; +} + +function runDedicatedWorker({ filtered, nocapture, handleMessage }) { + return new Promise((resolve, reject) => { + let worker; + try { + worker = new Worker("/worker-runner.mjs", { type: "module" }); + } catch (error) { + reject(error); + return; + } + const timeout = setTimeout(() => { + reject(new Error("dedicated worker timed out")); + worker.terminate(); + }, 30000); + worker.onmessage = event => { + const report = handleMessage(event); + if (report) { + clearTimeout(timeout); + resolve(report); + worker.terminate(); + } + }; + worker.onerror = err => reject(err); + worker.postMessage({ filtered, nocapture }); + }); +} + +function runSharedWorker({ filtered, nocapture, handleMessage }) { + return new Promise((resolve, reject) => { + let shared; + try { + shared = new SharedWorker("/worker-runner.mjs", { type: "module" }); + } catch (error) { + reject(error); + return; + } + const port = shared.port; + const timeout = setTimeout(() => { + reject(new Error("shared worker timed out")); + port.close(); + }, 30000); + port.onmessage = event => { + const report = handleMessage(event); + if (report) { + clearTimeout(timeout); + resolve(report); + port.close(); + } + }; + port.onmessageerror = err => reject(err); + port.start(); + port.postMessage({ filtered, nocapture }); + }); +} + +async function runServiceWorker({ filtered, nocapture, handleMessage }) { + if (!navigator.serviceWorker) { + throw new Error("service workers are not supported"); + } + const registration = await navigator.serviceWorker.register("/service-worker.mjs", { + type: "module", + }); + await navigator.serviceWorker.ready; + + if (!navigator.serviceWorker.controller) { + if (!sessionStorage.getItem("jbtest-sw-reload")) { + sessionStorage.setItem("jbtest-sw-reload", "1"); + location.reload(); + return new Promise(() => { }); + } + throw new Error("service worker not controlling the page"); + } + + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = setTimeout(() => { + reject(new Error("service worker timed out")); + channel.port1.close(); + channel.port2.close(); + }, 30000); + channel.port1.onmessage = event => { + const report = handleMessage(event); + if (report) { + clearTimeout(timeout); + resolve(report); + channel.port1.close(); + channel.port2.close(); + } + }; + channel.port1.onmessageerror = err => reject(err); + navigator.serviceWorker.controller.postMessage( + { filtered, nocapture }, + [channel.port2] + ); + }); +} + +function appendOutput(line) { + const output = ensureOutput(); + if (output.textContent.length > 0) { + output.textContent += "\n"; + } + output.textContent += stripAnsi(line); +} + +function stripAnsi(line) { + return line.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function ensureOutput() { + if (typeof document === "undefined") { + return { textContent: "" }; + } + let output = document.getElementById("output"); + if (!output) { + output = document.createElement("pre"); + output.id = "output"; + document.body.append(output); + } + return output; +} diff --git a/host/js-bindgen-test-runner/js/console-hook.mjs b/host/js-bindgen-test-runner/js/console-hook.mjs new file mode 100644 index 00000000..d1d6549f --- /dev/null +++ b/host/js-bindgen-test-runner/js/console-hook.mjs @@ -0,0 +1,72 @@ +function installConsoleHook() { + const existing = globalThis.__jbtestConsoleHook; + if (existing) { + return existing; + } + const base = { + log: console.log.bind(console), + error: console.error.bind(console), + warn: console.warn.bind(console), + info: console.info.bind(console), + debug: console.debug.bind(console), + }; + + let hook = null; + let forwardToConsole = false; + function make(level) { + return (...args) => { + if (hook) { + hook(level, args); + if (forwardToConsole) { + base[level](...args); + } + } else { + base[level](...args); + } + }; + } + console.log = make("log"); + console.error = make("error"); + console.warn = make("warn"); + console.info = make("info"); + console.debug = make("debug"); + + const h = { + base, + setHook(fn, forward) { + hook = fn; + forwardToConsole = forward; + }, + clearHook() { + hook = null; + forwardToConsole = false; + }, + }; + globalThis.__jbtestConsoleHook = h; + return h; +} + +export function withConsoleCapture({ name, run, emit, consoleHook, forwardToConsole }) { + consoleHook.setHook( + (level, args) => { + const line = args.join(" "); + const stream = level === "error" || level === "warn" ? "stderr" : "stdout"; + emit({ type: "test-output", name, line, stream, level }); + }, + forwardToConsole + ); + + try { + run(); + return { ok: true }; + } catch (error) { + return { + ok: false, + stack: error.stack + }; + } finally { + consoleHook.clearHook(); + } +} + +export default installConsoleHook(); diff --git a/host/js-bindgen-test-runner/js/node-runner.mjs b/host/js-bindgen-test-runner/js/node-runner.mjs new file mode 100644 index 00000000..eb20d186 --- /dev/null +++ b/host/js-bindgen-test-runner/js/node-runner.mjs @@ -0,0 +1,60 @@ +import fs from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { createTextFormatter } from "./shared.mjs"; +import { runTests } from "./runner-core.mjs"; +import consoleHook, { withConsoleCapture } from "./console-hook.mjs"; + +const wasmPath = process.env.JS_BINDGEN_WASM; +const importsPath = process.env.JS_BINDGEN_IMPORTS; +const testsJson = process.env.JS_BINDGEN_TESTS; +const nocapture = process.env.JS_BINDGEN_NOCAPTURE === "1"; +const filtered = Number(process.env.JS_BINDGEN_FILTERED || "0"); + +if (!wasmPath || !importsPath || !testsJson) { + console.error("missing test runner environment"); + process.exit(1); +} + +const { importObject } = await import(pathToFileURL(importsPath)); +const wasmBytes = await fs.readFile(wasmPath); +const tests = JSON.parse(testsJson); + +const baseLog = consoleHook.base.log; +const baseError = consoleHook.base.error; +const formatter = createTextFormatter({ + nocapture, + write(line, stream) { + if (stream === "stderr") { + baseError(line); + } else { + baseLog(line); + } + }, +}); + +function emit(event) { + formatter.onEvent(event); +} + +const testInputs = tests.map(test => ({ + ...test, + run(testFn) { + return withConsoleCapture({ + name: test.name, + run: () => testFn(), + emit: event => formatter.onEvent(event), + consoleHook, + forwardToConsole: false, + }); + }, +})); + +const result = await runTests({ + wasmBytes, + importObject, + tests: testInputs, + filtered, + emit, +}); + +process.exit(result.failed === 0 ? 0 : 1); diff --git a/host/js-bindgen-test-runner/js/playwright-runner.mjs b/host/js-bindgen-test-runner/js/playwright-runner.mjs new file mode 100644 index 00000000..19ea90c3 --- /dev/null +++ b/host/js-bindgen-test-runner/js/playwright-runner.mjs @@ -0,0 +1,25 @@ +import { chromium, firefox, webkit } from "playwright"; + +const url = process.env.JBTEST_URL; +if (!url) { + throw new Error("missing JBTEST_URL"); +} + +const browserName = (process.env.JBTEST_BROWSER || "chromium").toLowerCase(); +const browserType = { + chromium, + firefox, + webkit, +}[browserName]; + +if (!browserType) { + throw new Error(`unsupported browser: ${browserName}`); +} + +const browser = await browserType.launch({ + headless: true, +}); +const page = await browser.newPage(); +await page.goto(url); +await page.waitForFunction("window.__jbtestDone === true"); +await browser.close(); diff --git a/host/js-bindgen-test-runner/js/runner-core.mjs b/host/js-bindgen-test-runner/js/runner-core.mjs new file mode 100644 index 00000000..00400d13 --- /dev/null +++ b/host/js-bindgen-test-runner/js/runner-core.mjs @@ -0,0 +1,108 @@ +export async function runTests({ wasmBytes, importObject, tests, filtered, emit }) { + const startTime = Date.now(); + emit({ type: "run-start", total: tests.length, filtered }); + + const { instance } = await WebAssembly.instantiate(wasmBytes, importObject); + const panicPayload = instance.exports.last_panic_payload; + const panicMessage = instance.exports.last_panic_message; + const externrefTable = resolveExternrefTable(importObject); + + let failed = 0; + let ignored = 0; + + for (const test of tests) { + if (test.ignore) { + ignored += 1; + emit({ type: "test-ignored", name: test.name, reason: test.ignore_reason }); + continue; + } + + const testFn = instance.exports[test.name]; + if (typeof testFn !== "function") { + emit({ + type: "test-failed", + name: test.name, + error: `missing export: ${test.name}`, + }); + failed += 1; + continue; + } + + const result = test.run(testFn); + const shouldPanic = test.should_panic; + if (shouldPanic) { + if (result.ok) { + emit({ + type: "test-failed", + name: test.name, + error: "test did not panic as expected", + should_panic: true, + }); + failed += 1; + continue; + } + + const expectedText = test.should_panic_reason; + const payload = coercePanicMessage(panicPayload(), externrefTable); + const message = coercePanicMessage(panicMessage(), externrefTable); + + if (expectedText && !payload.includes(expectedText)) { + const displayPayload = escapeForDisplay(payload); + const displayExpected = escapeForDisplay(expectedText); + emit({ + type: "test-failed", + name: test.name, + error: + message + "\n" + + result.stack + "\n" + + "note: panic did not contain expected string\n" + + ` panic message: "${displayPayload}"\n` + + ` expected substring: "${displayExpected}"`, + should_panic: true, + }); + failed += 1; + continue; + } + + emit({ type: "test-ok", name: test.name, should_panic: true }); + continue; + } + + if (result.ok) { + emit({ type: "test-ok", name: test.name, should_panic: false }); + } else { + const message = coercePanicMessage(panicMessage(), externrefTable); + emit({ type: "test-failed", name: test.name, error: message + "\n" + result.stack }); + failed += 1; + } + } + + emit({ + type: "run-end", + status: failed === 0 ? "ok" : "FAILED", + passed: tests.length - failed - ignored, + failed, + ignored, + filtered, + duration_ms: Date.now() - startTime, + }); + + return { failed }; +} + +function resolveExternrefTable(importObject) { + return importObject["js_sys"]["externref.table"]; +} + +function coercePanicMessage(value, externrefTable) { + const ref = externrefTable.get(value); + externrefTable.set(value, null); + return String(ref); +} + +function escapeForDisplay(value) { + return String(value || "") + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t"); +} diff --git a/host/js-bindgen-test-runner/js/service-worker.mjs b/host/js-bindgen-test-runner/js/service-worker.mjs new file mode 100644 index 00000000..72596acf --- /dev/null +++ b/host/js-bindgen-test-runner/js/service-worker.mjs @@ -0,0 +1,62 @@ +import { createTextFormatter } from "./shared.mjs"; +import { runTests } from "./runner-core.mjs"; +import consoleHook, { withConsoleCapture } from "./console-hook.mjs"; +import { importObject } from "./import.js"; + +self.addEventListener("message", event => { + const port = event.ports && event.ports[0]; + if (!port) { + return; + } + execute(port, event.data).catch(error => { + port.postMessage({ type: "report", lines: [String(error)], failed: 1 }); + }); +}); + +async function execute(port, { nocapture, filtered }) { + const tests = await (await fetch("/tests.json")).json(); + const wasmBytes = await (await fetch("/wasm")).arrayBuffer(); + const lines = []; + const formatter = createTextFormatter({ + nocapture, + write(line) { + lines.push(line); + port.postMessage({ type: "line", line }); + }, + }); + + function emit(event) { + if (event.type === "test-output") { + port.postMessage({ + type: "user-output", + line: event.line, + stream: event.stream, + level: event.level || (event.stream === "stderr" ? "error" : "log"), + }); + } + formatter.onEvent(event); + } + + const testInputs = tests.map(test => ({ + ...test, + run(testFn) { + return withConsoleCapture({ + name: test.name, + run: () => testFn(), + emit, + consoleHook, + forwardToConsole: false, + }); + }, + })); + + const result = await runTests({ + wasmBytes, + importObject, + tests: testInputs, + filtered, + emit, + }); + + port.postMessage({ type: "report", lines, failed: result.failed }); +} diff --git a/host/js-bindgen-test-runner/js/shared.mjs b/host/js-bindgen-test-runner/js/shared.mjs new file mode 100644 index 00000000..408a6fda --- /dev/null +++ b/host/js-bindgen-test-runner/js/shared.mjs @@ -0,0 +1,120 @@ +export function createTextFormatter({ nocapture, write }) { + const buffered = new Map(); + const failed = []; + const failureReports = []; + const green = "\u001b[32m"; + const red = "\u001b[31m"; + const yellow = "\u001b[33m"; + const reset = "\u001b[0m"; + + function buffer(name, line, stream) { + if (!buffered.has(name)) { + buffered.set(name, []); + } + buffered.get(name).push({ line, stream }); + } + + function takeBuffer(name) { + const entries = buffered.get(name); + if (!entries || entries.length === 0) { + return []; + } + buffered.delete(name); + return entries; + } + + return { + onEvent(event) { + switch (event.type) { + case "run-start": + write("", "stdout"); + write(`running ${event.total} tests`, "stdout"); + break; + case "test-output": + if (nocapture) { + write(event.line, event.stream); + } + if (!nocapture) { + buffer(event.name, event.line, event.stream); + } + break; + case "test-ok": + takeBuffer(event.name); + if (event.should_panic) { + write( + `test ${event.name} - should panic ... ${green}ok${reset}`, + "stdout" + ); + } else { + write(`test ${event.name} ... ${green}ok${reset}`, "stdout"); + } + break; + case "test-ignored": + takeBuffer(event.name); + if (event.reason) { + write( + `test ${event.name} ... ${yellow}ignored, ${event.reason}${reset}`, + "stdout" + ); + } else { + write(`test ${event.name} ... ${yellow}ignored${reset}`, "stdout"); + } + break; + case "test-failed": + failed.push(event.name); + if (event.should_panic) { + write( + `test ${event.name} - should panic ... ${red}FAILED${reset}`, + "stdout" + ); + } else { + write(`test ${event.name} ... ${red}FAILED${reset}`, "stdout"); + } + failureReports.push({ + name: event.name, + entries: takeBuffer(event.name), + error: event.error, + }); + break; + case "run-end": + write("", "stdout"); + if (failed.length > 0) { + write("failures:", "stdout"); + write("", "stdout"); + for (const report of failureReports) { + write(`---- ${report.name} stdout ----`, "stdout"); + for (const entry of report.entries) { + write(entry.line, entry.stream); + } + if (report.error) { + write("", "stdout"); + write(report.error, "stdout"); + } + write("", "stdout"); + } + } + const status = + event.status === "ok" + ? `${green}${event.status}${reset}` + : `${red}${event.status}${reset}`; + const durationMs = typeof event.duration_ms === "number" ? event.duration_ms : 0; + const durationSeconds = (durationMs / 1000).toFixed(2); + if (failed.length > 0) { + write("failures:", "stdout"); + for (const name of failed) { + write(` ${name}`, "stdout"); + } + write("", "stdout"); + } + write( + `test result: ${status}. ${event.passed} passed; ${event.failed} failed; ${event.ignored} ignored; 0 measured; ${event.filtered} filtered out; finished in ${durationSeconds}s`, + "stdout" + ); + write("", "stdout"); + break; + default: + break; + } + }, + }; +} diff --git a/host/js-bindgen-test-runner/js/worker-runner.mjs b/host/js-bindgen-test-runner/js/worker-runner.mjs new file mode 100644 index 00000000..5504cb01 --- /dev/null +++ b/host/js-bindgen-test-runner/js/worker-runner.mjs @@ -0,0 +1,71 @@ +import { createTextFormatter } from "./shared.mjs"; +import { runTests } from "./runner-core.mjs"; +import consoleHook, { withConsoleCapture } from "./console-hook.mjs"; + +async function execute(port, { nocapture, filtered }) { + const tests = await (await fetch("/tests.json")).json(); + const wasmBytes = await (await fetch("/wasm")).arrayBuffer(); + const { importObject } = await import("/import.js"); + + const lines = []; + const formatter = createTextFormatter({ + nocapture, + write(line) { + lines.push(line); + port.postMessage({ type: "line", line }); + }, + }); + + function emit(event) { + if (event.type === "test-output") { + port.postMessage({ + type: "user-output", + line: event.line, + stream: event.stream, + level: event.level || (event.stream === "stderr" ? "error" : "log"), + }); + } + formatter.onEvent(event); + } + + const testInputs = tests.map(test => ({ + ...test, + run(testFn) { + return withConsoleCapture({ + name: test.name, + run: () => testFn(), + emit, + consoleHook, + forwardToConsole: false, + }); + }, + })); + + const result = await runTests({ + wasmBytes, + importObject, + tests: testInputs, + filtered, + emit, + }); + + port.postMessage({ type: "report", lines, failed: result.failed }); +} + +if (typeof self.onconnect !== "undefined") { + self.onconnect = event => { + const port = event.ports[0]; + port.onmessage = msg => { + execute(port, msg.data).catch(error => { + port.postMessage({ type: "report", lines: [String(error)], failed: 1 }); + }); + }; + port.start(); + }; +} else { + self.onmessage = event => { + execute(self, event.data).catch(error => { + self.postMessage({ type: "report", lines: [String(error)], failed: 1 }); + }); + }; +} diff --git a/host/js-bindgen-test-runner/src/main.rs b/host/js-bindgen-test-runner/src/main.rs new file mode 100644 index 00000000..de1b29e5 --- /dev/null +++ b/host/js-bindgen-test-runner/src/main.rs @@ -0,0 +1,692 @@ +use std::env; +use std::fs; +use std::io::{ErrorKind, Read, Write as IoWrite}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use wasmparser::{Parser, Payload}; + +const NODE_RUNNER: &str = "js/node-runner.mjs"; +const PLAYWRIGHT_RUNNER: &str = "js/playwright-runner.mjs"; +const BROWSER_RUNNER_SOURCE: &str = include_str!("../js/browser-runner.mjs"); +const RUNNER_CORE_SOURCE: &str = include_str!("../js/runner-core.mjs"); +const SHARED_JS_SOURCE: &str = include_str!("../js/shared.mjs"); +const WORKER_RUNNER_SOURCE: &str = include_str!("../js/worker-runner.mjs"); +const SERVICE_WORKER_SOURCE: &str = include_str!("../js/service-worker.mjs"); +const CONSOLE_HOOK_SOURCE: &str = include_str!("../js/console-hook.mjs"); + +#[derive(Debug, serde::Serialize)] +struct TestEntry { + name: String, + ignore: bool, + ignore_reason: Option, + should_panic: bool, + should_panic_reason: Option, +} + +fn main() -> Result<()> { + let mut args = env::args().skip(1).collect::>(); + let wasm_path = args + .first() + .map(PathBuf::from) + .context("expected a wasm file path")?; + let extra_args = args.split_off(1); + + let args = parse_test_args(extra_args)?; + let wasm_bytes = fs::read(&wasm_path) + .with_context(|| format!("failed to read wasm file: {}", wasm_path.display()))?; + + let mut tests = read_tests(&wasm_bytes)?; + let filtered_count = apply_filters(&mut tests, &args.filters, args.ignored_only, args.exact); + + if args.list_only { + match args.list_format { + ListFormat::Standard => { + for test in &tests { + println!("{}: test", test.name); + } + println!(); + println!("{} tests, 0 benchmarks", tests.len()); + } + ListFormat::Terse => { + for test in &tests { + println!("{}: test", test.name); + } + } + } + return Ok(()); + } + + if tests.is_empty() { + println!(); + println!("running 0 tests"); + println!(); + println!( + "test result: \u{001b}[32mok\u{001b}[0m. 0 passed; 0 failed; 0 ignored; 0 measured; {filtered_count} filtered out; finished in 0.00s" + ); + println!(); + return Ok(()); + } + + // `web_sys-4e01138c76cd2a1a.wasm` to `web_sys-4e01138c76cd2a1a.js` + let imports_path = wasm_path.with_extension("js"); + let tests_json = serde_json::to_string(&tests).expect("checked"); + + match args.runner.kind { + RunnerKind::Node => run_node( + &wasm_path, + &imports_path, + tests_json, + filtered_count, + args.nocapture, + )?, + RunnerKind::Browser => run_playwright( + &wasm_path, + &imports_path, + tests_json, + filtered_count, + args.nocapture, + &args.runner, + )?, + RunnerKind::BrowserServer => run_browser_server( + &wasm_path, + &imports_path, + tests_json, + filtered_count, + args.nocapture, + &args.runner, + )?, + } + + Ok(()) +} + +fn parse_test_args(args: Vec) -> Result { + let mut output = TestArgs { + list_only: false, + nocapture: false, + filters: Vec::new(), + list_format: ListFormat::Standard, + ignored_only: false, + exact: false, + runner: RunnerConfig::from_env()?, + }; + + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + if arg == "--list" { + output.list_only = true; + } else if arg == "--nocapture" { + output.nocapture = true; + } else if arg == "--ignored" { + output.ignored_only = true; + } else if arg == "--exact" { + output.exact = true; + } else if let Some(value) = arg.strip_prefix("--format=") { + if value == "terse" { + output.list_format = ListFormat::Terse; + } + } else if arg == "--format" { + if let Some(value) = iter.next() { + if value == "terse" { + output.list_format = ListFormat::Terse; + } + } + } else if arg.starts_with('-') { + continue; + } else { + output.filters.push(arg); + } + } + + Ok(output) +} + +enum ListFormat { + Standard, + Terse, +} + +struct TestArgs { + list_only: bool, + nocapture: bool, + filters: Vec, + list_format: ListFormat, + ignored_only: bool, + exact: bool, + runner: RunnerConfig, +} + +#[derive(Debug)] +struct RunnerConfig { + kind: RunnerKind, + browser: String, + worker: Option, +} + +impl RunnerConfig { + fn from_env() -> Result { + let mut config = RunnerConfig { + kind: RunnerKind::Node, + browser: "chromium".to_string(), + worker: None, + }; + + if let Ok(worker) = env::var("JBTEST_WORKER") { + if !matches!(worker.as_str(), "dedicated" | "shared" | "service") { + bail!("unsupported {worker}, supported dedicated, shared and service"); + } + config.worker = Some(worker); + } + + if let Ok(browser) = env::var("JBTEST_BROWSER") { + config.kind = RunnerKind::Browser; + if matches!(browser.as_str(), "chromium" | "firefox" | "webkit") { + config.browser = browser; + } + } + + if std::env::var("JBTEST_SERVER").is_ok() { + config.kind = RunnerKind::BrowserServer; + } + + Ok(config) + } +} + +#[derive(Debug)] +enum RunnerKind { + Node, + Browser, + BrowserServer, +} + +fn run_node( + wasm_path: &Path, + imports_path: &Path, + tests_json: String, + filtered_count: usize, + nocapture: bool, +) -> Result<()> { + ensure_module_package(imports_path); + let runner_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(NODE_RUNNER); + + let status = Command::new("node") + .arg(runner_path) + .env("JS_BINDGEN_WASM", wasm_path) + .env("JS_BINDGEN_IMPORTS", imports_path) + .env("JS_BINDGEN_TESTS", tests_json) + .env("JS_BINDGEN_FILTERED", filtered_count.to_string()) + .env("JS_BINDGEN_NOCAPTURE", if nocapture { "1" } else { "0" }) + .status() + .context("failed to run node")?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn ensure_module_package(imports_path: &Path) { + let Some(parent) = imports_path.parent() else { + return; + }; + let package_json = parent.join("package.json"); + if let Err(err) = fs::write(&package_json, r#"{"type": "module"}"#) { + eprintln!("failed to write package.json: {err}"); + } +} + +fn run_playwright( + wasm_path: &Path, + imports_path: &Path, + tests_json: String, + filtered_count: usize, + nocapture: bool, + runner: &RunnerConfig, +) -> Result<()> { + let assets = BrowserAssets::new( + wasm_path, + imports_path, + &tests_json, + filtered_count, + nocapture, + runner.worker.as_deref(), + )?; + let server = HttpServer::start(assets, env::var("JBTEST_SERVER_ADDRESS").ok().as_deref())?; + let url = build_browser_url(server.base_url.as_str()); + + let runner_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(PLAYWRIGHT_RUNNER); + let mut child = Command::new("node") + .arg(runner_path) + .env("JBTEST_URL", url) + .env("JBTEST_BROWSER", runner.browser.clone()) + .spawn() + .context("failed to run node")?; + + let report = server.wait_for_report(); + server.shutdown.store(true, Ordering::Relaxed); + + for line in report.lines { + println!("{line}"); + } + + let _ = child.kill(); + let _ = child.wait(); + + if report.failed > 0 { + std::process::exit(1); + } + + Ok(()) +} + +fn run_browser_server( + wasm_path: &Path, + imports_path: &Path, + tests_json: String, + filtered_count: usize, + nocapture: bool, + runner: &RunnerConfig, +) -> Result<()> { + let assets = BrowserAssets::new( + wasm_path, + imports_path, + &tests_json, + filtered_count, + nocapture, + runner.worker.as_deref(), + )?; + let server = HttpServer::start(assets, env::var("JBTEST_SERVER_ADDRESS").ok().as_deref())?; + let url = build_browser_url(server.base_url.as_str()); + + println!("open this URL in your browser to run tests:"); + println!("{url}"); + + loop { + let _ = server.wait_for_report(); + } +} + +fn build_browser_url(base_url: &str) -> String { + format!("{base_url}/index.html") +} + +#[derive(Debug, serde::Deserialize)] +struct Report { + lines: Vec, + failed: u64, +} + +struct ReportState { + result: Mutex>, + signal: Condvar, +} + +struct BrowserAssets { + wasm_bytes: Vec, + import_js: String, + tests_json: String, + runner_js: &'static str, + core_js: &'static str, + shared_js: &'static str, + worker_js: &'static str, + service_worker_js: &'static str, + console_hook_js: &'static str, + index_html: String, +} + +impl BrowserAssets { + fn new( + wasm_path: &Path, + imports_path: &Path, + tests_json: &str, + filtered_count: usize, + nocapture: bool, + worker: Option<&str>, + ) -> Result { + let wasm_bytes = fs::read(wasm_path)?; + let import_js = fs::read_to_string(imports_path)?; + + let index_html = format!( + r#" + +js-bindgen test +

+
+"#,
+			filtered_count = filtered_count,
+			nocapture_flag = if nocapture { "true" } else { "false" },
+			worker = if let Some(worker) = worker {
+				format!("{worker:?}")
+			} else {
+				"null".to_owned()
+			},
+		);
+
+		Ok(Self {
+			wasm_bytes,
+			import_js,
+			tests_json: tests_json.to_string(),
+			runner_js: BROWSER_RUNNER_SOURCE,
+			core_js: RUNNER_CORE_SOURCE,
+			shared_js: SHARED_JS_SOURCE,
+			worker_js: WORKER_RUNNER_SOURCE,
+			service_worker_js: SERVICE_WORKER_SOURCE,
+			console_hook_js: CONSOLE_HOOK_SOURCE,
+			index_html,
+		})
+	}
+}
+
+struct HttpServer {
+	base_url: String,
+	shutdown: Arc,
+	report_state: Arc,
+}
+
+impl HttpServer {
+	fn start(assets: BrowserAssets, address: Option<&str>) -> Result {
+		let listener = bind_default_port(address).context("failed to bind server")?;
+		let local_addr = listener.local_addr()?;
+		let base_url = format!("http://{}:{}", local_addr.ip(), local_addr.port());
+		let shutdown = Arc::new(AtomicBool::new(false));
+		let shutdown_flag = Arc::clone(&shutdown);
+		let assets = Arc::new(assets);
+		let report_state = Arc::new(ReportState {
+			result: Mutex::new(None),
+			signal: Condvar::new(),
+		});
+		let report_state_thread = Arc::clone(&report_state);
+
+		listener
+			.set_nonblocking(true)
+			.context("failed to set nonblocking")?;
+
+		thread::spawn(move || {
+			while !shutdown_flag.load(Ordering::Relaxed) {
+				match listener.accept() {
+					Ok((stream, _)) => {
+						let assets = Arc::clone(&assets);
+						let report_state = Arc::clone(&report_state_thread);
+						let _ = handle_connection(stream, assets, report_state);
+					}
+					Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+						thread::sleep(Duration::from_millis(5));
+					}
+					Err(_) => break,
+				}
+			}
+		});
+
+		Ok(Self {
+			base_url,
+			shutdown,
+			report_state,
+		})
+	}
+
+	fn wait_for_report(&self) -> Report {
+		let mut guard = self.report_state.result.lock().unwrap();
+		loop {
+			if let Some(report) = guard.take() {
+				return report;
+			}
+			guard = self.report_state.signal.wait(guard).unwrap();
+		}
+	}
+}
+
+fn bind_default_port(address: Option<&str>) -> Result {
+	let default_addr = address.unwrap_or("127.0.0.1:8000");
+	match TcpListener::bind(default_addr) {
+		Ok(listener) => Ok(listener),
+		Err(err) if err.kind() == ErrorKind::AddrInUse => {
+			let fallback_addr = address
+				.and_then(|addr| addr.split_once(':'))
+				.map(|(ip, _)| format!("{ip}:0"))
+				.unwrap_or_else(|| "127.0.0.1:0".to_string());
+			TcpListener::bind(&fallback_addr).context("failed to bind fallback port")
+		}
+		Err(err) => Err(err).context("failed to bind default port"),
+	}
+}
+
+fn handle_connection(
+	mut stream: TcpStream,
+	assets: Arc,
+	report_state: Arc,
+) -> Result<()> {
+	let mut buffer = [0u8; 4096];
+	let mut request = Vec::new();
+	let mut header_end = 0;
+
+	loop {
+		let size = stream.read(&mut buffer)?;
+		if size == 0 {
+			break;
+		}
+		let len = request.len();
+		request.extend_from_slice(&buffer[..size]);
+
+		if let Some(pos) = request[len.saturating_sub(3)..]
+			.windows(4)
+			.position(|window| window == b"\r\n\r\n")
+		{
+			header_end = pos;
+			break;
+		}
+	}
+
+	if header_end == 0 {
+		return Ok(());
+	}
+
+	let (header_bytes, body) = request.split_at(header_end + 4);
+	let request_text = String::from_utf8_lossy(header_bytes);
+	let mut lines = request_text.lines();
+	let Some(request_line) = lines.next() else {
+		return Ok(());
+	};
+
+	// GET /index.html HTTP/1.1
+	let mut parts = request_line.split_whitespace();
+	let method = parts.next().unwrap_or_default();
+	let path = parts.next().unwrap_or("/");
+
+	let mut content_length = 0usize;
+	for line in lines {
+		if let Some(value) = line.strip_prefix("Content-Length:") {
+			content_length = value.trim().parse().unwrap_or(0);
+		}
+	}
+
+	if method == "POST" && path.starts_with("/report") {
+		let mut body_vec = body.to_vec();
+		while body_vec.len() < content_length {
+			let size = stream.read(&mut buffer)?;
+			if size == 0 {
+				break;
+			}
+			body_vec.extend_from_slice(&buffer[..size]);
+		}
+
+		let report = serde_json::from_slice(&body_vec)?;
+		*report_state.result.lock().unwrap() = Some(report);
+		report_state.signal.notify_all();
+
+		write_response(&mut stream, 200, "text/plain", b"OK")?;
+		return Ok(());
+	}
+
+	if method == "OPTIONS" {
+		write_response(&mut stream, 204, "text/plain", b"")?;
+		return Ok(());
+	}
+
+	if method != "GET" {
+		write_response(&mut stream, 405, "text/plain", b"Method Not Allowed")?;
+		return Ok(());
+	}
+
+	let (body, content_type, status) = match path {
+		"/" | "/index.html" => (assets.index_html.as_bytes(), "text/html", 200),
+		"/browser-runner.mjs" => (assets.runner_js.as_bytes(), "application/javascript", 200),
+		"/runner-core.mjs" => (assets.core_js.as_bytes(), "application/javascript", 200),
+		"/shared.mjs" => (assets.shared_js.as_bytes(), "application/javascript", 200),
+		"/worker-runner.mjs" => (assets.worker_js.as_bytes(), "application/javascript", 200),
+		"/service-worker.mjs" => (
+			assets.service_worker_js.as_bytes(),
+			"application/javascript",
+			200,
+		),
+		"/console-hook.mjs" => (
+			assets.console_hook_js.as_bytes(),
+			"application/javascript",
+			200,
+		),
+		"/import.js" => (assets.import_js.as_bytes(), "application/javascript", 200),
+		"/tests.json" => (assets.tests_json.as_bytes(), "application/json", 200),
+		"/wasm" => (assets.wasm_bytes.as_slice(), "application/wasm", 200),
+		_ => (b"Not Found".as_slice(), "text/plain", 404),
+	};
+
+	write_response(&mut stream, status, content_type, body)?;
+	Ok(())
+}
+
+fn write_response(
+	stream: &mut TcpStream,
+	status: u16,
+	content_type: &str,
+	body: &[u8],
+) -> Result<()> {
+	let status_text = match status {
+		200 => "OK",
+		204 => "No Content",
+		404 => "Not Found",
+		405 => "Method Not Allowed",
+		_ => "OK",
+	};
+	write!(
+		stream,
+		"HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nCross-Origin-Opener-Policy: same-origin\r\nCross-Origin-Embedder-Policy: require-corp\r\n\r\n",
+		body.len()
+	)?;
+	stream.write_all(body)?;
+	Ok(())
+}
+
+fn read_tests(wasm_bytes: &[u8]) -> Result> {
+	/// None: `[0]`
+	///
+	/// Some(None): `[1]`
+	///
+	/// Some(Some(s)): `[2][len(s)][len]`
+	fn option_option_string(
+		data: &[u8],
+		mut offset: usize,
+	) -> Result<(Option>, usize)> {
+		offset += 1;
+		let value = match data[offset - 1] {
+			0 => None,
+			1 => Some(None),
+			2 => {
+				let len = u32::from_le_bytes(
+					data[offset..offset + 4]
+						.try_into()
+						.expect("slice length checked"),
+				) as usize;
+				offset += 4;
+				let s = std::str::from_utf8(&data[offset..offset + len])
+					.context("payload is not utf-8")?
+					.to_string();
+				offset += len;
+				Some(Some(s))
+			}
+			_ => bail!("mismatch flag value"),
+		};
+		Ok((value, offset))
+	}
+
+	let mut tests = Vec::new();
+
+	for payload in Parser::new(0).parse_all(wasm_bytes) {
+		if let Payload::CustomSection(section) = payload.context("failed to parse wasm")?
+			&& section.name() == "js_bindgen.test"
+		{
+			let mut offset = 0;
+			let data = section.data();
+
+			while offset < data.len() {
+				let len = u32::from_le_bytes(
+					data[offset..offset + 4]
+						.try_into()
+						.expect("slice length checked"),
+				) as usize;
+				offset += 4;
+				let end = offset + len;
+
+				let (ignore, offset1) = option_option_string(data, offset)?;
+				let (should_panic, offset2) = option_option_string(data, offset1)?;
+
+				let name =
+					std::str::from_utf8(&data[offset2..end]).context("test name is not utf-8")?;
+
+				tests.push(TestEntry {
+					name: name.to_string(),
+					ignore: ignore.is_some(),
+					ignore_reason: ignore.and_then(|s| s),
+					should_panic: should_panic.is_some(),
+					should_panic_reason: should_panic.and_then(|s| s),
+				});
+
+				offset = end;
+			}
+		}
+	}
+
+	Ok(tests)
+}
+
+fn apply_filters(
+	tests: &mut Vec,
+	filters: &[String],
+	ignored_only: bool,
+	exact: bool,
+) -> usize {
+	let initial = tests.len();
+	tests.retain(|test| {
+		let matches_ignore = !ignored_only || test.ignore;
+		let matches_filter = if filters.is_empty() {
+			true
+		} else if exact {
+			filters.contains(&test.name)
+		} else {
+			filters.iter().any(|filter| test.name.contains(filter))
+		};
+		matches_ignore && matches_filter
+	});
+	initial - tests.len()
+}
diff --git a/host/js-bindgen/src/lib.rs b/host/js-bindgen/src/lib.rs
index 279c7052..c2f0eb01 100644
--- a/host/js-bindgen/src/lib.rs
+++ b/host/js-bindgen/src/lib.rs
@@ -22,12 +22,10 @@ mod tests;
 #[cfg(not(test))]
 use std::env;
 use std::iter::Peekable;
-use std::{iter, mem};
+use std::mem;
 
 use js_bindgen_macro_shared::*;
-use proc_macro::{
-	Delimiter, Group, Ident, Literal, Punct, Spacing, Span, TokenStream, TokenTree, token_stream,
-};
+use proc_macro::{Delimiter, Span, TokenStream, TokenTree, token_stream};
 
 #[cfg_attr(not(test), proc_macro)]
 pub fn unsafe_embed_asm(input: TokenStream) -> TokenStream {
@@ -82,16 +80,6 @@ fn import_js_internal(input: TokenStream) -> Result {
 	))
 }
 
-struct Argument {
-	cfg: Option<[TokenTree; 2]>,
-	kind: ArgumentKind,
-}
-
-enum ArgumentKind {
-	String(String),
-	Interpolate(Vec),
-}
-
 fn parse_string_arguments(
 	mut stream: &mut Peekable,
 	mut previous_span: Span,
@@ -291,385 +279,6 @@ fn parse_string_arguments(
 	}
 }
 
-/// ```"not rust"
-/// const _: () = {
-/// 	const LEN: u32 = {
-/// 		let mut len: usize = 0;
-/// 		#(len += LEN_;)*
-/// 		len as u32
-/// 	};
-///
-/// 	const _: () = {
-/// 		#[repr(C)]
-/// 		struct Layout([u8; 4], #([u8; LEN_]),*);
-///
-/// 		#[link_section = name]
-/// 		static CUSTOM_SECTION: Layout = Layout(::core::primitive::u32::to_le_bytes(LEN), #(ARR_),*);
-/// 	};
-/// };
-/// ```
-fn custom_section(name: &str, prefix: Option<&str>, data: &[Argument]) -> TokenStream {
-	let span = Span::mixed_site();
-
-	// If a prefix is present:
-	// `const ARR_PREFIX: [u8; .len()] = *;`
-	let const_prefix = prefix
-		.into_iter()
-		.filter(|prefix| !prefix.is_empty())
-		.flat_map(|prefix| {
-			r#const(
-				"ARR_PREFIX",
-				iter::once(group(
-					Delimiter::Bracket,
-					[
-						ident("u8"),
-						Punct::new(';', Spacing::Alone).into(),
-						Literal::usize_unsuffixed(prefix.len()).into(),
-					],
-				)),
-				[
-					Punct::new('*', Spacing::Alone).into(),
-					Literal::byte_string(prefix.as_bytes()).into(),
-				],
-			)
-		});
-
-	// For every string we insert:
-	// ```
-	// const ARR_: [u8; .len()] = *;
-	// ```
-	//
-	// For every formatting argument we insert:
-	// ```
-	// const VAL_: &str = ;
-	// const LEN_: usize = ::core::primitive::str::len(VAL_);
-	// const PTR_: *const u8 = ::core::primitive::str::as_ptr(VAL_);
-	// const ARR_: [u8; LEN_] = unsafe { *(PTR_ as *const _) };
-	// ```
-	let consts = data
-		.iter()
-		.enumerate()
-		.flat_map(|(index, arg)| match &arg.kind {
-			ArgumentKind::String(string) => {
-				// `const ARR_: [u8; .len()] = *;`
-				arg.cfg
-					.clone()
-					.into_iter()
-					.flatten()
-					.chain(r#const(
-						&format!("ARR_{index}"),
-						iter::once(group(
-							Delimiter::Bracket,
-							[
-								ident("u8"),
-								Punct::new(';', Spacing::Alone).into(),
-								Literal::usize_unsuffixed(string.len()).into(),
-							],
-						)),
-						[
-							Punct::new('*', Spacing::Alone).into(),
-							Literal::byte_string(string.as_bytes()).into(),
-						],
-					))
-					.collect::>()
-			}
-			ArgumentKind::Interpolate(interpolate) => {
-				let value_name = format!("VAL_{index}");
-				let value = ident(&value_name);
-				let len_name = format!("LEN_{index}");
-				let ptr_name = format!("PTR_{index}");
-
-				// `const VAL_: &str = ;`
-				arg.cfg
-					.clone()
-					.into_iter()
-					.flatten()
-					.chain(r#const(
-						&value_name,
-						[Punct::new('&', Spacing::Alone).into(), ident("str")],
-						interpolate.iter().cloned(),
-					))
-					// `const LEN_: usize = ::core::primitive::str::len(VAL_);`
-					.chain(arg.cfg.clone().into_iter().flatten())
-					.chain(r#const(
-						&len_name,
-						iter::once(ident("usize")),
-						path(["core", "primitive", "str", "len"], span).chain(iter::once(group(
-							Delimiter::Parenthesis,
-							iter::once(value.clone()),
-						))),
-					))
-					// `const PTR_: *const u8 = ::core::primitive::str::as_ptr(VAL_);`
-					.chain(arg.cfg.clone().into_iter().flatten())
-					.chain(r#const(
-						&ptr_name,
-						[
-							Punct::new('*', Spacing::Alone).into(),
-							ident("const"),
-							ident("u8"),
-						],
-						path(["core", "primitive", "str", "as_ptr"], span)
-							.chain(iter::once(group(Delimiter::Parenthesis, iter::once(value)))),
-					))
-					// `const ARR_: [u8; LEN_] = unsafe { *(PTR_ as *const _)
-					// };`
-					.chain(arg.cfg.clone().into_iter().flatten())
-					.chain(r#const(
-						&format!("ARR_{index}"),
-						iter::once(group(
-							Delimiter::Bracket,
-							[
-								ident("u8"),
-								Punct::new(';', Spacing::Alone).into(),
-								ident(&len_name),
-							],
-						)),
-						[
-							ident("unsafe"),
-							group(
-								Delimiter::Brace,
-								[
-									Punct::new('*', Spacing::Alone).into(),
-									group(
-										Delimiter::Parenthesis,
-										[
-											ident(&ptr_name),
-											ident("as"),
-											Punct::new('*', Spacing::Alone).into(),
-											ident("const"),
-											ident("_"),
-										],
-									),
-								],
-							),
-						],
-					))
-					.collect::>()
-			}
-		});
-
-	// ```
-	// const LEN: u32 = {
-	//     let mut len: usize = 0;
-	//     #(len += LEN_;)*
-	//     len as u32
-	// };
-	// ```
-	let len = r#const(
-		"LEN",
-		iter::once(ident("u32")),
-		[group(
-			Delimiter::Brace,
-			[
-				ident("let"),
-				ident("mut"),
-				ident("len"),
-				Punct::new(':', Spacing::Alone).into(),
-				ident("usize"),
-				Punct::new('=', Spacing::Alone).into(),
-				Literal::usize_unsuffixed(0).into(),
-				Punct::new(';', Spacing::Alone).into(),
-			]
-			.into_iter()
-			.chain(data.iter().enumerate().flat_map(|(index, par)| {
-				par.cfg
-					.clone()
-					.into_iter()
-					.flatten()
-					.chain(iter::once(group(
-						Delimiter::Brace,
-						[
-							ident("len"),
-							Punct::new('+', Spacing::Joint).into(),
-							Punct::new('=', Spacing::Alone).into(),
-							match &par.kind {
-								ArgumentKind::String(string) => {
-									Literal::usize_unsuffixed(string.len()).into()
-								}
-								ArgumentKind::Interpolate(_) => ident(&format!("LEN_{index}")),
-							},
-							Punct::new(';', Spacing::Alone).into(),
-						],
-					)))
-			}))
-			.chain([ident("len"), ident("as"), ident("u32")]),
-		)],
-	);
-
-	// `[u8; 4], #([u8; LEN_]),*`
-	let tys = [
-		group(
-			Delimiter::Bracket,
-			[
-				ident("u8"),
-				Punct::new(';', Spacing::Alone).into(),
-				Literal::usize_unsuffixed(4).into(),
-			],
-		),
-		Punct::new(',', Spacing::Alone).into(),
-	]
-	.into_iter()
-	// Optional prefix length.
-	.chain(prefix.into_iter().flat_map(|prefix| {
-		[
-			group(
-				Delimiter::Bracket,
-				[
-					ident("u8"),
-					Punct::new(';', Spacing::Alone).into(),
-					Literal::usize_unsuffixed(2).into(),
-				],
-			),
-			Punct::new(',', Spacing::Alone).into(),
-		]
-		.into_iter()
-		.chain(
-			(!prefix.is_empty())
-				.then(|| {
-					[
-						group(
-							Delimiter::Bracket,
-							[
-								ident("u8"),
-								Punct::new(';', Spacing::Alone).into(),
-								Literal::usize_unsuffixed(prefix.len()).into(),
-							],
-						),
-						Punct::new(',', Spacing::Alone).into(),
-					]
-				})
-				.into_iter()
-				.flatten(),
-		)
-	}))
-	.chain(data.iter().enumerate().flat_map(move |(index, arg)| {
-		arg.cfg.clone().into_iter().flatten().chain([
-			group(
-				Delimiter::Bracket,
-				[
-					ident("u8"),
-					Punct::new(';', Spacing::Alone).into(),
-					match &arg.kind {
-						ArgumentKind::String(string) => {
-							Literal::usize_unsuffixed(string.len()).into()
-						}
-						ArgumentKind::Interpolate(_) => ident(&format!("LEN_{index}")),
-					},
-				],
-			),
-			Punct::new(',', Spacing::Alone).into(),
-		])
-	}));
-
-	// ```
-	// #[repr(C)]
-	// struct Layout(...);
-	// ```
-	let layout = [
-		Punct::new('#', Spacing::Alone).into(),
-		group(
-			Delimiter::Bracket,
-			[
-				ident("repr"),
-				group(Delimiter::Parenthesis, iter::once(ident("C"))),
-			],
-		),
-		ident("struct"),
-		ident("Layout"),
-		group(Delimiter::Parenthesis, tys),
-		Punct::new(';', Spacing::Alone).into(),
-	];
-
-	// `#[link_section = name]`
-	let link_section = [
-		Punct::new('#', Spacing::Alone).into(),
-		group(
-			Delimiter::Bracket,
-			[
-				ident("unsafe"),
-				group(
-					Delimiter::Parenthesis,
-					[
-						ident("link_section"),
-						Punct::new('=', Spacing::Alone).into(),
-						Literal::string(name).into(),
-					],
-				),
-			],
-		),
-	];
-
-	// (::core::primitive::u32::to_le_bytes(LEN), #(ARR_),*)
-	let values = group(
-		Delimiter::Parenthesis,
-		path(["core", "primitive", "u32", "to_le_bytes"], span)
-			.chain([
-				group(Delimiter::Parenthesis, iter::once(ident("LEN"))),
-				Punct::new(',', Spacing::Alone).into(),
-			])
-			// Optional prefix value.
-			.chain(prefix.into_iter().flat_map(|prefix| {
-				let len = u16::try_from(prefix.len()).unwrap().to_le_bytes();
-
-				[
-					group(
-						Delimiter::Bracket,
-						[
-							Literal::u8_unsuffixed(len[0]).into(),
-							Punct::new(',', Spacing::Alone).into(),
-							Literal::u8_unsuffixed(len[1]).into(),
-							Punct::new(',', Spacing::Alone).into(),
-						],
-					),
-					Punct::new(',', Spacing::Alone).into(),
-				]
-				.into_iter()
-				.chain(
-					(!prefix.is_empty())
-						.then(|| [ident("ARR_PREFIX"), Punct::new(',', Spacing::Alone).into()])
-						.into_iter()
-						.flatten(),
-				)
-			}))
-			.chain(data.iter().enumerate().flat_map(move |(index, arg)| {
-				arg.cfg.clone().into_iter().flatten().chain([
-					ident(&format!("ARR_{index}")),
-					Punct::new(',', Spacing::Alone).into(),
-				])
-			})),
-	);
-
-	// `static CUSTOM_SECTION: Layout = Layout(...);`
-	let custom_section = [
-		ident("static"),
-		ident("CUSTOM_SECTION"),
-		Punct::new(':', Spacing::Alone).into(),
-		ident("Layout"),
-		Punct::new('=', Spacing::Alone).into(),
-		ident("Layout"),
-		values,
-		Punct::new(';', Spacing::Alone).into(),
-	];
-
-	// `const _: () = { ... }`
-	r#const(
-		"_",
-		iter::once(group(Delimiter::Parenthesis, iter::empty())),
-		iter::once(group(
-			Delimiter::Brace,
-			const_prefix.chain(consts).chain(len).chain(r#const(
-				"_",
-				iter::once(group(Delimiter::Parenthesis, iter::empty())),
-				iter::once(group(
-					Delimiter::Brace,
-					layout.into_iter().chain(link_section).chain(custom_section),
-				)),
-			)),
-		)),
-	)
-	.collect()
-}
-
 fn expect_meta_name_value(
 	stream: &mut Peekable,
 	attribute: &str,
@@ -687,31 +296,6 @@ fn expect_meta_name_value(
 	Ok(string)
 }
 
-fn r#const(
-	name: &str,
-	ty: impl IntoIterator,
-	value: impl IntoIterator,
-) -> impl Iterator {
-	[
-		ident("const"),
-		ident(name),
-		Punct::new(':', Spacing::Alone).into(),
-	]
-	.into_iter()
-	.chain(ty)
-	.chain(iter::once(Punct::new('=', Spacing::Alone).into()))
-	.chain(value)
-	.chain(iter::once(Punct::new(';', Spacing::Alone).into()))
-}
-
-fn group(delimiter: Delimiter, inner: impl IntoIterator) -> TokenTree {
-	Group::new(delimiter, inner.into_iter().collect()).into()
-}
-
-fn ident(string: &str) -> TokenTree {
-	Ident::new(string, Span::mixed_site()).into()
-}
-
 #[cfg(not(test))]
 #[cfg_attr(coverage_nightly, coverage(off))]
 fn package() -> String {
diff --git a/host/ld/src/main.rs b/host/ld/src/main.rs
index 93b93ff5..f49b7944 100644
--- a/host/ld/src/main.rs
+++ b/host/ld/src/main.rs
@@ -92,7 +92,8 @@ fn main() {
 		js_bindgen_ld_shared::ld_input_parser::(input, |path, data| {
 			process_object(&arch_str, &mut add_args, path, data);
 			Ok(())
-		});
+		})
+		.unwrap();
 	}
 
 	let status = Command::new("rust-lld")
@@ -400,10 +401,9 @@ fn post_processing(output_path: &Path, main_memory: MainMemory<'_>) {
 		"missing JS embed: {expected_embed:?}"
 	);
 
-	let mut js_output = BufWriter::new(
-		File::create(output_path.with_file_name(package).with_extension("js"))
-			.expect("output JS file should be writable"),
-	);
+	let js_output_path = output_path.with_file_name(package).with_extension("js");
+	let mut js_output =
+		BufWriter::new(File::create(&js_output_path).expect("output JS file should be writable"));
 
 	// Create our `WebAssembly.Memory`.
 	js_output
@@ -497,4 +497,8 @@ fn post_processing(output_path: &Path, main_memory: MainMemory<'_>) {
 	// When it does, we should rename the old file and write to a new file. This way
 	// we can keep parsing and writing at the same time without allocating memory.
 	fs::write(output_path, wasm_output).expect("output Wasm file should be writable");
+
+	// We also need a js file with a fingerprint, otherwise the test files might overwrite each other.
+	fs::copy(js_output_path, output_path.with_extension("js"))
+		.expect("copy JS file should be success");
 }
diff --git a/host/macro-shared/src/lib.rs b/host/macro-shared/src/lib.rs
index 60e67efe..f83b503e 100644
--- a/host/macro-shared/src/lib.rs
+++ b/host/macro-shared/src/lib.rs
@@ -10,6 +10,448 @@ use proc_macro::{
 	Delimiter, Group, Ident, Literal, Punct, Spacing, Span, TokenStream, TokenTree, token_stream,
 };
 
+pub struct Argument {
+	pub cfg: Option<[TokenTree; 2]>,
+	pub kind: ArgumentKind,
+}
+
+pub enum ArgumentKind {
+	Bytes(Vec),
+	String(String),
+	Interpolate(Vec),
+}
+
+/// ```"not rust"
+/// const _: () = {
+/// 	const LEN: u32 = {
+/// 		let mut len: usize = 0;
+/// 		#(len += LEN_;)*
+/// 		len as u32
+/// 	};
+///
+/// 	const _: () = {
+/// 		#[repr(C)]
+/// 		struct Layout([u8; 4], #([u8; LEN_]),*);
+///
+/// 		#[link_section = name]
+/// 		static CUSTOM_SECTION: Layout = Layout(::core::primitive::u32::to_le_bytes(LEN), #(ARR_),*);
+/// 	};
+/// };
+/// ```
+pub fn custom_section(name: &str, prefix: Option<&str>, data: &[Argument]) -> TokenStream {
+	fn group(delimiter: Delimiter, inner: impl IntoIterator) -> TokenTree {
+		Group::new(delimiter, inner.into_iter().collect()).into()
+	}
+
+	fn r#const(
+		name: &str,
+		ty: impl IntoIterator,
+		value: impl IntoIterator,
+	) -> impl Iterator {
+		[
+			ident("const"),
+			ident(name),
+			Punct::new(':', Spacing::Alone).into(),
+		]
+		.into_iter()
+		.chain(ty)
+		.chain(iter::once(Punct::new('=', Spacing::Alone).into()))
+		.chain(value)
+		.chain(iter::once(Punct::new(';', Spacing::Alone).into()))
+	}
+
+	fn ident(string: &str) -> TokenTree {
+		Ident::new(string, Span::mixed_site()).into()
+	}
+
+	let span = Span::mixed_site();
+
+	// If a prefix is present:
+	// `const ARR_PREFIX: [u8; .len()] = *;`
+	let const_prefix = prefix
+		.into_iter()
+		.filter(|prefix| !prefix.is_empty())
+		.flat_map(|prefix| {
+			r#const(
+				"ARR_PREFIX",
+				iter::once(group(
+					Delimiter::Bracket,
+					[
+						ident("u8"),
+						Punct::new(';', Spacing::Alone).into(),
+						Literal::usize_unsuffixed(prefix.len()).into(),
+					],
+				)),
+				[
+					Punct::new('*', Spacing::Alone).into(),
+					Literal::byte_string(prefix.as_bytes()).into(),
+				],
+			)
+		});
+
+	// For every string we insert:
+	// ```
+	// const ARR_: [u8; .len()] = *;
+	// ```
+	//
+	// For every formatting argument we insert:
+	// ```
+	// const VAL_: &str = ;
+	// const LEN_: usize = ::core::primitive::str::len(VAL_);
+	// const PTR_: *const u8 = ::core::primitive::str::as_ptr(VAL_);
+	// const ARR_: [u8; LEN_] = unsafe { *(PTR_ as *const _) };
+	// ```
+	let consts = data
+		.iter()
+		.enumerate()
+		.flat_map(|(index, arg)| match &arg.kind {
+			ArgumentKind::Bytes(bytes) => {
+				// `const ARR_: [u8; .len()] = ;`
+				arg.cfg
+					.clone()
+					.into_iter()
+					.flatten()
+					.chain(r#const(
+						&format!("ARR_{index}"),
+						iter::once(group(
+							Delimiter::Bracket,
+							[
+								ident("u8"),
+								Punct::new(';', Spacing::Alone).into(),
+								Literal::usize_unsuffixed(bytes.len()).into(),
+							],
+						)),
+						[
+							Punct::new('*', Spacing::Alone).into(),
+							Literal::byte_string(bytes).into(),
+						],
+					))
+					.collect::>()
+			}
+			ArgumentKind::String(string) => {
+				// `const ARR_: [u8; .len()] = *;`
+				arg.cfg
+					.clone()
+					.into_iter()
+					.flatten()
+					.chain(r#const(
+						&format!("ARR_{index}"),
+						iter::once(group(
+							Delimiter::Bracket,
+							[
+								ident("u8"),
+								Punct::new(';', Spacing::Alone).into(),
+								Literal::usize_unsuffixed(string.len()).into(),
+							],
+						)),
+						[
+							Punct::new('*', Spacing::Alone).into(),
+							Literal::byte_string(string.as_bytes()).into(),
+						],
+					))
+					.collect::>()
+			}
+			ArgumentKind::Interpolate(interpolate) => {
+				let value_name = format!("VAL_{index}");
+				let value = ident(&value_name);
+				let len_name = format!("LEN_{index}");
+				let ptr_name = format!("PTR_{index}");
+
+				// `const VAL_: &str = ;`
+				arg.cfg
+					.clone()
+					.into_iter()
+					.flatten()
+					.chain(r#const(
+						&value_name,
+						[Punct::new('&', Spacing::Alone).into(), ident("str")],
+						interpolate.iter().cloned(),
+					))
+					// `const LEN_: usize = ::core::primitive::str::len(VAL_);`
+					.chain(arg.cfg.clone().into_iter().flatten())
+					.chain(r#const(
+						&len_name,
+						iter::once(ident("usize")),
+						path(["core", "primitive", "str", "len"], span).chain(iter::once(group(
+							Delimiter::Parenthesis,
+							iter::once(value.clone()),
+						))),
+					))
+					// `const PTR_: *const u8 = ::core::primitive::str::as_ptr(VAL_);`
+					.chain(arg.cfg.clone().into_iter().flatten())
+					.chain(r#const(
+						&ptr_name,
+						[
+							Punct::new('*', Spacing::Alone).into(),
+							ident("const"),
+							ident("u8"),
+						],
+						path(["core", "primitive", "str", "as_ptr"], span)
+							.chain(iter::once(group(Delimiter::Parenthesis, iter::once(value)))),
+					))
+					// `const ARR_: [u8; LEN_] = unsafe { *(PTR_ as *const _)
+					// };`
+					.chain(arg.cfg.clone().into_iter().flatten())
+					.chain(r#const(
+						&format!("ARR_{index}"),
+						iter::once(group(
+							Delimiter::Bracket,
+							[
+								ident("u8"),
+								Punct::new(';', Spacing::Alone).into(),
+								ident(&len_name),
+							],
+						)),
+						[
+							ident("unsafe"),
+							group(
+								Delimiter::Brace,
+								[
+									Punct::new('*', Spacing::Alone).into(),
+									group(
+										Delimiter::Parenthesis,
+										[
+											ident(&ptr_name),
+											ident("as"),
+											Punct::new('*', Spacing::Alone).into(),
+											ident("const"),
+											ident("_"),
+										],
+									),
+								],
+							),
+						],
+					))
+					.collect::>()
+			}
+		});
+
+	// ```
+	// const LEN: u32 = {
+	//     let mut len: usize = 0;
+	//     #(len += LEN_;)*
+	//     len as u32
+	// };
+	// ```
+	let len = r#const(
+		"LEN",
+		iter::once(ident("u32")),
+		[group(
+			Delimiter::Brace,
+			[
+				ident("let"),
+				ident("mut"),
+				ident("len"),
+				Punct::new(':', Spacing::Alone).into(),
+				ident("usize"),
+				Punct::new('=', Spacing::Alone).into(),
+				Literal::usize_unsuffixed(0).into(),
+				Punct::new(';', Spacing::Alone).into(),
+			]
+			.into_iter()
+			.chain(data.iter().enumerate().flat_map(|(index, par)| {
+				par.cfg
+					.clone()
+					.into_iter()
+					.flatten()
+					.chain(iter::once(group(
+						Delimiter::Brace,
+						[
+							ident("len"),
+							Punct::new('+', Spacing::Joint).into(),
+							Punct::new('=', Spacing::Alone).into(),
+							match &par.kind {
+								ArgumentKind::Bytes(bytes) => {
+									Literal::usize_unsuffixed(bytes.len()).into()
+								}
+								ArgumentKind::String(string) => {
+									Literal::usize_unsuffixed(string.len()).into()
+								}
+								ArgumentKind::Interpolate(_) => ident(&format!("LEN_{index}")),
+							},
+							Punct::new(';', Spacing::Alone).into(),
+						],
+					)))
+			}))
+			.chain([ident("len"), ident("as"), ident("u32")]),
+		)],
+	);
+
+	// `[u8; 4], #([u8; LEN_]),*`
+	let tys = [
+		group(
+			Delimiter::Bracket,
+			[
+				ident("u8"),
+				Punct::new(';', Spacing::Alone).into(),
+				Literal::usize_unsuffixed(4).into(),
+			],
+		),
+		Punct::new(',', Spacing::Alone).into(),
+	]
+	.into_iter()
+	// Optional prefix length.
+	.chain(prefix.into_iter().flat_map(|prefix| {
+		[
+			group(
+				Delimiter::Bracket,
+				[
+					ident("u8"),
+					Punct::new(';', Spacing::Alone).into(),
+					Literal::usize_unsuffixed(2).into(),
+				],
+			),
+			Punct::new(',', Spacing::Alone).into(),
+		]
+		.into_iter()
+		.chain(
+			(!prefix.is_empty())
+				.then(|| {
+					[
+						group(
+							Delimiter::Bracket,
+							[
+								ident("u8"),
+								Punct::new(';', Spacing::Alone).into(),
+								Literal::usize_unsuffixed(prefix.len()).into(),
+							],
+						),
+						Punct::new(',', Spacing::Alone).into(),
+					]
+				})
+				.into_iter()
+				.flatten(),
+		)
+	}))
+	.chain(data.iter().enumerate().flat_map(move |(index, arg)| {
+		arg.cfg.clone().into_iter().flatten().chain([
+			group(
+				Delimiter::Bracket,
+				[
+					ident("u8"),
+					Punct::new(';', Spacing::Alone).into(),
+					match &arg.kind {
+						ArgumentKind::Bytes(bytes) => Literal::usize_unsuffixed(bytes.len()).into(),
+						ArgumentKind::String(string) => {
+							Literal::usize_unsuffixed(string.len()).into()
+						}
+						ArgumentKind::Interpolate(_) => ident(&format!("LEN_{index}")),
+					},
+				],
+			),
+			Punct::new(',', Spacing::Alone).into(),
+		])
+	}));
+
+	// ```
+	// #[repr(C)]
+	// struct Layout(...);
+	// ```
+	let layout = [
+		Punct::new('#', Spacing::Alone).into(),
+		group(
+			Delimiter::Bracket,
+			[
+				ident("repr"),
+				group(Delimiter::Parenthesis, iter::once(ident("C"))),
+			],
+		),
+		ident("struct"),
+		ident("Layout"),
+		group(Delimiter::Parenthesis, tys),
+		Punct::new(';', Spacing::Alone).into(),
+	];
+
+	// `#[link_section = name]`
+	let link_section = [
+		Punct::new('#', Spacing::Alone).into(),
+		group(
+			Delimiter::Bracket,
+			[
+				ident("unsafe"),
+				group(
+					Delimiter::Parenthesis,
+					[
+						ident("link_section"),
+						Punct::new('=', Spacing::Alone).into(),
+						Literal::string(name).into(),
+					],
+				),
+			],
+		),
+	];
+
+	// (::core::primitive::u32::to_le_bytes(LEN), #(ARR_),*)
+	let values = group(
+		Delimiter::Parenthesis,
+		path(["core", "primitive", "u32", "to_le_bytes"], span)
+			.chain([
+				group(Delimiter::Parenthesis, iter::once(ident("LEN"))),
+				Punct::new(',', Spacing::Alone).into(),
+			])
+			// Optional prefix value.
+			.chain(prefix.into_iter().flat_map(|prefix| {
+				let len = u16::try_from(prefix.len()).unwrap().to_le_bytes();
+
+				[
+					group(
+						Delimiter::Bracket,
+						[
+							Literal::u8_unsuffixed(len[0]).into(),
+							Punct::new(',', Spacing::Alone).into(),
+							Literal::u8_unsuffixed(len[1]).into(),
+							Punct::new(',', Spacing::Alone).into(),
+						],
+					),
+					Punct::new(',', Spacing::Alone).into(),
+				]
+				.into_iter()
+				.chain(
+					(!prefix.is_empty())
+						.then(|| [ident("ARR_PREFIX"), Punct::new(',', Spacing::Alone).into()])
+						.into_iter()
+						.flatten(),
+				)
+			}))
+			.chain(data.iter().enumerate().flat_map(move |(index, arg)| {
+				arg.cfg.clone().into_iter().flatten().chain([
+					ident(&format!("ARR_{index}")),
+					Punct::new(',', Spacing::Alone).into(),
+				])
+			})),
+	);
+
+	// `static CUSTOM_SECTION: Layout = Layout(...);`
+	let custom_section = [
+		ident("static"),
+		ident("CUSTOM_SECTION"),
+		Punct::new(':', Spacing::Alone).into(),
+		ident("Layout"),
+		Punct::new('=', Spacing::Alone).into(),
+		ident("Layout"),
+		values,
+		Punct::new(';', Spacing::Alone).into(),
+	];
+
+	// `const _: () = { ... }`
+	r#const(
+		"_",
+		iter::once(group(Delimiter::Parenthesis, iter::empty())),
+		iter::once(group(
+			Delimiter::Brace,
+			const_prefix.chain(consts).chain(len).chain(r#const(
+				"_",
+				iter::once(group(Delimiter::Parenthesis, iter::empty())),
+				iter::once(group(
+					Delimiter::Brace,
+					layout.into_iter().chain(link_section).chain(custom_section),
+				)),
+			)),
+		)),
+	)
+	.collect()
+}
+
 pub fn parse_meta_name_value(
 	mut stream: &mut Peekable,
 ) -> Result<(Ident, String), TokenStream> {
diff --git a/linker-shim/linker b/linker-shim/linker
index 5c9fc98b..ca6e32ec 100755
--- a/linker-shim/linker
+++ b/linker-shim/linker
@@ -2,6 +2,6 @@
 
 (
   cd "$(dirname "$0")/../host" || exit 1
-  cargo +stable run -q -p js-bindgen-ld -- "$@"
+  cargo run -q -p js-bindgen-ld -- "$@"
 )
 exit $?
diff --git a/linker-shim/linker.cmd b/linker-shim/linker.cmd
index 2526f468..e5ed2355 100644
--- a/linker-shim/linker.cmd
+++ b/linker-shim/linker.cmd
@@ -1,5 +1,5 @@
 @echo off
 pushd "%~dp0..\host" || exit /b 1
-cargo +stable run -q -p js-bindgen-ld -- %*
+cargo run -q -p js-bindgen-ld -- %*
 popd
 exit /b %ERRORLEVEL%
diff --git a/linker-shim/test-runner b/linker-shim/test-runner
new file mode 100755
index 00000000..6f878ea3
--- /dev/null
+++ b/linker-shim/test-runner
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+(
+  cd "$(dirname "$0")/../host" || exit 1
+  cargo run -q -p js-bindgen-test-runner -- "$@"
+)
+exit $?
diff --git a/linker-shim/test-runner.cmd b/linker-shim/test-runner.cmd
new file mode 100644
index 00000000..f0f9cd3d
--- /dev/null
+++ b/linker-shim/test-runner.cmd
@@ -0,0 +1,5 @@
+@echo off
+pushd "%~dp0..\host" || exit /b 1
+cargo run -q -p js-bindgen-test-runner -- %*
+popd
+exit /b %ERRORLEVEL%