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