From 852021e6128460fdda4b74eed4974128d2bd6b84 Mon Sep 17 00:00:00 2001 From: Drew Crawford Date: Wed, 14 Jan 2026 18:10:26 -0600 Subject: [PATCH] Add doctest support for wasm-bindgen-test-runner --- .github/workflows/main.yml | 3 + CHANGELOG_FFI.md | 13 + Cargo.toml | 2 +- crates/cli-support/src/decode.rs | 7 + crates/cli/src/wasm_bindgen_test_runner.rs | 194 +++++-- .../src/wasm_bindgen_test_runner/doctest.rs | 251 +++++++++ .../src/wasm_bindgen_test_runner/server.rs | 452 ++++++++++++++-- .../wasm-bindgen-test-runner/doctests.rs | 494 ++++++++++++++++++ .../tests/wasm-bindgen-test-runner/main.rs | 20 +- crates/msrv/cli/Cargo.toml | 12 +- 10 files changed, 1359 insertions(+), 89 deletions(-) create mode 100644 crates/cli/src/wasm_bindgen_test_runner/doctest.rs create mode 100644 crates/cli/tests/wasm-bindgen-test-runner/doctests.rs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5c4a1996095..3093522b80f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -218,6 +218,9 @@ jobs: - uses: actions/setup-node@v6 with: node-version: '20' + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x - run: cargo test - run: cargo test -p wasm-bindgen-cli-support - run: cargo test -p wasm-bindgen-cli diff --git a/CHANGELOG_FFI.md b/CHANGELOG_FFI.md index 7a4559fecee..c9127baabe5 100644 --- a/CHANGELOG_FFI.md +++ b/CHANGELOG_FFI.md @@ -1,3 +1,16 @@ +# doctest-support + +Actually run doctests in wasm-bindgen-test-runner. Previously, doctests were silently skipped because they export a `main` function instead of `__wbgt_*` test exports. The runner would print "no tests to run!" and exit 0, causing the tooling to report the doctest as passed. + +Now doctests are detected and executed properly. Supports: +- Node.js CommonJS (default, no configuration needed) +- Node.js ES modules (`wasm_bindgen_test_configure!(run_in_node_experimental)`) +- Deno (`WASM_BINDGEN_USE_DENO=1` environment variable) +- Browser main thread (`wasm_bindgen_test_configure!(run_in_browser)`) +- Dedicated worker (`wasm_bindgen_test_configure!(run_in_dedicated_worker)`) +- Shared worker (`wasm_bindgen_test_configure!(run_in_shared_worker)`) +- Service worker (`wasm_bindgen_test_configure!(run_in_service_worker)`) + # nodejs-threads Add Node.js `worker_threads` support for atomics builds. Supports both CommonJS (`--target nodejs`) and ESM (`--target experimental-nodejs-module`) targets. When targeting Node.js with atomics enabled, wasm-bindgen now generates: diff --git a/Cargo.toml b/Cargo.toml index 70e4d29ecbd..b5764a2d4b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ wasm-bindgen-shared = { path = "crates/shared", version = "=0.2.106" } [build-dependencies] # In older MSRVs, dependencies and crate features can't have the same name. -rustversion-compat = { package = "rustversion", version = "1.0" } +rustversion-compat = { package = "rustversion", version = "1.0.6" } [dev-dependencies] once_cell = "1" diff --git a/crates/cli-support/src/decode.rs b/crates/cli-support/src/decode.rs index c928ce9fb61..3b9ea901472 100644 --- a/crates/cli-support/src/decode.rs +++ b/crates/cli-support/src/decode.rs @@ -58,6 +58,13 @@ impl<'src> Decode<'src> for u32 { impl<'src> Decode<'src> for &'src str { fn decode(data: &mut &'src [u8]) -> &'src str { let n = u32::decode(data); + if n as usize > data.len() { + panic!( + "String decode failed: length {n} but only {} bytes remaining. First 20 bytes: {:?}", + data.len(), + &data[..data.len().min(20)] + ); + } let (a, b) = data.split_at(n as usize); *data = b; let r = str::from_utf8(a).unwrap(); diff --git a/crates/cli/src/wasm_bindgen_test_runner.rs b/crates/cli/src/wasm_bindgen_test_runner.rs index e4ae9de0af5..555e1d7090c 100644 --- a/crates/cli/src/wasm_bindgen_test_runner.rs +++ b/crates/cli/src/wasm_bindgen_test_runner.rs @@ -22,6 +22,7 @@ use std::thread; use wasm_bindgen_cli_support::Bindgen; mod deno; +mod doctest; mod headless; mod node; mod server; @@ -222,10 +223,21 @@ fn rmain(cli: Cli) -> anyhow::Result<()> { let module = "wasm-bindgen-test"; + // Check if this is a doctest - doctests have a `main` export instead of + // `__wbgt_*` test exports, AND have a function named `__doctest_main_*` + // which is generated by rustdoc. + let has_main_export = wasm.exports.iter().any(|e| e.name == "main"); + let has_doctest_main = wasm.funcs.iter().any(|f| { + f.name + .as_ref() + .is_some_and(|n| n.contains("__doctest_main")) + }); + let is_doctest = tests.tests.is_empty() && has_main_export && has_doctest_main; + // Right now there's a bug where if no tests are present then the // `wasm-bindgen-test` runtime support isn't linked in, so just bail out // early saying everything is ok. - if tests.tests.is_empty() { + if tests.tests.is_empty() && !is_doctest { println!("no tests to run!"); return Ok(()); } @@ -237,13 +249,18 @@ fn rmain(cli: Cli) -> anyhow::Result<()> { let custom_section = wasm.customs.remove_raw("__wasm_bindgen_test_unstable"); let no_modules = std::env::var("WASM_BINDGEN_USE_NO_MODULE").is_ok(); + // Force no_modules for ServiceWorker because Firefox < 147 doesn't support + // ES module service workers. See https://bugzilla.mozilla.org/show_bug.cgi?id=1360870 + let service_worker_no_modules = true; let test_mode = match custom_section { Some(section) if section.data.contains(&0x01) => TestMode::Browser { no_modules }, Some(section) if section.data.contains(&0x02) => TestMode::DedicatedWorker { no_modules }, Some(section) if section.data.contains(&0x03) => TestMode::SharedWorker { no_modules }, - Some(section) if section.data.contains(&0x04) => TestMode::ServiceWorker { no_modules }, + Some(section) if section.data.contains(&0x04) => TestMode::ServiceWorker { + no_modules: service_worker_no_modules, + }, Some(section) if section.data.contains(&0x05) => TestMode::Node { no_modules }, - Some(_) => bail!("invalid __wasm_bingen_test_unstable value"), + Some(_) => bail!("invalid __wasm_bindgen_test_unstable value"), None => { let mut modes = Vec::new(); let mut add_mode = @@ -252,7 +269,9 @@ fn rmain(cli: Cli) -> anyhow::Result<()> { add_mode(TestMode::Browser { no_modules }); add_mode(TestMode::DedicatedWorker { no_modules }); add_mode(TestMode::SharedWorker { no_modules }); - add_mode(TestMode::ServiceWorker { no_modules }); + add_mode(TestMode::ServiceWorker { + no_modules: service_worker_no_modules, + }); add_mode(TestMode::Node { no_modules }); match modes.len() { @@ -366,57 +385,138 @@ fn rmain(cli: Cli) -> anyhow::Result<()> { // code. // // It has nothing to do with Rust. - b.debug(debug) + let bindgen_result = b + .debug(debug) .input_module(module, wasm) .emit_start(false) - .generate(&tmpdir_path) - .context("executing `wasm-bindgen` over the Wasm file")?; + .generate(&tmpdir_path); shell.clear(); - match test_mode { - TestMode::Node { no_modules } => { - node::execute(module, &tmpdir_path, cli, tests, !no_modules, benchmark)? + // For doctests, if wasm-bindgen fails, try a fallback that executes the raw wasm + // with stub imports. This handles doctests that use wasm-bindgen types but don't + // actually need the full wasm-bindgen runtime. + if is_doctest { + let use_fallback = bindgen_result.is_err(); + if use_fallback { + log::info!( + "wasm-bindgen failed for doctest, using fallback execution: {:?}", + bindgen_result.as_ref().unwrap_err() + ); } - TestMode::Deno => deno::execute(module, &tmpdir_path, cli, tests)?, - TestMode::Browser { .. } - | TestMode::DedicatedWorker { .. } - | TestMode::SharedWorker { .. } - | TestMode::ServiceWorker { .. } => { - let srv = server::spawn( - &if headless { - "127.0.0.1:0".parse().unwrap() - } else if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") { - address.parse().unwrap() + + match test_mode { + TestMode::Node { no_modules } => { + println!("running 1 doctest"); + if use_fallback { + doctest::execute_node_fallback(&cli.file)?; } else { - "127.0.0.1:8000".parse().unwrap() - }, - headless, - module, - &tmpdir_path, - cli, - tests, - test_mode, - std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(), - benchmark, - ) - .context("failed to spawn server")?; - let addr = srv.server_addr(); - - // TODO: eventually we should provide the ability to exit at some point - // (gracefully) here, but for now this just runs forever. - if !headless { - println!("Interactive browsers tests are now available at http://{addr}"); - println!(); - println!("Note that interactive mode is enabled because `NO_HEADLESS`"); - println!("is specified in the environment of this process. Once you're"); - println!("done with testing you'll need to kill this server with"); - println!("Ctrl-C."); - srv.run(); - return Ok(()); + doctest::execute_node(module, &tmpdir_path, !no_modules)?; + } } + TestMode::Deno => { + if use_fallback { + bail!( + "This doctest cannot be processed by wasm-bindgen. \ + Deno fallback execution is not yet implemented. \ + Consider adding `wasm_bindgen_test` imports to enable full support." + ); + } + println!("running 1 doctest"); + doctest::execute_deno(module, &tmpdir_path)?; + } + TestMode::Browser { .. } + | TestMode::DedicatedWorker { .. } + | TestMode::SharedWorker { .. } + | TestMode::ServiceWorker { .. } => { + // Browser fallback not yet implemented + if use_fallback { + bail!( + "This doctest cannot be processed by wasm-bindgen. \ + Browser fallback execution is not yet implemented. \ + Consider adding `wasm_bindgen_test` imports to enable full support." + ); + } + println!("running 1 doctest"); + let srv = server::spawn_doctest( + &if headless { + "127.0.0.1:0".parse().unwrap() + } else if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") { + address.parse().unwrap() + } else { + "127.0.0.1:8000".parse().unwrap() + }, + headless, + module, + &tmpdir_path, + test_mode, + std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(), + ) + .context("failed to spawn server")?; + let addr = srv.server_addr(); + + if !headless { + println!("Interactive doctest is now available at http://{addr}"); + println!(); + println!("Note that interactive mode is enabled because `NO_HEADLESS`"); + println!("is specified in the environment of this process. Once you're"); + println!("done with testing you'll need to kill this server with"); + println!("Ctrl-C."); + srv.run(); + return Ok(()); + } - thread::spawn(|| srv.run()); - headless::run(&addr, &shell, driver_timeout, browser_timeout)?; + thread::spawn(|| srv.run()); + headless::run(&addr, &shell, driver_timeout, browser_timeout)?; + } + } + } else { + // For non-doctests, wasm-bindgen must succeed + bindgen_result.context("executing `wasm-bindgen` over the Wasm file")?; + match test_mode { + TestMode::Node { no_modules } => { + node::execute(module, &tmpdir_path, cli, tests, !no_modules, benchmark)? + } + TestMode::Deno => deno::execute(module, &tmpdir_path, cli, tests)?, + TestMode::Browser { .. } + | TestMode::DedicatedWorker { .. } + | TestMode::SharedWorker { .. } + | TestMode::ServiceWorker { .. } => { + let srv = server::spawn( + &if headless { + "127.0.0.1:0".parse().unwrap() + } else if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") { + address.parse().unwrap() + } else { + "127.0.0.1:8000".parse().unwrap() + }, + headless, + module, + &tmpdir_path, + cli, + tests, + test_mode, + std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(), + benchmark, + ) + .context("failed to spawn server")?; + let addr = srv.server_addr(); + + // TODO: eventually we should provide the ability to exit at some point + // (gracefully) here, but for now this just runs forever. + if !headless { + println!("Interactive browsers tests are now available at http://{addr}"); + println!(); + println!("Note that interactive mode is enabled because `NO_HEADLESS`"); + println!("is specified in the environment of this process. Once you're"); + println!("done with testing you'll need to kill this server with"); + println!("Ctrl-C."); + srv.run(); + return Ok(()); + } + + thread::spawn(|| srv.run()); + headless::run(&addr, &shell, driver_timeout, browser_timeout)?; + } } } Ok(()) diff --git a/crates/cli/src/wasm_bindgen_test_runner/doctest.rs b/crates/cli/src/wasm_bindgen_test_runner/doctest.rs new file mode 100644 index 00000000000..c972b3db643 --- /dev/null +++ b/crates/cli/src/wasm_bindgen_test_runner/doctest.rs @@ -0,0 +1,251 @@ +//! Execution of doctests (tests with a `main` function instead of `__wbgt_*` exports) +//! +//! Doctests are simpler than regular wasm-bindgen tests - they just have a `main` +//! function that should be called. Unlike regular tests, they don't use the +//! WasmBindgenTestContext infrastructure. + +use std::path::Path; +use std::process::Command; +use std::{env, fs}; + +use anyhow::{bail, Context, Error}; +use tempfile::tempdir; + +/// Execute a doctest in Node.js by calling its `main` function. +pub fn execute_node(module: &str, tmpdir: &Path, module_format: bool) -> Result<(), Error> { + let js_to_execute = if !module_format { + // CommonJS format - wasm is loaded synchronously + format!( + r#" +const {{ exit }} = require('node:process'); +const wasm = require('./{module}.js'); + +// For Node.js CommonJS, wasm-bindgen exports __wasm containing the wasm exports +// The module is already initialized synchronously +try {{ + if (typeof wasm.__wasm.main === 'function') {{ + wasm.__wasm.main(); + }} else {{ + throw new Error('No main function found in doctest wasm module'); + }} + console.log('test result: ok. 1 passed; 0 failed'); + exit(0); +}} catch (e) {{ + console.error('Doctest failed:', e); + console.log('test result: FAILED. 0 passed; 1 failed'); + exit(1); +}} +"# + ) + } else { + // ES module format - module is auto-initialized on import + // wasm exports are accessed via wasm.__wasm (same as CommonJS) + format!( + r#" +import {{ exit }} from 'node:process'; +import * as wasm from './{module}.js'; + +// For Node.js ES modules, wasm-bindgen exports __wasm containing the wasm exports +// The module is already initialized when imported +try {{ + if (typeof wasm.__wasm.main === 'function') {{ + wasm.__wasm.main(); + }} else {{ + throw new Error('No main function found in doctest wasm module'); + }} + console.log('test result: ok. 1 passed; 0 failed'); + exit(0); +}} catch (e) {{ + console.error('Doctest failed:', e); + console.log('test result: FAILED. 0 passed; 1 failed'); + exit(1); +}} +"# + ) + }; + + let js_path = if module_format { + // For ES modules, need package.json with type: module + let package_json = tmpdir.join("package.json"); + fs::write(&package_json, r#"{"type": "module"}"#).unwrap(); + tmpdir.join("run.mjs") + } else { + tmpdir.join("run.cjs") + }; + fs::write(&js_path, js_to_execute).context("failed to write JS file")?; + + // Augment `NODE_PATH` so imports work correctly + let path = env::var("NODE_PATH").unwrap_or_default(); + let mut path = env::split_paths(&path).collect::>(); + path.push(env::current_dir().unwrap()); + path.push(tmpdir.to_path_buf()); + let extra_node_args = env::var("NODE_ARGS") + .unwrap_or_default() + .split(',') + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .collect::>(); + + let status = Command::new("node") + .env("NODE_PATH", env::join_paths(&path).unwrap()) + .args(&extra_node_args) + .arg(&js_path) + .status() + .context("failed to find or execute Node.js")?; + + if !status.success() { + bail!("Node failed with exit_code {}", status.code().unwrap_or(1)) + } + + Ok(()) +} + +/// Execute a doctest in Node.js using fallback mode (without wasm-bindgen processing). +/// +/// This is used when wasm-bindgen CLI fails to process the wasm file (e.g., when the +/// doctest imports wasm-bindgen types but doesn't actually use them at runtime). +/// We provide stub implementations for wasm-bindgen imports and execute the wasm directly. +pub fn execute_node_fallback(wasm_path: &Path) -> Result<(), Error> { + let tmpdir = tempdir()?; + let tmpdir_path = tmpdir.path(); + + // Copy the wasm file to the temp directory + let wasm_dest = tmpdir_path.join("doctest.wasm"); + fs::copy(wasm_path, &wasm_dest).context("failed to copy wasm file")?; + + // JavaScript that loads the wasm with stub imports and calls main() + let js_to_execute = r#" +const { exit } = require('node:process'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); + +// Stub imports for wasm-bindgen functions that may be imported but not called +const stubImports = { + __wbindgen_placeholder__: new Proxy({}, { + get: (target, prop) => { + // Return a stub function for any requested import + return (...args) => { + // __wbindgen_describe is called at build time, not runtime - no-op + if (prop === '__wbindgen_describe') return; + // For other functions, if they're actually called at runtime, + // the test should fail + throw new Error(`wasm-bindgen stub called: ${prop}. This doctest requires wasm-bindgen-test support.`); + }; + } + }), + __wbindgen_externref_xform__: new Proxy({}, { + get: (target, prop) => { + return (...args) => { + throw new Error(`externref stub called: ${prop}. This doctest requires wasm-bindgen-test support.`); + }; + } + }), + // Provide a minimal env if needed + env: {} +}; + +async function run() { + try { + const wasmPath = join(__dirname, 'doctest.wasm'); + const wasmBytes = readFileSync(wasmPath); + const wasmModule = await WebAssembly.compile(wasmBytes); + + // Get the imports the module needs + const moduleImports = WebAssembly.Module.imports(wasmModule); + + // Build import object with stubs for all required imports + const imports = {}; + for (const imp of moduleImports) { + if (!imports[imp.module]) { + imports[imp.module] = stubImports[imp.module] || {}; + } + } + + const instance = await WebAssembly.instantiate(wasmModule, imports); + + if (typeof instance.exports.main !== 'function') { + throw new Error('No main function found in doctest wasm module'); + } + + instance.exports.main(); + + console.log('test result: ok. 1 passed; 0 failed'); + console.log(''); + console.log('note: This doctest ran in fallback mode without wasm-bindgen.'); + console.log(' Console output from the test was not captured.'); + exit(0); + } catch (e) { + console.error('Doctest failed:', e.message || e); + console.log('test result: FAILED. 0 passed; 1 failed'); + console.log(''); + console.log('note: This doctest ran in fallback mode without wasm-bindgen.'); + console.log(' For better error messages, add wasm_bindgen_test imports.'); + exit(1); + } +} + +run(); +"#; + + let js_path = tmpdir_path.join("run.cjs"); + fs::write(&js_path, js_to_execute).context("failed to write JS file")?; + + let extra_node_args = env::var("NODE_ARGS") + .unwrap_or_default() + .split(',') + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .collect::>(); + + let status = Command::new("node") + .current_dir(tmpdir_path) + .args(&extra_node_args) + .arg(&js_path) + .status() + .context("failed to find or execute Node.js")?; + + if !status.success() { + bail!("Node failed with exit_code {}", status.code().unwrap_or(1)) + } + + Ok(()) +} + +/// Execute a doctest in Deno by calling its `main` function. +pub fn execute_deno(module: &str, tmpdir: &Path) -> Result<(), Error> { + // Deno uses ES modules - import the wasm-bindgen generated module + // and access exports via __wasm (same as regular Deno tests) + let js_to_execute = format!( + r#"import * as wasm from "./{module}.js"; + +try {{ + if (typeof wasm.__wasm.main === 'function') {{ + wasm.__wasm.main(); + }} else {{ + throw new Error('No main function found in doctest wasm module'); + }} + console.log("test result: ok. 1 passed; 0 failed"); +}} catch (e) {{ + console.error("Doctest failed:", e); + console.log("test result: FAILED. 0 passed; 1 failed"); + Deno.exit(1); +}} +"# + ); + + let js_path = tmpdir.join("run.js"); + fs::write(&js_path, &js_to_execute).context("failed to write JS file")?; + + let status = Command::new("deno") + .arg("run") + .arg("--allow-read") + .arg(&js_path) + .status() + .context("failed to find or execute Deno")?; + + if !status.success() { + bail!("Deno failed with exit_code {}", status.code().unwrap_or(1)) + } + + Ok(()) +} diff --git a/crates/cli/src/wasm_bindgen_test_runner/server.rs b/crates/cli/src/wasm_bindgen_test_runner/server.rs index 215008ddade..444ab155dce 100644 --- a/crates/cli/src/wasm_bindgen_test_runner/server.rs +++ b/crates/cli/src/wasm_bindgen_test_runner/server.rs @@ -9,6 +9,37 @@ use rouille::{Request, Response, Server}; use super::{Cli, TestMode, Tests}; +/// Try to serve an asset from a directory, handling ES module imports without extensions. +fn try_asset(request: &Request, dir: &Path) -> Response { + let response = rouille::match_assets(request, dir); + if response.is_success() { + return response; + } + + // When a browser is doing ES imports it's using the directives we + // write in the code that *don't* have file extensions (aka we say `from + // 'foo'` instead of `from 'foo.js'`. Fixup those paths here to see if a + // `js` file exists. + if let Some(part) = request.url().split('/').next_back() { + if !part.contains('.') { + let new_request = Request::fake_http( + request.method(), + format!("{}.js", request.url()), + request + .headers() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(), + Vec::new(), + ); + let response = rouille::match_assets(&new_request, dir); + if response.is_success() { + return response; + } + } + } + response +} + pub(crate) fn spawn( addr: &SocketAddr, headless: bool, @@ -570,37 +601,7 @@ SharedWorker.prototype = __wbg_OriginalSharedWorker.prototype; response }) .map_err(|e| anyhow!("{e}"))?; - return Ok(srv); - - fn try_asset(request: &Request, dir: &Path) -> Response { - let response = rouille::match_assets(request, dir); - if response.is_success() { - return response; - } - - // When a browser is doing ES imports it's using the directives we - // write in the code that *don't* have file extensions (aka we say `from - // 'foo'` instead of `from 'foo.js'`. Fixup those paths here to see if a - // `js` file exists. - if let Some(part) = request.url().split('/').next_back() { - if !part.contains('.') { - let new_request = Request::fake_http( - request.method(), - format!("{}.js", request.url()), - request - .headers() - .map(|(a, b)| (a.to_string(), b.to_string())) - .collect(), - Vec::new(), - ); - let response = rouille::match_assets(&new_request, dir); - if response.is_success() { - return response; - } - } - } - response - } + Ok(srv) } fn handle_benchmark_fetch(path: &Path) -> Response { @@ -659,3 +660,392 @@ fn set_isolate_origin_headers(response: &mut Response) { Cow::Borrowed("require-corp"), )); } + +/// Spawn a server for running doctests in a browser. +/// Doctests are simpler than regular tests - they just call `main()`. +pub(crate) fn spawn_doctest( + addr: &SocketAddr, + headless: bool, + module: &'static str, + tmpdir: &Path, + test_mode: TestMode, + isolate_origin: bool, +) -> Result Response + Send + Sync>, Error> { + // For worker modes, we need to create a worker script + if test_mode.is_worker() { + let module_type = if test_mode.no_modules() { + "classic" + } else { + "module" + }; + + // Build worker script based on worker type + let (worker_script, worker_filename, main_page_script) = match test_mode { + TestMode::DedicatedWorker { .. } => { + // Console shim for dedicated worker - posts directly to self + let console_shim = r#" +["debug","log","info","warn","error"].forEach(m => { + const og = console[m]; + console[m] = function(...args) { + og.apply(this, args); + self.postMessage({ type: 'console', method: m, args: args.map(String) }); + }; +}); +"#; + let worker = if test_mode.no_modules() { + format!( + r#"importScripts("{module}.js"); +{console_shim} +async function runDoctest() {{ + try {{ + const wasm = await wasm_bindgen('./{module}_bg.wasm'); + wasm.main(); + self.postMessage({{ type: 'success' }}); + }} catch (e) {{ + console.error('Doctest failed:', e); + self.postMessage({{ type: 'error', message: String(e) }}); + }} +}} +runDoctest(); +"# + ) + } else { + format!( + r#"import init from './{module}.js'; +{console_shim} +async function runDoctest() {{ + try {{ + const wasm = await init('./{module}_bg.wasm'); + wasm.main(); + self.postMessage({{ type: 'success' }}); + }} catch (e) {{ + console.error('Doctest failed:', e); + self.postMessage({{ type: 'error', message: String(e) }}); + }} +}} +runDoctest(); +"# + ) + }; + + let main_page = format!( + r#" +document.getElementById('output').textContent = "Running doctest...\n"; +const worker = new Worker('worker.js', {{ type: '{module_type}' }}); + +worker.onmessage = function(e) {{ + if (e.data.type === 'console') {{ + const text = e.data.args.join(' '); + document.getElementById('output').textContent += text + '\n'; + }} else if (e.data.type === 'success') {{ + document.getElementById('output').textContent += "\ntest result: ok. 1 passed; 0 failed\n"; + }} else if (e.data.type === 'error') {{ + document.getElementById('output').textContent += "\nDoctest failed: " + e.data.message + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; + }} +}}; + +worker.onerror = function(e) {{ + console.error('Worker error:', e.message); + document.getElementById('output').textContent += "\nWorker error: " + e.message + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; +}}; +"# + ); + + (worker, "worker.js", main_page) + } + + TestMode::SharedWorker { .. } => { + // SharedWorker uses 'connect' event and port-based messaging + let worker = if test_mode.no_modules() { + format!( + r#"importScripts("{module}.js"); + +self.addEventListener('connect', async (e) => {{ + const port = e.ports[0]; + + // Console shim that forwards to port + ["debug","log","info","warn","error"].forEach(m => {{ + const og = console[m]; + console[m] = function(...args) {{ + og.apply(this, args); + port.postMessage({{ type: 'console', method: m, args: args.map(String) }}); + }}; + }}); + + try {{ + const wasm = await wasm_bindgen('./{module}_bg.wasm'); + wasm.main(); + port.postMessage({{ type: 'success' }}); + }} catch (e) {{ + console.error('Doctest failed:', e); + port.postMessage({{ type: 'error', message: String(e) }}); + }} +}}); +"# + ) + } else { + format!( + r#"import init from './{module}.js'; + +self.addEventListener('connect', async (e) => {{ + const port = e.ports[0]; + + // Console shim that forwards to port + ["debug","log","info","warn","error"].forEach(m => {{ + const og = console[m]; + console[m] = function(...args) {{ + og.apply(this, args); + port.postMessage({{ type: 'console', method: m, args: args.map(String) }}); + }}; + }}); + + try {{ + const wasm = await init('./{module}_bg.wasm'); + wasm.main(); + port.postMessage({{ type: 'success' }}); + }} catch (e) {{ + console.error('Doctest failed:', e); + port.postMessage({{ type: 'error', message: String(e) }}); + }} +}}); +"# + ) + }; + + let main_page = format!( + r#" +document.getElementById('output').textContent = "Running doctest...\n"; +const worker = new SharedWorker('worker.js?random=' + crypto.randomUUID(), {{ type: '{module_type}' }}); +const port = worker.port; +port.start(); + +port.onmessage = function(e) {{ + if (e.data.type === 'console') {{ + const text = e.data.args.join(' '); + document.getElementById('output').textContent += text + '\n'; + }} else if (e.data.type === 'success') {{ + document.getElementById('output').textContent += "\ntest result: ok. 1 passed; 0 failed\n"; + }} else if (e.data.type === 'error') {{ + document.getElementById('output').textContent += "\nDoctest failed: " + e.data.message + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; + }} +}}; + +worker.onerror = function(e) {{ + console.error('SharedWorker error:', e.message); + document.getElementById('output').textContent += "\nSharedWorker error: " + e.message + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; +}}; +"# + ); + + (worker, "worker.js", main_page) + } + + TestMode::ServiceWorker { .. } => { + // ServiceWorker has install/activate lifecycle + let worker = if test_mode.no_modules() { + format!( + r#"importScripts("{module}.js"); + +self.addEventListener('install', (e) => self.skipWaiting()); +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())); + +self.addEventListener('message', async (e) => {{ + const port = e.ports[0]; + + // Console shim that forwards to port + ["debug","log","info","warn","error"].forEach(m => {{ + const og = console[m]; + console[m] = function(...args) {{ + og.apply(this, args); + port.postMessage({{ type: 'console', method: m, args: args.map(String) }}); + }}; + }}); + + try {{ + const wasm = await wasm_bindgen('./{module}_bg.wasm'); + wasm.main(); + port.postMessage({{ type: 'success' }}); + }} catch (e) {{ + console.error('Doctest failed:', e); + port.postMessage({{ type: 'error', message: String(e) }}); + }} +}}); +"# + ) + } else { + format!( + r#"import init from './{module}.js'; + +self.addEventListener('install', (e) => self.skipWaiting()); +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())); + +self.addEventListener('message', async (e) => {{ + const port = e.ports[0]; + + // Console shim that forwards to port + ["debug","log","info","warn","error"].forEach(m => {{ + const og = console[m]; + console[m] = function(...args) {{ + og.apply(this, args); + port.postMessage({{ type: 'console', method: m, args: args.map(String) }}); + }}; + }}); + + try {{ + const wasm = await init('./{module}_bg.wasm'); + wasm.main(); + port.postMessage({{ type: 'success' }}); + }} catch (e) {{ + console.error('Doctest failed:', e); + port.postMessage({{ type: 'error', message: String(e) }}); + }} +}}); +"# + ) + }; + + let main_page = format!( + r#" +document.getElementById('output').textContent = "Running doctest...\n"; + +(async () => {{ + const url = 'service.js?random=' + crypto.randomUUID(); + const registration = await navigator.serviceWorker.register(url, {{ type: '{module_type}' }}); + + // Wait for the service worker to be ready + await new Promise((resolve) => {{ + if (navigator.serviceWorker.controller) {{ + resolve(); + }} else {{ + navigator.serviceWorker.addEventListener('controllerchange', () => resolve()); + }} + }}); + + const channel = new MessageChannel(); + channel.port1.onmessage = function(e) {{ + if (e.data.type === 'console') {{ + const text = e.data.args.join(' '); + document.getElementById('output').textContent += text + '\n'; + }} else if (e.data.type === 'success') {{ + document.getElementById('output').textContent += "\ntest result: ok. 1 passed; 0 failed\n"; + }} else if (e.data.type === 'error') {{ + document.getElementById('output').textContent += "\nDoctest failed: " + e.data.message + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; + }} + }}; + + navigator.serviceWorker.controller.postMessage(null, [channel.port2]); +}})().catch(e => {{ + console.error('ServiceWorker error:', e); + document.getElementById('output').textContent += "\nServiceWorker error: " + e + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; +}}); +"# + ); + + (worker, "service.js", main_page) + } + + _ => unreachable!("non-worker mode in worker branch"), + }; + + let worker_js_path = tmpdir.join(worker_filename); + fs::write(&worker_js_path, worker_script).context("failed to write worker JS file")?; + + let js_path = tmpdir.join("run.js"); + fs::write(&js_path, main_page_script).context("failed to write JS file")?; + } else { + // Browser mode (main thread) - run doctest directly on the page + let js_to_execute = if test_mode.no_modules() { + format!( + r#" +async function runDoctest() {{ + document.getElementById('output').textContent = "Loading Wasm module...\n"; + try {{ + const wasm = await wasm_bindgen('./{module}_bg.wasm'); + document.getElementById('output').textContent += "Running doctest...\n"; + wasm.main(); + document.getElementById('output').textContent += "\ntest result: ok. 1 passed; 0 failed\n"; + }} catch (e) {{ + console.error('Doctest failed:', e); + document.getElementById('output').textContent += "\nDoctest failed: " + e + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; + }} +}} +runDoctest(); +"# + ) + } else { + format!( + r#" +import init from './{module}.js'; + +async function runDoctest() {{ + document.getElementById('output').textContent = "Loading Wasm module...\n"; + try {{ + const wasm = await init('./{module}_bg.wasm'); + document.getElementById('output').textContent += "Running doctest...\n"; + wasm.main(); + document.getElementById('output').textContent += "\ntest result: ok. 1 passed; 0 failed\n"; + }} catch (e) {{ + console.error('Doctest failed:', e); + document.getElementById('output').textContent += "\nDoctest failed: " + e + "\n"; + document.getElementById('output').textContent += "test result: FAILED. 0 passed; 1 failed\n"; + }} +}} +runDoctest(); +"# + ) + }; + + let js_path = tmpdir.join("run.js"); + fs::write(&js_path, js_to_execute).context("failed to write JS file")?; + } + + let tmpdir = tmpdir.to_path_buf(); + let srv = Server::new(addr, move |request| { + if request.url() == "/" { + let s = if headless { + include_str!("index-headless.html") + } else { + include_str!("index.html") + }; + let s = s.replace("// {NOCAPTURE}", "const nocapture = true;"); + let s = if test_mode.no_modules() { + s.replace( + "", + &format!("\n"), + ) + } else { + s.replace( + "", + "", + ) + }; + + let mut response = Response::from_data("text/html", s); + if isolate_origin { + set_isolate_origin_headers(&mut response) + } + return response; + } + + // Serve static files + let mut response = try_asset(request, &tmpdir); + if !response.is_success() { + response = try_asset(request, ".".as_ref()); + } + response.headers.retain(|(k, _)| k != "Cache-Control"); + if isolate_origin { + set_isolate_origin_headers(&mut response) + } + response + }) + .map_err(|e| anyhow!("{e}"))?; + + Ok(srv) +} diff --git a/crates/cli/tests/wasm-bindgen-test-runner/doctests.rs b/crates/cli/tests/wasm-bindgen-test-runner/doctests.rs new file mode 100644 index 00000000000..1c03755e8b2 --- /dev/null +++ b/crates/cli/tests/wasm-bindgen-test-runner/doctests.rs @@ -0,0 +1,494 @@ +//! Tests for doctest support in wasm-bindgen-test-runner. +//! +//! Doctests export a `main` function instead of `__wbgt_*` test exports. +//! These tests verify that doctests are properly detected and executed +//! in various modes (Node.js, browser main thread, dedicated worker). +//! +//! Doctests are built from source using `cargo +nightly test --doc` with +//! `--persist-doctests` to capture the generated wasm files. +//! +//! These tests require nightly Rust and will be skipped if nightly is not available. + +use super::{Project, REPO_ROOT, TARGET_DIR}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::OnceLock; + +/// Check if nightly toolchain is available. Cached for performance. +fn has_nightly() -> bool { + static HAS_NIGHTLY: OnceLock = OnceLock::new(); + *HAS_NIGHTLY.get_or_init(|| { + Command::new("cargo") + .arg("+nightly") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }) +} + +/// Check if Deno is available. Cached for performance. +fn has_deno() -> bool { + static HAS_DENO: OnceLock = OnceLock::new(); + *HAS_DENO.get_or_init(|| { + Command::new("deno") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }) +} + +/// Skip a test if nightly is not available. Panics if nightly IS available +/// (meaning the test should have worked but failed for another reason). +macro_rules! require_nightly_or_skip { + ($result:expr) => { + match $result { + Ok(path) => path, + Err(e) => { + if has_nightly() { + panic!("Nightly is available but doctest build failed: {e}"); + } else { + eprintln!("Skipping test: nightly toolchain not available"); + return; + } + } + } + }; +} + +impl Project { + /// Build doctests and return the path to the generated wasm file. + /// Uses `cargo +nightly test --doc` with `--persist-doctests` to capture the wasm. + /// The `doctest_line` parameter specifies which line the doctest starts at (1-indexed). + pub fn build_doctest(&mut self, doctest_line: u32) -> anyhow::Result { + // Use a special cargo.toml for doctests - needs rlib, not cdylib + self.cargo_toml_for_doctest(); + + let doctests_dir = self.root.join("doctests"); + fs::create_dir_all(&doctests_dir)?; + + // Build the doctests with --persist-doctests + let output = std::process::Command::new("cargo") + .current_dir(&self.root) + .arg("+nightly") + .arg("test") + .arg("--target") + .arg("wasm32-unknown-unknown") + .arg("--doc") + .arg("-Zbuild-std=std,panic_abort") + .env("CARGO_TARGET_DIR", &*TARGET_DIR) + .env( + "RUSTDOCFLAGS", + format!("--persist-doctests {}", doctests_dir.display()), + ) + // We expect this to fail since there's no runner, but the wasm is still generated + .output()?; + + // The doctest directory name follows the pattern: src_lib_rs_{line}_0 + let doctest_dir_name = format!("src_lib_rs_{}_0", doctest_line); + let wasm_path = doctests_dir.join(&doctest_dir_name).join("rust_out.wasm"); + + if !wasm_path.exists() { + // Try to find what directories were created for debugging + let entries: Vec<_> = fs::read_dir(&doctests_dir) + .map(|rd| rd.filter_map(|e| e.ok()).collect()) + .unwrap_or_default(); + anyhow::bail!( + "Doctest wasm not found at {:?}. Available directories: {:?}\nstdout: {}\nstderr: {}", + wasm_path, + entries.iter().map(|e: &fs::DirEntry| e.file_name()).collect::>(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(wasm_path) + } + + /// Run wasm-bindgen-test-runner on a specific wasm file. + pub fn run_wasm_bindgen_test_runner(&self, wasm_path: &Path) -> anyhow::Result { + self.run_wasm_bindgen_test_runner_with_env(wasm_path, &[]) + } + + /// Run wasm-bindgen-test-runner on a specific wasm file with custom environment variables. + pub fn run_wasm_bindgen_test_runner_with_env( + &self, + wasm_path: &Path, + envs: &[(&str, &str)], + ) -> anyhow::Result { + let runner = REPO_ROOT.join("crates").join("cli").join("Cargo.toml"); + let mut cmd = std::process::Command::new("cargo"); + cmd.arg("run") + .arg("--manifest-path") + .arg(&runner) + .arg("--bin") + .arg("wasm-bindgen-test-runner") + .arg("--") + .arg(wasm_path); + for (key, value) in envs { + cmd.env(key, value); + } + let output = cmd.output()?; + Ok(output) + } + + /// Generate a Cargo.toml suitable for doctests (uses rlib, not cdylib). + fn cargo_toml_for_doctest(&mut self) { + self.file( + "Cargo.toml", + &format!( + r#" +[package] +name = "{}" +authors = [] +version = "1.0.0" +edition = "2021" + +[dependencies] +{} +{} + +[lib] +crate-type = ["rlib"] + +[workspace] + +[profile.dev] +codegen-units = 1 +"#, + self.name, + self.deps.replace("{root}", REPO_ROOT.to_str().unwrap()), + self.dev_deps.replace("{root}", REPO_ROOT.to_str().unwrap()), + ), + ); + } +} + +/// Test that a doctest runs correctly in Node.js (default mode). +#[test] +fn test_doctest_node() { + // The doctest is at line 1 of lib.rs (the ```rust line) + let mut project = Project::new("test_doctest_node"); + project.file( + "src/lib.rs", + r#"//! ``` +//! wasm_bindgen_test::console_log!("Hello from doctest!"); +//! ``` +"#, + ); + + let wasm_path = require_nightly_or_skip!(project.build_doctest(1)); + + let output = project + .run_wasm_bindgen_test_runner(&wasm_path) + .expect("Failed to run wasm-bindgen-test-runner"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Doctest should have been detected and run + assert!( + stdout.contains("running 1 doctest") || stderr.contains("running 1 doctest"), + "Expected 'running 1 doctest' in output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Console output should appear + assert!( + stdout.contains("Hello from doctest!") || stderr.contains("Hello from doctest!"), + "Expected doctest console.log output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Test should pass + assert!( + stdout.contains("test result: ok") || stderr.contains("test result: ok"), + "Expected doctest to pass.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!( + output.status.success(), + "Expected exit code 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Test that a doctest runs correctly in browser main thread mode. +#[test] +fn test_doctest_browser() { + let mut project = Project::new("test_doctest_browser"); + project.file( + "src/lib.rs", + r#"//! ``` +//! wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); +//! wasm_bindgen_test::console_log!("Hello from browser doctest!"); +//! ``` +"#, + ); + + let wasm_path = require_nightly_or_skip!(project.build_doctest(1)); + + let output = project + .run_wasm_bindgen_test_runner(&wasm_path) + .expect("Failed to run wasm-bindgen-test-runner"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Doctest should have been detected and run + assert!( + stdout.contains("running 1 doctest") || stderr.contains("running 1 doctest"), + "Expected 'running 1 doctest' in output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Console output should appear + assert!( + stdout.contains("Hello from browser doctest!") + || stderr.contains("Hello from browser doctest!"), + "Expected doctest console.log output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Test should pass + assert!( + stdout.contains("test result: ok") || stderr.contains("test result: ok"), + "Expected doctest to pass.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!( + output.status.success(), + "Expected exit code 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Test that a doctest configured for dedicated worker runs correctly. +#[test] +fn test_doctest_dedicated_worker() { + let mut project = Project::new("test_doctest_dedicated_worker"); + project.file( + "src/lib.rs", + r#"//! ``` +//! wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker); +//! wasm_bindgen_test::console_log!("Hello from worker doctest!"); +//! ``` +"#, + ); + + let wasm_path = require_nightly_or_skip!(project.build_doctest(1)); + + let output = project + .run_wasm_bindgen_test_runner(&wasm_path) + .expect("Failed to run wasm-bindgen-test-runner"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Doctest should have been detected and run + assert!( + stdout.contains("running 1 doctest") || stderr.contains("running 1 doctest"), + "Expected 'running 1 doctest' in output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Console output from the worker should appear + assert!( + stdout.contains("Hello from worker doctest!") + || stderr.contains("Hello from worker doctest!"), + "Expected doctest console.log output from worker.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Test should pass + assert!( + stdout.contains("test result: ok") || stderr.contains("test result: ok"), + "Expected doctest to pass.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!( + output.status.success(), + "Expected exit code 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Test that a doctest configured for Node.js ES module mode runs correctly. +#[test] +fn test_doctest_node_experimental() { + let mut project = Project::new("test_doctest_node_experimental"); + project.file( + "src/lib.rs", + r#"//! ``` +//! wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_node_experimental); +//! wasm_bindgen_test::console_log!("Hello from node experimental doctest!"); +//! ``` +"#, + ); + + let wasm_path = require_nightly_or_skip!(project.build_doctest(1)); + + let output = project + .run_wasm_bindgen_test_runner(&wasm_path) + .expect("Failed to run wasm-bindgen-test-runner"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Doctest should have been detected and run + assert!( + stdout.contains("running 1 doctest") || stderr.contains("running 1 doctest"), + "Expected 'running 1 doctest' in output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Console output should appear + assert!( + stdout.contains("Hello from node experimental doctest!") + || stderr.contains("Hello from node experimental doctest!"), + "Expected doctest console.log output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Test should pass + assert!( + stdout.contains("test result: ok") || stderr.contains("test result: ok"), + "Expected doctest to pass.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!( + output.status.success(), + "Expected exit code 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Test that a doctest configured for shared worker runs correctly. +#[test] +fn test_doctest_shared_worker() { + let mut project = Project::new("test_doctest_shared_worker"); + project.file( + "src/lib.rs", + r#"//! ``` +//! wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_shared_worker); +//! wasm_bindgen_test::console_log!("Hello from shared worker doctest!"); +//! ``` +"#, + ); + + let wasm_path = require_nightly_or_skip!(project.build_doctest(1)); + + let output = project + .run_wasm_bindgen_test_runner(&wasm_path) + .expect("Failed to run wasm-bindgen-test-runner"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Doctest should have been detected and run + assert!( + stdout.contains("running 1 doctest") || stderr.contains("running 1 doctest"), + "Expected 'running 1 doctest' in output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Console output from the worker should appear + assert!( + stdout.contains("Hello from shared worker doctest!") + || stderr.contains("Hello from shared worker doctest!"), + "Expected doctest console.log output from shared worker.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Test should pass + assert!( + stdout.contains("test result: ok") || stderr.contains("test result: ok"), + "Expected doctest to pass.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!( + output.status.success(), + "Expected exit code 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Test that a doctest configured for service worker runs correctly. +#[test] +fn test_doctest_service_worker() { + let mut project = Project::new("test_doctest_service_worker"); + project.file( + "src/lib.rs", + r#"//! ``` +//! wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_service_worker); +//! wasm_bindgen_test::console_log!("Hello from service worker doctest!"); +//! ``` +"#, + ); + + let wasm_path = require_nightly_or_skip!(project.build_doctest(1)); + + let output = project + .run_wasm_bindgen_test_runner(&wasm_path) + .expect("Failed to run wasm-bindgen-test-runner"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Doctest should have been detected and run + assert!( + stdout.contains("running 1 doctest") || stderr.contains("running 1 doctest"), + "Expected 'running 1 doctest' in output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Console output from the worker should appear + assert!( + stdout.contains("Hello from service worker doctest!") + || stderr.contains("Hello from service worker doctest!"), + "Expected doctest console.log output from service worker.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Test should pass + assert!( + stdout.contains("test result: ok") || stderr.contains("test result: ok"), + "Expected doctest to pass.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!( + output.status.success(), + "Expected exit code 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Test that a doctest runs correctly in Deno. +/// Deno mode is triggered via WASM_BINDGEN_USE_DENO environment variable. +#[test] +fn test_doctest_deno() { + if !has_deno() { + eprintln!("Skipping test: Deno not available"); + return; + } + + // For Deno, we use a plain doctest (no configure macro) and set env var + let mut project = Project::new("test_doctest_deno"); + project.file( + "src/lib.rs", + r#"//! ``` +//! wasm_bindgen_test::console_log!("Hello from deno doctest!"); +//! ``` +"#, + ); + + let wasm_path = require_nightly_or_skip!(project.build_doctest(1)); + + let output = project + .run_wasm_bindgen_test_runner_with_env(&wasm_path, &[("WASM_BINDGEN_USE_DENO", "1")]) + .expect("Failed to run wasm-bindgen-test-runner"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Doctest should have been detected and run + assert!( + stdout.contains("running 1 doctest") || stderr.contains("running 1 doctest"), + "Expected 'running 1 doctest' in output.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + // Test should pass + assert!( + stdout.contains("test result: ok") || stderr.contains("test result: ok"), + "Expected doctest to pass.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + assert!( + output.status.success(), + "Expected exit code 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} diff --git a/crates/cli/tests/wasm-bindgen-test-runner/main.rs b/crates/cli/tests/wasm-bindgen-test-runner/main.rs index 6483436ce00..72122e996ca 100644 --- a/crates/cli/tests/wasm-bindgen-test-runner/main.rs +++ b/crates/cli/tests/wasm-bindgen-test-runner/main.rs @@ -9,7 +9,9 @@ use std::path::PathBuf; use std::process::Output; use std::sync::LazyLock; -static TARGET_DIR: LazyLock = LazyLock::new(|| { +mod doctests; + +pub static TARGET_DIR: LazyLock = LazyLock::new(|| { let mut dir = env::current_exe().unwrap(); dir.pop(); // current exe if dir.ends_with("deps") { @@ -19,22 +21,22 @@ static TARGET_DIR: LazyLock = LazyLock::new(|| { dir }); -static REPO_ROOT: LazyLock = LazyLock::new(|| { +pub static REPO_ROOT: LazyLock = LazyLock::new(|| { let mut repo_root = env::current_dir().unwrap(); repo_root.pop(); // remove 'cli' repo_root.pop(); // remove 'crates' repo_root }); -struct Project { - root: PathBuf, - name: String, - deps: String, - dev_deps: String, +pub struct Project { + pub root: PathBuf, + pub name: String, + pub deps: String, + pub dev_deps: String, } impl Project { - fn new(name: impl Into) -> Project { + pub fn new(name: impl Into) -> Project { let name = name.into(); let root = TARGET_DIR.join("cli-tests").join(&name); drop(fs::remove_dir_all(&root)); @@ -47,7 +49,7 @@ impl Project { } } - fn file(&mut self, name: &str, contents: &str) -> &mut Project { + pub fn file(&mut self, name: &str, contents: &str) -> &mut Project { let dst = self.root.join(name); fs::create_dir_all(dst.parent().unwrap()).unwrap(); fs::write(&dst, contents).unwrap(); diff --git a/crates/msrv/cli/Cargo.toml b/crates/msrv/cli/Cargo.toml index 0b666c4a606..a92c9611e43 100644 --- a/crates/msrv/cli/Cargo.toml +++ b/crates/msrv/cli/Cargo.toml @@ -5,6 +5,7 @@ publish = false version = "0.0.0" [dependencies] +# Pin ICU dependencies to versions compatible with Rust 1.82 icu_collections = "=2.0.0" icu_locale_core = "=2.0.0" icu_normalizer = "=2.0.0" @@ -12,6 +13,15 @@ icu_normalizer_data = "=2.0.0" icu_properties = "=2.0.0" icu_properties_data = "=2.0.0" icu_provider = "=2.0.0" -wasm-bindgen-cli = { path = "../../cli" } + +# Pin time dependencies to versions compatible with Rust 1.82 +time = "=0.3.34" +time-core = "=0.1.2" + +# Pin other problematic dependencies +rouille = "=3.6.2" + # enable default feature for icu writeable = "0.6.0" + +wasm-bindgen-cli = { path = "../../cli" }