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 inconsitent 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_KEEP_LLD_EXPORTS").is_ok() {
350        b.keep_lld_exports(true);
351    }
352
353    // The path of benchmark baseline.
354    let benchmark = if let Ok(path) = std::env::var("WASM_BINDGEN_BENCH_RESULT") {
355        PathBuf::from(path)
356    } else {
357        // such as `js-sys/target/wbg_benchmark.json`
358        let path = env::current_dir()
359            .context("Failed to get current dir")?
360            .join("target");
361        // crates in the workspace that do not have a target dir.
362        if cli.bench {
363            fs::create_dir_all(&path)?;
364        }
365        path.join("wbg_benchmark.json")
366    };
367
368    // The debug here means adding some assertions and some error messages to the generated js
369    // code.
370    //
371    // It has nothing to do with Rust.
372    b.debug(debug)
373        .input_module(module, wasm)
374        .emit_start(false)
375        .generate(&tmpdir_path)
376        .context("executing `wasm-bindgen` over the Wasm file")?;
377    shell.clear();
378
379    match test_mode {
380        TestMode::Node { no_modules } => {
381            node::execute(module, &tmpdir_path, cli, tests, !no_modules, benchmark)?
382        }
383        TestMode::Deno => deno::execute(module, &tmpdir_path, cli, tests)?,
384        TestMode::Emscripten => {
385            let srv = server::spawn_emscripten(
386                &if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") {
387                    address.parse().unwrap()
388                } else if headless {
389                    "127.0.0.1:0".parse().unwrap()
390                } else {
391                    "127.0.0.1:8000".parse().unwrap()
392                },
393                &tmpdir_path,
394                std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(),
395            )
396            .context("failed to spawn server")?;
397            let addr = srv.server_addr();
398            if !headless {
399                println!("Interactive browsers tests are now available at http://{addr}");
400                println!();
401                println!("Note that interactive mode is enabled because `NO_HEADLESS`");
402                println!("is specified in the environment of this process. Once you're");
403                println!("done with testing you'll need to kill this server with");
404                println!("Ctrl-C.");
405                srv.run();
406                return Ok(());
407            }
408            println!("Tests are now available at http://{addr}");
409            thread::spawn(|| srv.run());
410            headless::run(&addr, &shell, &timeouts, cli.nocapture)?;
411        }
412        TestMode::Browser { .. }
413        | TestMode::DedicatedWorker { .. }
414        | TestMode::SharedWorker { .. }
415        | TestMode::ServiceWorker { .. } => {
416            let nocapture = cli.nocapture;
417            let srv = server::spawn(
418                &if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") {
419                    address.parse().unwrap()
420                } else if headless {
421                    "127.0.0.1:0".parse().unwrap()
422                } else {
423                    "127.0.0.1:8000".parse().unwrap()
424                },
425                headless,
426                module,
427                &tmpdir_path,
428                cli,
429                tests,
430                test_mode,
431                std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(),
432                benchmark,
433            )
434            .context("failed to spawn server")?;
435            let addr = srv.server_addr();
436
437            // TODO: eventually we should provide the ability to exit at some point
438            // (gracefully) here, but for now this just runs forever.
439            if !headless {
440                println!("Interactive browsers tests are now available at http://{addr}");
441                println!();
442                println!("Note that interactive mode is enabled because `NO_HEADLESS`");
443                println!("is specified in the environment of this process. Once you're");
444                println!("done with testing you'll need to kill this server with");
445                println!("Ctrl-C.");
446                srv.run();
447                return Ok(());
448            }
449
450            thread::spawn(|| srv.run());
451            headless::run(&addr, &shell, &timeouts, nocapture)?;
452        }
453    }
454    Ok(())
455}
456
457fn timeout_from_env(name: &str, default_secs: u64) -> Duration {
458    let Ok(value) = env::var(name) else {
459        return Duration::from_secs(default_secs);
460    };
461    let secs = value
462        .parse()
463        .unwrap_or_else(|_| panic!("Could not parse '{name}'"));
464    println!("Set {name} to {secs} seconds...");
465    Duration::from_secs(secs)
466}
467
468fn module_uses_memory64(wasm: &[u8]) -> anyhow::Result<bool> {
469    for payload in WasmParser::new(0).parse_all(wasm) {
470        match payload.context("failed to inspect Wasm module")? {
471            Payload::ImportSection(imports) => {
472                for import in imports {
473                    match import? {
474                        Imports::Single(_, import) => {
475                            if let TypeRef::Memory(memory) = import.ty {
476                                if memory.memory64 {
477                                    return Ok(true);
478                                }
479                            }
480                        }
481                        Imports::Compact1 { items, .. } => {
482                            for item in items {
483                                if let TypeRef::Memory(memory) = item?.ty {
484                                    if memory.memory64 {
485                                        return Ok(true);
486                                    }
487                                }
488                            }
489                        }
490                        Imports::Compact2 { ty, .. } => {
491                            if let TypeRef::Memory(memory) = ty {
492                                if memory.memory64 {
493                                    return Ok(true);
494                                }
495                            }
496                        }
497                    }
498                }
499            }
500            Payload::MemorySection(memories) => {
501                for memory in memories {
502                    if memory?.memory64 {
503                        return Ok(true);
504                    }
505                }
506            }
507            _ => {}
508        }
509    }
510
511    Ok(false)
512}
513
514#[derive(Debug, Copy, Clone, Eq, PartialEq)]
515enum TestMode {
516    Node { no_modules: bool },
517    Deno,
518    Browser { no_modules: bool },
519    DedicatedWorker { no_modules: bool },
520    SharedWorker { no_modules: bool },
521    ServiceWorker { no_modules: bool },
522    Emscripten,
523}
524
525impl TestMode {
526    fn is_worker(self) -> bool {
527        matches!(
528            self,
529            Self::DedicatedWorker { .. } | Self::SharedWorker { .. } | Self::ServiceWorker { .. }
530        )
531    }
532
533    fn no_modules(self) -> bool {
534        match self {
535            Self::Deno | Self::Emscripten => true,
536            Self::Browser { no_modules }
537            | Self::Node { no_modules }
538            | Self::DedicatedWorker { no_modules }
539            | Self::SharedWorker { no_modules }
540            | Self::ServiceWorker { no_modules } => no_modules,
541        }
542    }
543
544    fn env(self) -> &'static str {
545        match self {
546            TestMode::Node { .. } => "WASM_BINDGEN_USE_NODE_EXPERIMENTAL",
547            TestMode::Deno => "WASM_BINDGEN_USE_DENO",
548            TestMode::Browser { .. } => "WASM_BINDGEN_USE_BROWSER",
549            TestMode::DedicatedWorker { .. } => "WASM_BINDGEN_USE_DEDICATED_WORKER",
550            TestMode::SharedWorker { .. } => "WASM_BINDGEN_USE_SHARED_WORKER",
551            TestMode::ServiceWorker { .. } => "WASM_BINDGEN_USE_SERVICE_WORKER",
552            TestMode::Emscripten => "WASM_BINDGEN_USE_EMSCRIPTEN",
553        }
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::{Cli, Tests};
560    use std::path::PathBuf;
561
562    fn cli() -> Cli {
563        Cli {
564            file: PathBuf::from("test.wasm"),
565            bench: false,
566            include_ignored: true,
567            ignored: false,
568            exact: false,
569            skip: Vec::new(),
570            list: false,
571            nocapture: false,
572            format: None,
573            filter: None,
574        }
575    }
576
577    #[test]
578    fn runner_args_keep_filtered_count_on_number_abi() {
579        let cli = cli();
580        let tests = Tests {
581            tests: Vec::new(),
582            filtered: 3,
583        };
584
585        let args = cli.get_args(&tests);
586
587        assert!(args.contains("cx.include_ignored(true);"));
588        assert!(args.contains("cx.filtered_count(3);"));
589        assert!(!args.contains("3n"));
590    }
591}
592
593/// Possible values for the `--format` option.
594#[derive(Debug, Clone, Copy, ValueEnum)]
595enum FormatSetting {
596    /// Display one character per test
597    Terse,
598}