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::Path;
21use std::path::PathBuf;
22use std::thread;
23use wasm_bindgen_cli_support::Bindgen;
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, conflicts_with = "ignored", help = "Run ignored tests")]
40    include_ignored: bool,
41    #[arg(long, conflicts_with = "include_ignored", help = "Run ignored tests")]
42    ignored: bool,
43    #[arg(long, help = "Exactly match filters rather than by substring")]
44    exact: bool,
45    #[arg(
46        long,
47        value_name = "FILTER",
48        help = "Skip tests whose names contain FILTER (this flag can be used multiple times)"
49    )]
50    skip: Vec<String>,
51    #[arg(long, help = "List all tests and benchmarks")]
52    list: bool,
53    #[arg(
54        long,
55        help = "don't capture `console.*()` of each task, allow printing directly"
56    )]
57    nocapture: bool,
58    #[arg(
59        long,
60        value_enum,
61        value_name = "terse",
62        help = "Configure formatting of output"
63    )]
64    format: Option<FormatSetting>,
65    #[arg(
66        index = 2,
67        value_name = "FILTER",
68        help = "The FILTER string is tested against the name of all tests, and only those tests \
69                whose names contain the filter are run."
70    )]
71    filter: Option<String>,
72}
73
74impl Cli {
75    fn into_args(self, tests: &Tests) -> String {
76        let include_ignored = self.include_ignored;
77        let filtered = tests.filtered;
78
79        format!(
80            r#"
81            // Forward runtime arguments.
82            cx.include_ignored({include_ignored:?});
83            cx.filtered_count({filtered});
84        "#
85        )
86    }
87}
88
89struct Tests {
90    tests: Vec<Test>,
91    filtered: usize,
92}
93
94impl Tests {
95    fn new() -> Self {
96        Self {
97            tests: Vec::new(),
98            filtered: 0,
99        }
100    }
101}
102
103struct Test {
104    // test name
105    name: String,
106    // symbol name
107    export: String,
108    ignored: bool,
109}
110
111pub fn run_cli_with_args<I, T>(args: I) -> anyhow::Result<()>
112where
113    I: IntoIterator<Item = T>,
114    T: Into<OsString> + Clone,
115{
116    let cli = match Cli::try_parse_from(args) {
117        Ok(a) => a,
118        Err(e) => match e.kind() {
119            // Passing --version and --help should not result in a failure.
120            clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
121                print!("{e}");
122                return Ok(());
123            }
124            _ => bail!(e),
125        },
126    };
127    rmain(cli)
128}
129
130fn rmain(cli: Cli) -> anyhow::Result<()> {
131    let shell = shell::Shell::new();
132
133    let file_name = cli
134        .file
135        .file_name()
136        .map(Path::new)
137        .context("file to test is not a valid file, can't extract file name")?;
138
139    // Collect all tests that the test harness is supposed to run. We assume
140    // that any exported function with the prefix `__wbg_test` is a test we need
141    // to execute.
142    let wasm = fs::read(&cli.file).context("failed to read Wasm file")?;
143    let mut wasm = walrus::ModuleConfig::new()
144        // generate dwarf by default, it can be controlled by debug profile
145        //
146        // https://doc.rust-lang.org/cargo/reference/profiles.html#debug
147        .generate_dwarf(true)
148        .parse(&wasm)
149        .context("failed to deserialize Wasm module")?;
150    let mut tests = Tests::new();
151
152    'outer: for export in wasm.exports.iter() {
153        let Some(name) = export.name.strip_prefix("__wbgt_") else {
154            continue;
155        };
156        let modifiers = name.split_once('_').expect("found invalid identifier").0;
157
158        let Some(name) = export.name.split_once("::").map(|s| s.1) else {
159            continue;
160        };
161
162        let test = Test {
163            name: name.into(),
164            export: export.name.clone(),
165            ignored: modifiers.contains('$'),
166        };
167
168        if let Some(filter) = &cli.filter {
169            let matches = if cli.exact {
170                name == *filter
171            } else {
172                name.contains(filter)
173            };
174
175            if !matches {
176                tests.filtered += 1;
177                continue;
178            }
179        }
180
181        for skip in &cli.skip {
182            let matches = if cli.exact {
183                name == *skip
184            } else {
185                name.contains(skip)
186            };
187
188            if matches {
189                tests.filtered += 1;
190                continue 'outer;
191            }
192        }
193
194        if !test.ignored && cli.ignored {
195            tests.filtered += 1;
196        } else {
197            tests.tests.push(test);
198        }
199    }
200
201    if cli.list {
202        for test in tests.tests {
203            println!("{}: test", test.name);
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    // Make the generated bindings available for the tests to execute against.
323    shell.status("Executing bindgen...");
324    let mut b = Bindgen::new();
325    match test_mode {
326        TestMode::Node { no_modules: true } => b.nodejs(true)?,
327        TestMode::Node { no_modules: false } => b.nodejs_module(true)?,
328        TestMode::Deno => b.deno(true)?,
329        TestMode::Browser { .. }
330        | TestMode::DedicatedWorker { .. }
331        | TestMode::SharedWorker { .. }
332        | TestMode::ServiceWorker { .. } => {
333            if test_mode.no_modules() {
334                b.no_modules(true)?
335            } else {
336                b.web(true)?
337            }
338        }
339    };
340
341    if std::env::var("WASM_BINDGEN_SPLIT_LINKED_MODULES").is_ok() {
342        b.split_linked_modules(true);
343    }
344    if std::env::var("WASM_BINDGEN_KEEP_LLD_EXPORTS").is_ok() {
345        b.keep_lld_exports(true);
346    }
347
348    let coverage = coverage_args(file_name);
349
350    // The debug here means adding some assertions and some error messages to the generated js
351    // code.
352    //
353    // It has nothing to do with Rust.
354    b.debug(debug)
355        .input_module(module, wasm)
356        .emit_start(false)
357        .generate(&tmpdir_path)
358        .context("executing `wasm-bindgen` over the Wasm file")?;
359    shell.clear();
360
361    match test_mode {
362        TestMode::Node { no_modules } => {
363            node::execute(module, &tmpdir_path, cli, tests, !no_modules, coverage)?
364        }
365        TestMode::Deno => deno::execute(module, &tmpdir_path, cli, tests)?,
366        TestMode::Browser { .. }
367        | TestMode::DedicatedWorker { .. }
368        | TestMode::SharedWorker { .. }
369        | TestMode::ServiceWorker { .. } => {
370            let srv = server::spawn(
371                &if headless {
372                    "127.0.0.1:0".parse().unwrap()
373                } else if let Ok(address) = std::env::var("WASM_BINDGEN_TEST_ADDRESS") {
374                    address.parse().unwrap()
375                } else {
376                    "127.0.0.1:8000".parse().unwrap()
377                },
378                headless,
379                module,
380                &tmpdir_path,
381                cli,
382                tests,
383                test_mode,
384                std::env::var("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION").is_err(),
385                coverage,
386            )
387            .context("failed to spawn server")?;
388            let addr = srv.server_addr();
389
390            // TODO: eventually we should provide the ability to exit at some point
391            // (gracefully) here, but for now this just runs forever.
392            if !headless {
393                println!("Interactive browsers tests are now available at http://{addr}");
394                println!();
395                println!("Note that interactive mode is enabled because `NO_HEADLESS`");
396                println!("is specified in the environment of this process. Once you're");
397                println!("done with testing you'll need to kill this server with");
398                println!("Ctrl-C.");
399                srv.run();
400                return Ok(());
401            }
402
403            thread::spawn(|| srv.run());
404            headless::run(&addr, &shell, driver_timeout, browser_timeout)?;
405        }
406    }
407    Ok(())
408}
409
410#[derive(Debug, Copy, Clone, Eq, PartialEq)]
411enum TestMode {
412    Node { no_modules: bool },
413    Deno,
414    Browser { no_modules: bool },
415    DedicatedWorker { no_modules: bool },
416    SharedWorker { no_modules: bool },
417    ServiceWorker { no_modules: bool },
418}
419
420impl TestMode {
421    fn is_worker(self) -> bool {
422        matches!(
423            self,
424            Self::DedicatedWorker { .. } | Self::SharedWorker { .. } | Self::ServiceWorker { .. }
425        )
426    }
427
428    fn no_modules(self) -> bool {
429        match self {
430            Self::Deno => true,
431            Self::Browser { no_modules }
432            | Self::Node { no_modules }
433            | Self::DedicatedWorker { no_modules }
434            | Self::SharedWorker { no_modules }
435            | Self::ServiceWorker { no_modules } => no_modules,
436        }
437    }
438
439    fn env(self) -> &'static str {
440        match self {
441            TestMode::Node { .. } => "WASM_BINDGEN_USE_NODE_EXPERIMENTAL",
442            TestMode::Deno => "WASM_BINDGEN_USE_DENO",
443            TestMode::Browser { .. } => "WASM_BINDGEN_USE_BROWSER",
444            TestMode::DedicatedWorker { .. } => "WASM_BINDGEN_USE_DEDICATED_WORKER",
445            TestMode::SharedWorker { .. } => "WASM_BINDGEN_USE_SHARED_WORKER",
446            TestMode::ServiceWorker { .. } => "WASM_BINDGEN_USE_SERVICE_WORKER",
447        }
448    }
449}
450
451fn coverage_args(file_name: &Path) -> PathBuf {
452    fn generated(file_name: &Path, prefix: &str) -> String {
453        let res = format!("{prefix}{}.profraw", file_name.display());
454        res
455    }
456
457    let prefix = env::var_os("WASM_BINDGEN_UNSTABLE_TEST_PROFRAW_PREFIX")
458        .map(|s| s.to_str().unwrap().to_string())
459        .unwrap_or_default();
460
461    match env::var_os("WASM_BINDGEN_UNSTABLE_TEST_PROFRAW_OUT") {
462        Some(s) => {
463            let mut buf = PathBuf::from(s);
464            if buf.is_dir() {
465                buf.push(generated(file_name, &prefix));
466            }
467            buf
468        }
469        None => PathBuf::from(generated(file_name, &prefix)),
470    }
471}
472
473/// Possible values for the `--format` option.
474#[derive(Debug, Clone, Copy, ValueEnum)]
475enum FormatSetting {
476    /// Display one character per test
477    Terse,
478}