Skip to main content

wasm_bindgen_cli/
wasm_bindgen_test_runner.rs

1//! A "wrapper binary" used to execute Wasm files as tests
2//!
3//! This binary is intended to be used as a "test runner" for Wasm binaries,
4//! being compatible with `cargo test` for the Wasm target. It will
5//! automatically execute `wasm-bindgen` (or the equivalent thereof) and then
6//! execute either Node.js over the tests or start a server which a browser can
7//! be used to run against to execute tests. In a browser mode if `CI` is in the
8//! environment then it'll also attempt headless testing, spawning the server in
9//! the background and then using the WebDriver protocol to execute tests.
10//!
11//! For more documentation about this see the `wasm-bindgen-test` crate README
12//! and source code.
13
14use anyhow::{bail, Context};
15use clap::Parser;
16use clap::ValueEnum;
17use std::env;
18use std::ffi::OsString;
19use std::fs;
20use std::path::PathBuf;
21use std::thread;
22use std::time::Duration;
23use wasm_bindgen_cli_support::Bindgen;
24use wasmparser::{Imports, Parser as WasmParser, Payload, TypeRef};
25
26mod deno;
27mod headless;
28mod node;
29mod server;
30mod shell;
31
32#[derive(Parser)]
33#[command(name = "wasm-bindgen-test-runner", version, about, long_about = None)]
34struct Cli {
35    #[arg(
36        index = 1,
37        help = "The file to test. `cargo test` passes this argument for you."
38    )]
39    file: PathBuf,
40    #[arg(long, help = "Run benchmarks")]
41    bench: bool,
42    #[arg(long, conflicts_with = "ignored", help = "Run ignored tests")]
43    include_ignored: bool,
44    #[arg(long, conflicts_with = "include_ignored", help = "Run ignored tests")]
45    ignored: bool,
46    #[arg(long, help = "Exactly match filters rather than by substring")]
47    exact: bool,
48    #[arg(
49        long,
50        value_name = "FILTER",
51        help = "Skip tests whose names contain FILTER (this flag can be used multiple times)"
52    )]
53    skip: Vec<String>,
54    #[arg(long, help = "List all tests and benchmarks")]
55    list: bool,
56    #[arg(
57        long,
58        help = "don't capture `console.*()` of each task, allow printing directly"
59    )]
60    nocapture: bool,
61    #[arg(
62        long,
63        value_enum,
64        value_name = "terse",
65        help = "Configure formatting of output"
66    )]
67    format: Option<FormatSetting>,
68    #[arg(
69        index = 2,
70        value_name = "FILTER",
71        help = "The FILTER string is tested against the name of all tests, and only those tests \
72                whose names contain the filter are run."
73    )]
74    filter: Option<String>,
75}
76
77impl Cli {
78    fn get_args(&self, tests: &Tests) -> String {
79        let include_ignored = self.include_ignored;
80        let filtered = tests.filtered;
81
82        format!(
83            r#"
84            // Forward runtime arguments.
85            cx.include_ignored({include_ignored:?});
86            cx.filtered_count({filtered});
87        "#
88        )
89    }
90}
91
92struct Tests {
93    tests: Vec<Test>,
94    filtered: usize,
95}
96
97impl Tests {
98    fn new() -> Self {
99        Self {
100            tests: Vec::new(),
101            filtered: 0,
102        }
103    }
104}
105
106struct Test {
107    // test name
108    name: String,
109    // symbol name
110    export: String,
111    ignored: bool,
112}
113
114pub fn run_cli_with_args<I, T>(args: I) -> anyhow::Result<()>
115where
116    I: IntoIterator<Item = T>,
117    T: Into<OsString> + Clone,
118{
119    let cli = match Cli::try_parse_from(args) {
120        Ok(a) => a,
121        Err(e) => match e.kind() {
122            // Passing --version and --help should not result in a failure.
123            clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
124                print!("{e}");
125                return Ok(());
126            }
127            _ => bail!(e),
128        },
129    };
130    rmain(cli)
131}
132
133fn rmain(cli: Cli) -> anyhow::Result<()> {
134    let mut file_name_buf = cli.file.clone();
135
136    // Repoint the file to be read from "name.js" to "name.wasm" in the case of emscripten.
137    // Rustc generates a .js and a .wasm file when targeting emscripten. It lists the .js
138    // file as the primary executor which is inconsistent with what is expected here.
139    if file_name_buf.extension().unwrap_or_default() == "js" {
140        file_name_buf.set_extension("wasm");
141    }
142    let wasm = fs::read(file_name_buf).context("failed to read Wasm file")?;
143    let uses_memory64 = module_uses_memory64(&wasm)?;
144    let mut wasm = walrus::ModuleConfig::new()
145        // Generate DWARF by default so the test runner preserves debug info for
146        // ordinary wasm32 test binaries. Walrus 0.26 currently overflows while
147        // remapping DWARF addresses for memory64 modules, so skip DWARF
148        // generation for wasm64 inputs until upstream supports them.
149        .generate_dwarf(!uses_memory64)
150        .parse(&wasm)
151        .context("failed to deserialize Wasm module")?;
152    let mut tests = Tests::new();
153
154    // benchmark or test
155    let prefix = if cli.bench { "__wbgb_" } else { "__wbgt_" };
156
157    'outer: for export in wasm.exports.iter() {
158        let Some(name) = export.name.strip_prefix(prefix) else {
159            continue;
160        };
161        let modifiers = name.split_once('_').expect("found invalid identifier").0;
162
163        let Some(name) = export.name.split_once("::").map(|s| s.1) else {
164            continue;
165        };
166
167        let test = Test {
168            name: name.into(),
169            export: export.name.clone(),
170            ignored: modifiers.contains('$'),
171        };
172
173        if let Some(filter) = &cli.filter {
174            let matches = if cli.exact {
175                name == *filter
176            } else {
177                name.contains(filter)
178            };
179
180            if !matches {
181                tests.filtered += 1;
182                continue;
183            }
184        }
185
186        for skip in &cli.skip {
187            let matches = if cli.exact {
188                name == *skip
189            } else {
190                name.contains(skip)
191            };
192
193            if matches {
194                tests.filtered += 1;
195                continue 'outer;
196            }
197        }
198
199        if !test.ignored && cli.ignored {
200            tests.filtered += 1;
201        } else {
202            tests.tests.push(test);
203        }
204    }
205
206    if cli.list {
207        for test in tests.tests {
208            if cli.bench {
209                println!("{}: benchmark", test.name);
210            } else {
211                println!("{}: test", test.name);
212            }
213        }
214
215        return Ok(());
216    }
217
218    let tmpdir = tempfile::tempdir()?;
219
220    // Support a WASM_BINDGEN_KEEP_TEST_BUILD=1 env var for debugging test files
221    let tmpdir_path = if env::var("WASM_BINDGEN_KEEP_TEST_BUILD").is_ok() {
222        let path = tmpdir.keep();
223        println!(
224            "Retaining temporary build output folder: {}",
225            path.to_string_lossy()
226        );
227        path
228    } else {
229        tmpdir.path().to_path_buf()
230    };
231
232    let module = "wasm-bindgen-test";
233
234    // Right now there's a bug where if no tests are present then the
235    // `wasm-bindgen-test` runtime support isn't linked in, so just bail out
236    // early saying everything is ok.
237    if tests.tests.is_empty() {
238        println!("no tests to run!");
239        return Ok(());
240    }
241
242    // Figure out if this tests is supposed to execute in node.js or a browser.
243    // That's done on a per-test-binary basis with the
244    // `wasm_bindgen_test_configure` macro, which emits a custom section for us
245    // to read later on.
246
247    let custom_section = wasm.customs.remove_raw("__wasm_bindgen_test_unstable");
248    let no_modules = std::env::var("WASM_BINDGEN_USE_NO_MODULE").is_ok();
249    let test_mode = match custom_section {
250        Some(section) if section.data.contains(&0x01) => TestMode::Browser { no_modules },
251        Some(section) if section.data.contains(&0x02) => TestMode::DedicatedWorker { no_modules },
252        Some(section) if section.data.contains(&0x03) => TestMode::SharedWorker { no_modules },
253        Some(section) if section.data.contains(&0x04) => TestMode::ServiceWorker { no_modules },
254        Some(section) if section.data.contains(&0x05) => TestMode::Node { no_modules },
255        Some(section) if section.data.contains(&0x06) => TestMode::Emscripten {},
256        Some(_) => bail!("invalid __wasm_bingen_test_unstable value"),
257        None => {
258            let mut modes = Vec::new();
259            let mut add_mode =
260                |mode: TestMode| std::env::var(mode.env()).is_ok().then(|| modes.push(mode));
261            add_mode(TestMode::Deno);
262            add_mode(TestMode::Browser { no_modules });
263            add_mode(TestMode::DedicatedWorker { no_modules });
264            add_mode(TestMode::SharedWorker { no_modules });
265            add_mode(TestMode::ServiceWorker { no_modules });
266            add_mode(TestMode::Node { no_modules });
267
268            match modes.len() {
269                0 => TestMode::Node { no_modules: true },
270                1 => modes[0],
271                _ => {
272                    bail!(
273                        "only one test mode must be set, found: `{}`",
274                        modes
275                            .into_iter()
276                            .map(TestMode::env)
277                            .collect::<Vec<_>>()
278                            .join("`, `")
279                    )
280                }
281            }
282        }
283    };
284
285    let headless = env::var("NO_HEADLESS").is_err();
286    let debug = env::var("WASM_BINDGEN_NO_DEBUG").is_err();
287
288    // Gracefully handle requests to execute only node or only web tests.
289    let node = matches!(test_mode, TestMode::Node { .. });
290
291    if env::var_os("WASM_BINDGEN_TEST_ONLY_NODE").is_some() && !node {
292        println!(
293            "this test suite is only configured to run in a browser, \
294             but we're only testing node.js tests so skipping"
295        );
296        return Ok(());
297    }
298    if env::var_os("WASM_BINDGEN_TEST_ONLY_WEB").is_some() && node {
299        println!(
300            "\
301    This test suite is only configured to run in node.js, but we're only running
302    browser tests so skipping. If you'd like to run the tests in a browser
303    include this in your crate when testing:
304
305        wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
306
307    You'll likely want to put that in a `#[cfg(test)]` module or at the top of an
308    integration test.\
309    "
310        );
311        return Ok(());
312    }
313
314    let test = timeout_from_env("WASM_BINDGEN_TEST_TIMEOUT", 20);
315    // Browser launch and page load follow the drivers' own default limits, but
316    // never undercut a raised test timeout.
317    let timeouts = headless::Timeouts {
318        driver: timeout_from_env("WASM_BINDGEN_TEST_DRIVER_TIMEOUT", 5),
319        startup: Duration::from_secs(60).max(test),
320        page_load: Duration::from_secs(300).max(test),
321        test,
322    };
323
324    let shell = shell::Shell::new();
325
326    // Make the generated bindings available for the tests to execute against.
327    shell.status("Executing bindgen...");
328    let mut b = Bindgen::new();
329    match test_mode {
330        TestMode::Node { no_modules: true } => b.nodejs(true)?,
331        TestMode::Node { no_modules: false } => b.nodejs_module(true)?,
332        TestMode::Deno => b.deno(true)?,
333        TestMode::Browser { .. }
334        | TestMode::DedicatedWorker { .. }
335        | TestMode::SharedWorker { .. }
336        | TestMode::ServiceWorker { .. }
337        | TestMode::Emscripten => {
338            if test_mode.no_modules() {
339                b.no_modules(true)?
340            } else {
341                b.web(true)?
342            }
343        }
344    };
345
346    if std::env::var("WASM_BINDGEN_SPLIT_LINKED_MODULES").is_ok() {
347        b.split_linked_modules(true);
348    }
349    if std::env::var("WASM_BINDGEN_JS_STRING_BUILTINS").is_ok() {
350        b.js_string_builtins(true);
351    }
352    if std::env::var("WASM_BINDGEN_KEEP_LLD_EXPORTS").is_ok() {
353        b.keep_lld_exports(true);
354    }
355
356    // The path of benchmark baseline.
357    let benchmark = if let Ok(path) = std::env::var("WASM_BINDGEN_BENCH_RESULT") {
358        PathBuf::from(path)
359    } else {
360        // such as `js-sys/target/wbg_benchmark.json`
361        let path = env::current_dir()
362            .context("Failed to get current dir")?
363            .join("target");
364        // crates in the workspace that do not have a target dir.
365        if cli.bench {
366            fs::create_dir_all(&path)?;
367        }
368        path.join("wbg_benchmark.json")
369    };
370
371    // The debug here means adding some assertions and some error messages to the generated js
372    // code.
373    //
374    // It has nothing to do with Rust.
375    //
376    // The tests are plain Wasm exports, reached through `__wasm`.
377    b.debug(debug)
378        .export_wasm(true)
379        .input_module(module, wasm)
380        .emit_start(false)
381        .generate(&tmpdir_path)
382        .context("executing `wasm-bindgen` over the Wasm file")?;
383    shell.clear();
384
385    match test_mode {
386        TestMode::Node { no_modules } => {
387            node::execute(module, &tmpdir_path, cli, tests, !no_modules, benchmark)?
388        }
389        TestMode::Deno => deno::execute(module, &tmpdir_path, cli, tests)?,
390        TestMode::Emscripten => {
391            let srv = server::spawn_emscripten(
392                &if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") {
393                    address.parse().unwrap()
394                } else if headless {
395                    "127.0.0.1:0".parse().unwrap()
396                } else {
397                    "127.0.0.1:8000".parse().unwrap()
398                },
399                &tmpdir_path,
400                std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(),
401            )
402            .context("failed to spawn server")?;
403            let addr = srv.server_addr();
404            if !headless {
405                println!("Interactive browsers tests are now available at http://{addr}");
406                println!();
407                println!("Note that interactive mode is enabled because `NO_HEADLESS`");
408                println!("is specified in the environment of this process. Once you're");
409                println!("done with testing you'll need to kill this server with");
410                println!("Ctrl-C.");
411                srv.run();
412                return Ok(());
413            }
414            println!("Tests are now available at http://{addr}");
415            thread::spawn(|| srv.run());
416            headless::run(&addr, &shell, &timeouts, cli.nocapture)?;
417        }
418        TestMode::Browser { .. }
419        | TestMode::DedicatedWorker { .. }
420        | TestMode::SharedWorker { .. }
421        | TestMode::ServiceWorker { .. } => {
422            let nocapture = cli.nocapture;
423            let srv = server::spawn(
424                &if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") {
425                    address.parse().unwrap()
426                } else if headless {
427                    "127.0.0.1:0".parse().unwrap()
428                } else {
429                    "127.0.0.1:8000".parse().unwrap()
430                },
431                headless,
432                module,
433                &tmpdir_path,
434                cli,
435                tests,
436                test_mode,
437                std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(),
438                benchmark,
439            )
440            .context("failed to spawn server")?;
441            let addr = srv.server_addr();
442
443            // TODO: eventually we should provide the ability to exit at some point
444            // (gracefully) here, but for now this just runs forever.
445            if !headless {
446                println!("Interactive browsers tests are now available at http://{addr}");
447                println!();
448                println!("Note that interactive mode is enabled because `NO_HEADLESS`");
449                println!("is specified in the environment of this process. Once you're");
450                println!("done with testing you'll need to kill this server with");
451                println!("Ctrl-C.");
452                srv.run();
453                return Ok(());
454            }
455
456            thread::spawn(|| srv.run());
457            headless::run(&addr, &shell, &timeouts, nocapture)?;
458        }
459    }
460    Ok(())
461}
462
463fn timeout_from_env(name: &str, default_secs: u64) -> Duration {
464    let Ok(value) = env::var(name) else {
465        return Duration::from_secs(default_secs);
466    };
467    let secs = value
468        .parse()
469        .unwrap_or_else(|_| panic!("Could not parse '{name}'"));
470    println!("Set {name} to {secs} seconds...");
471    Duration::from_secs(secs)
472}
473
474fn module_uses_memory64(wasm: &[u8]) -> anyhow::Result<bool> {
475    for payload in WasmParser::new(0).parse_all(wasm) {
476        match payload.context("failed to inspect Wasm module")? {
477            Payload::ImportSection(imports) => {
478                for import in imports {
479                    match import? {
480                        Imports::Single(_, import) => {
481                            if let TypeRef::Memory(memory) = import.ty {
482                                if memory.memory64 {
483                                    return Ok(true);
484                                }
485                            }
486                        }
487                        Imports::Compact1 { items, .. } => {
488                            for item in items {
489                                if let TypeRef::Memory(memory) = item?.ty {
490                                    if memory.memory64 {
491                                        return Ok(true);
492                                    }
493                                }
494                            }
495                        }
496                        Imports::Compact2 { ty, .. } => {
497                            if let TypeRef::Memory(memory) = ty {
498                                if memory.memory64 {
499                                    return Ok(true);
500                                }
501                            }
502                        }
503                    }
504                }
505            }
506            Payload::MemorySection(memories) => {
507                for memory in memories {
508                    if memory?.memory64 {
509                        return Ok(true);
510                    }
511                }
512            }
513            _ => {}
514        }
515    }
516
517    Ok(false)
518}
519
520#[derive(Debug, Copy, Clone, Eq, PartialEq)]
521enum TestMode {
522    Node { no_modules: bool },
523    Deno,
524    Browser { no_modules: bool },
525    DedicatedWorker { no_modules: bool },
526    SharedWorker { no_modules: bool },
527    ServiceWorker { no_modules: bool },
528    Emscripten,
529}
530
531impl TestMode {
532    fn is_worker(self) -> bool {
533        matches!(
534            self,
535            Self::DedicatedWorker { .. } | Self::SharedWorker { .. } | Self::ServiceWorker { .. }
536        )
537    }
538
539    fn no_modules(self) -> bool {
540        match self {
541            Self::Deno | Self::Emscripten => true,
542            Self::Browser { no_modules }
543            | Self::Node { no_modules }
544            | Self::DedicatedWorker { no_modules }
545            | Self::SharedWorker { no_modules }
546            | Self::ServiceWorker { no_modules } => no_modules,
547        }
548    }
549
550    fn env(self) -> &'static str {
551        match self {
552            TestMode::Node { .. } => "WASM_BINDGEN_USE_NODE_EXPERIMENTAL",
553            TestMode::Deno => "WASM_BINDGEN_USE_DENO",
554            TestMode::Browser { .. } => "WASM_BINDGEN_USE_BROWSER",
555            TestMode::DedicatedWorker { .. } => "WASM_BINDGEN_USE_DEDICATED_WORKER",
556            TestMode::SharedWorker { .. } => "WASM_BINDGEN_USE_SHARED_WORKER",
557            TestMode::ServiceWorker { .. } => "WASM_BINDGEN_USE_SERVICE_WORKER",
558            TestMode::Emscripten => "WASM_BINDGEN_USE_EMSCRIPTEN",
559        }
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::{Cli, Tests};
566    use std::path::PathBuf;
567
568    fn cli() -> Cli {
569        Cli {
570            file: PathBuf::from("test.wasm"),
571            bench: false,
572            include_ignored: true,
573            ignored: false,
574            exact: false,
575            skip: Vec::new(),
576            list: false,
577            nocapture: false,
578            format: None,
579            filter: None,
580        }
581    }
582
583    #[test]
584    fn runner_args_keep_filtered_count_on_number_abi() {
585        let cli = cli();
586        let tests = Tests {
587            tests: Vec::new(),
588            filtered: 3,
589        };
590
591        let args = cli.get_args(&tests);
592
593        assert!(args.contains("cx.include_ignored(true);"));
594        assert!(args.contains("cx.filtered_count(3);"));
595        assert!(!args.contains("3n"));
596    }
597}
598
599/// Possible values for the `--format` option.
600#[derive(Debug, Clone, Copy, ValueEnum)]
601enum FormatSetting {
602    /// Display one character per test
603    Terse,
604}