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    // Collect all tests that the test harness is supposed to run. We assume
133    // that any exported function with the prefix `__wbg_test` is a test we need
134    // to execute.
135    let wasm = fs::read(&cli.file).context("failed to read Wasm file")?;
136    let mut wasm = walrus::ModuleConfig::new()
137        // generate dwarf by default, it can be controlled by debug profile
138        //
139        // https://doc.rust-lang.org/cargo/reference/profiles.html#debug
140        .generate_dwarf(true)
141        .parse(&wasm)
142        .context("failed to deserialize Wasm module")?;
143    let mut tests = Tests::new();
144
145    // benchmark or test
146    let prefix = if cli.bench { "__wbgb_" } else { "__wbgt_" };
147
148    'outer: for export in wasm.exports.iter() {
149        let Some(name) = export.name.strip_prefix(prefix) else {
150            continue;
151        };
152        let modifiers = name.split_once('_').expect("found invalid identifier").0;
153
154        let Some(name) = export.name.split_once("::").map(|s| s.1) else {
155            continue;
156        };
157
158        let test = Test {
159            name: name.into(),
160            export: export.name.clone(),
161            ignored: modifiers.contains('$'),
162        };
163
164        if let Some(filter) = &cli.filter {
165            let matches = if cli.exact {
166                name == *filter
167            } else {
168                name.contains(filter)
169            };
170
171            if !matches {
172                tests.filtered += 1;
173                continue;
174            }
175        }
176
177        for skip in &cli.skip {
178            let matches = if cli.exact {
179                name == *skip
180            } else {
181                name.contains(skip)
182            };
183
184            if matches {
185                tests.filtered += 1;
186                continue 'outer;
187            }
188        }
189
190        if !test.ignored && cli.ignored {
191            tests.filtered += 1;
192        } else {
193            tests.tests.push(test);
194        }
195    }
196
197    if cli.list {
198        for test in tests.tests {
199            if cli.bench {
200                println!("{}: benchmark", test.name);
201            } else {
202                println!("{}: test", test.name);
203            }
204        }
205
206        return Ok(());
207    }
208
209    let tmpdir = tempfile::tempdir()?;
210
211    // Support a WASM_BINDGEN_KEEP_TEST_BUILD=1 env var for debugging test files
212    let tmpdir_path = if env::var("WASM_BINDGEN_KEEP_TEST_BUILD").is_ok() {
213        let path = tmpdir.keep();
214        println!(
215            "Retaining temporary build output folder: {}",
216            path.to_string_lossy()
217        );
218        path
219    } else {
220        tmpdir.path().to_path_buf()
221    };
222
223    let module = "wasm-bindgen-test";
224
225    // Right now there's a bug where if no tests are present then the
226    // `wasm-bindgen-test` runtime support isn't linked in, so just bail out
227    // early saying everything is ok.
228    if tests.tests.is_empty() {
229        println!("no tests to run!");
230        return Ok(());
231    }
232
233    // Figure out if this tests is supposed to execute in node.js or a browser.
234    // That's done on a per-test-binary basis with the
235    // `wasm_bindgen_test_configure` macro, which emits a custom section for us
236    // to read later on.
237
238    let custom_section = wasm.customs.remove_raw("__wasm_bindgen_test_unstable");
239    let no_modules = std::env::var("WASM_BINDGEN_USE_NO_MODULE").is_ok();
240    let test_mode = match custom_section {
241        Some(section) if section.data.contains(&0x01) => TestMode::Browser { no_modules },
242        Some(section) if section.data.contains(&0x02) => TestMode::DedicatedWorker { no_modules },
243        Some(section) if section.data.contains(&0x03) => TestMode::SharedWorker { no_modules },
244        Some(section) if section.data.contains(&0x04) => TestMode::ServiceWorker { no_modules },
245        Some(section) if section.data.contains(&0x05) => TestMode::Node { no_modules },
246        Some(_) => bail!("invalid __wasm_bingen_test_unstable value"),
247        None => {
248            let mut modes = Vec::new();
249            let mut add_mode =
250                |mode: TestMode| std::env::var(mode.env()).is_ok().then(|| modes.push(mode));
251            add_mode(TestMode::Deno);
252            add_mode(TestMode::Browser { no_modules });
253            add_mode(TestMode::DedicatedWorker { no_modules });
254            add_mode(TestMode::SharedWorker { no_modules });
255            add_mode(TestMode::ServiceWorker { no_modules });
256            add_mode(TestMode::Node { no_modules });
257
258            match modes.len() {
259                0 => TestMode::Node { no_modules: true },
260                1 => modes[0],
261                _ => {
262                    bail!(
263                        "only one test mode must be set, found: `{}`",
264                        modes
265                            .into_iter()
266                            .map(TestMode::env)
267                            .collect::<Vec<_>>()
268                            .join("`, `")
269                    )
270                }
271            }
272        }
273    };
274
275    let headless = env::var("NO_HEADLESS").is_err();
276    let debug = env::var("WASM_BINDGEN_NO_DEBUG").is_err();
277
278    // Gracefully handle requests to execute only node or only web tests.
279    let node = matches!(test_mode, TestMode::Node { .. });
280
281    if env::var_os("WASM_BINDGEN_TEST_ONLY_NODE").is_some() && !node {
282        println!(
283            "this test suite is only configured to run in a browser, \
284             but we're only testing node.js tests so skipping"
285        );
286        return Ok(());
287    }
288    if env::var_os("WASM_BINDGEN_TEST_ONLY_WEB").is_some() && node {
289        println!(
290            "\
291    This test suite is only configured to run in node.js, but we're only running
292    browser tests so skipping. If you'd like to run the tests in a browser
293    include this in your crate when testing:
294
295        wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
296
297    You'll likely want to put that in a `#[cfg(test)]` module or at the top of an
298    integration test.\
299    "
300        );
301        return Ok(());
302    }
303
304    let driver_timeout = env::var("WASM_BINDGEN_TEST_DRIVER_TIMEOUT")
305        .map(|timeout| {
306            timeout
307                .parse()
308                .expect("Could not parse 'WASM_BINDGEN_TEST_DRIVER_TIMEOUT'")
309        })
310        .unwrap_or(5);
311
312    let browser_timeout = env::var("WASM_BINDGEN_TEST_TIMEOUT")
313        .map(|timeout| {
314            let timeout = timeout
315                .parse()
316                .expect("Could not parse 'WASM_BINDGEN_TEST_TIMEOUT'");
317            println!("Set timeout to {timeout} seconds...");
318            timeout
319        })
320        .unwrap_or(20);
321
322    let shell = shell::Shell::new();
323
324    // Make the generated bindings available for the tests to execute against.
325    shell.status("Executing bindgen...");
326    let mut b = Bindgen::new();
327    match test_mode {
328        TestMode::Node { no_modules: true } => b.nodejs(true)?,
329        TestMode::Node { no_modules: false } => b.nodejs_module(true)?,
330        TestMode::Deno => b.deno(true)?,
331        TestMode::Browser { .. }
332        | TestMode::DedicatedWorker { .. }
333        | TestMode::SharedWorker { .. }
334        | TestMode::ServiceWorker { .. } => {
335            if test_mode.no_modules() {
336                b.no_modules(true)?
337            } else {
338                b.web(true)?
339            }
340        }
341    };
342
343    if std::env::var("WASM_BINDGEN_SPLIT_LINKED_MODULES").is_ok() {
344        b.split_linked_modules(true);
345    }
346    if std::env::var("WASM_BINDGEN_KEEP_LLD_EXPORTS").is_ok() {
347        b.keep_lld_exports(true);
348    }
349
350    // The path of benchmark baseline.
351    let benchmark = if let Ok(path) = std::env::var("WASM_BINDGEN_BENCH_RESULT") {
352        PathBuf::from(path)
353    } else {
354        // such as `js-sys/target/wbg_benchmark.json`
355        let path = env::current_dir()
356            .context("Failed to get current dir")?
357            .join("target");
358        // crates in the workspace that do not have a target dir.
359        if cli.bench {
360            fs::create_dir_all(&path)?;
361        }
362        path.join("wbg_benchmark.json")
363    };
364
365    // The debug here means adding some assertions and some error messages to the generated js
366    // code.
367    //
368    // It has nothing to do with Rust.
369    b.debug(debug)
370        .input_module(module, wasm)
371        .emit_start(false)
372        .generate(&tmpdir_path)
373        .context("executing `wasm-bindgen` over the Wasm file")?;
374    shell.clear();
375
376    match test_mode {
377        TestMode::Node { no_modules } => {
378            node::execute(module, &tmpdir_path, cli, tests, !no_modules, benchmark)?
379        }
380        TestMode::Deno => deno::execute(module, &tmpdir_path, cli, tests)?,
381        TestMode::Browser { .. }
382        | TestMode::DedicatedWorker { .. }
383        | TestMode::SharedWorker { .. }
384        | TestMode::ServiceWorker { .. } => {
385            let srv = server::spawn(
386                &if headless {
387                    "127.0.0.1:0".parse().unwrap()
388                } else if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") {
389                    address.parse().unwrap()
390                } else {
391                    "127.0.0.1:8000".parse().unwrap()
392                },
393                headless,
394                module,
395                &tmpdir_path,
396                cli,
397                tests,
398                test_mode,
399                std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(),
400                benchmark,
401            )
402            .context("failed to spawn server")?;
403            let addr = srv.server_addr();
404
405            // TODO: eventually we should provide the ability to exit at some point
406            // (gracefully) here, but for now this just runs forever.
407            if !headless {
408                println!("Interactive browsers tests are now available at http://{addr}");
409                println!();
410                println!("Note that interactive mode is enabled because `NO_HEADLESS`");
411                println!("is specified in the environment of this process. Once you're");
412                println!("done with testing you'll need to kill this server with");
413                println!("Ctrl-C.");
414                srv.run();
415                return Ok(());
416            }
417
418            thread::spawn(|| srv.run());
419            headless::run(&addr, &shell, driver_timeout, browser_timeout)?;
420        }
421    }
422    Ok(())
423}
424
425#[derive(Debug, Copy, Clone, Eq, PartialEq)]
426enum TestMode {
427    Node { no_modules: bool },
428    Deno,
429    Browser { no_modules: bool },
430    DedicatedWorker { no_modules: bool },
431    SharedWorker { no_modules: bool },
432    ServiceWorker { no_modules: bool },
433}
434
435impl TestMode {
436    fn is_worker(self) -> bool {
437        matches!(
438            self,
439            Self::DedicatedWorker { .. } | Self::SharedWorker { .. } | Self::ServiceWorker { .. }
440        )
441    }
442
443    fn no_modules(self) -> bool {
444        match self {
445            Self::Deno => true,
446            Self::Browser { no_modules }
447            | Self::Node { no_modules }
448            | Self::DedicatedWorker { no_modules }
449            | Self::SharedWorker { no_modules }
450            | Self::ServiceWorker { no_modules } => no_modules,
451        }
452    }
453
454    fn env(self) -> &'static str {
455        match self {
456            TestMode::Node { .. } => "WASM_BINDGEN_USE_NODE_EXPERIMENTAL",
457            TestMode::Deno => "WASM_BINDGEN_USE_DENO",
458            TestMode::Browser { .. } => "WASM_BINDGEN_USE_BROWSER",
459            TestMode::DedicatedWorker { .. } => "WASM_BINDGEN_USE_DEDICATED_WORKER",
460            TestMode::SharedWorker { .. } => "WASM_BINDGEN_USE_SHARED_WORKER",
461            TestMode::ServiceWorker { .. } => "WASM_BINDGEN_USE_SERVICE_WORKER",
462        }
463    }
464}
465
466/// Possible values for the `--format` option.
467#[derive(Debug, Clone, Copy, ValueEnum)]
468enum FormatSetting {
469    /// Display one character per test
470    Terse,
471}