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