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