Skip to main content

supercov_engine/
rust_wasm_pack.rs

1//! `wasm-pack test`: what it hands Cargo, learned by running it.
2//!
3//! wasm-pack resolves the browser driver (downloading `chromedriver` or
4//! `geckodriver` when asked to), installs the matching
5//! `wasm-bindgen-test-runner`, and then runs `cargo test --target
6//! wasm32-unknown-unknown` with the runner and the driver in the environment.
7//! Supercov runs a project's tests itself, test by test, so it runs wasm-pack
8//! once with a `cargo` of its own first on `PATH`: `cargo metadata` passes
9//! through, the `cargo build --tests` that only warms a cache is skipped, and
10//! `cargo test` is recorded rather than run. The run then measures that
11//! `cargo test`, with the runner named on its command line and the driver's
12//! variables given to each test's runner.
13
14use std::{
15    fs,
16    path::{Path, PathBuf},
17    process::Command,
18};
19
20/// What `wasm-pack test` would have run.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct WasmPackTest {
23    /// `cargo test ...`, with the target runner given by `--config`.
24    pub command: Vec<String>,
25    /// The variables the runner reads: the driver, headless or not, which
26    /// environments to run in.
27    pub runner_environment: Vec<(String, String)>,
28}
29
30/// The variables of wasm-pack's `cargo test` that its runner reads.
31const RUNNER_VARIABLES: &[&str] = &[
32    "CHROMEDRIVER",
33    "CHROMEDRIVER_ARGS",
34    "GECKODRIVER",
35    "GECKODRIVER_ARGS",
36    "SAFARIDRIVER",
37    "SAFARIDRIVER_ARGS",
38    "MSEDGEDRIVER",
39    "MSEDGEDRIVER_ARGS",
40    "NO_HEADLESS",
41    "WASM_BINDGEN_TEST_ONLY_WEB",
42    "WASM_BINDGEN_TEST_ONLY_NODE",
43];
44
45/// `wasm-pack test ...`.
46pub fn is_wasm_pack_test(command: &[String]) -> bool {
47    command.first().is_some_and(|program| {
48        Path::new(program)
49            .file_stem()
50            .is_some_and(|name| name == "wasm-pack")
51    }) && command.get(1).is_some_and(|word| word == "test")
52}
53
54/// Run `command`, a `wasm-pack test`, in `root` with Supercov's `cargo`
55/// first on `PATH`, and read back the `cargo test` it would have run.
56pub fn learn(root: &Path, command: &[String], scratch: &Path) -> Result<WasmPackTest, String> {
57    if cfg!(windows) {
58        return Err("`wasm-pack test` is measured on Linux and macOS; on Windows, run the `cargo test --target wasm32-unknown-unknown` it runs".into());
59    }
60    let cargo = which("cargo").ok_or("no `cargo` on PATH for `wasm-pack test`")?;
61    let bin = scratch.join("wasm-pack-cargo");
62    fs::create_dir_all(&bin).map_err(|error| format!("{}: {error}", bin.display()))?;
63    let record = scratch.join("wasm-pack-test.txt");
64    let _ = fs::remove_file(&record);
65    write_cargo(&bin, &cargo, &record)?;
66    let mut path = vec![bin.clone()];
67    path.extend(std::env::split_paths(
68        &std::env::var_os("PATH").unwrap_or_default(),
69    ));
70    let output = Command::new(&command[0])
71        .args(&command[1..])
72        .current_dir(root)
73        .env(
74            "PATH",
75            std::env::join_paths(path).map_err(|error| error.to_string())?,
76        )
77        .output()
78        .map_err(|error| format!("could not run {}: {error}", command[0]))?;
79    let text = fs::read_to_string(&record).map_err(|_| {
80        let said = String::from_utf8_lossy(&output.stderr);
81        let tail = said.lines().rev().take(8).collect::<Vec<_>>();
82        format!(
83            "`wasm-pack test` stopped before it ran `cargo test`:\n{}",
84            tail.into_iter().rev().collect::<Vec<_>>().join("\n")
85        )
86    })?;
87    parse_record(root, &text)
88}
89
90/// The recorded call: the working directory, then each argument, then
91/// `NAME=value` for each runner variable, separated by a line `--env`.
92fn parse_record(root: &Path, text: &str) -> Result<WasmPackTest, String> {
93    let mut lines = text.lines();
94    let directory = PathBuf::from(lines.next().ok_or("an empty wasm-pack record")?);
95    let mut arguments = Vec::new();
96    let mut environment = Vec::new();
97    let mut in_environment = false;
98    for line in lines {
99        if line == "--env" {
100            in_environment = true;
101        } else if in_environment {
102            if let Some((name, value)) = line.split_once('=') {
103                environment.push((name.to_owned(), value.to_owned()));
104            }
105        } else {
106            arguments.push(line.to_owned());
107        }
108    }
109    let mut command = vec!["cargo".to_owned()];
110    command.extend(arguments);
111    // The runner wasm-pack chose, named where the run reads runners from.
112    let mut runner_environment = Vec::new();
113    for (name, value) in environment {
114        if let Some(target) = name
115            .strip_prefix("CARGO_TARGET_")
116            .and_then(|rest| rest.strip_suffix("_RUNNER"))
117        {
118            let triple = target.to_ascii_lowercase().replace('_', "-");
119            command.push("--config".into());
120            // A string, as most projects write a runner: given as an array it
121            // could not be merged with a `.cargo/config.toml` string one.
122            command.push(format!(
123                "target.{triple}.runner={}",
124                serde_json::to_string(&value).map_err(|error| error.to_string())?
125            ));
126        } else if RUNNER_VARIABLES.contains(&name.as_str()) {
127            runner_environment.push((name, value));
128        }
129    }
130    // A crate below the project root is the one wasm-pack ran in.
131    let canonical_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_owned());
132    let canonical_directory = fs::canonicalize(&directory).unwrap_or(directory);
133    if canonical_directory != canonical_root
134        && let Ok(relative) = canonical_directory.strip_prefix(&canonical_root)
135    {
136        command.push("--manifest-path".into());
137        command.push(relative.join("Cargo.toml").to_string_lossy().into_owned());
138    }
139    Ok(WasmPackTest {
140        command,
141        runner_environment,
142    })
143}
144
145fn write_cargo(bin: &Path, cargo: &Path, record: &Path) -> Result<(), String> {
146    #[cfg(unix)]
147    {
148        use std::os::unix::fs::PermissionsExt;
149        let quote = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"));
150        let variables = RUNNER_VARIABLES.join("|");
151        let script = format!(
152            r#"#!/bin/sh
153# Written by Supercov: records the `cargo test` wasm-pack runs, instead of
154# running it, and skips the `cargo build --tests` that only warms a cache.
155case "$1" in
156  test)
157    {{
158      pwd
159      for argument in "$@"; do printf '%s\n' "$argument"; done
160      echo --env
161      env | grep -E '^(CARGO_TARGET_[A-Z0-9_]+_RUNNER|{variables})='
162    }} > {record}
163    exit 0
164    ;;
165  build) exit 0 ;;
166esac
167exec {cargo} "$@"
168"#,
169            record = quote(record),
170            cargo = quote(cargo),
171        );
172        let path = bin.join("cargo");
173        fs::write(&path, script).map_err(|error| format!("{}: {error}", path.display()))?;
174        fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
175            .map_err(|error| format!("{}: {error}", path.display()))?;
176    }
177    #[cfg(not(unix))]
178    {
179        let _ = (bin, cargo, record);
180    }
181    Ok(())
182}
183
184fn which(name: &str) -> Option<PathBuf> {
185    std::env::split_paths(&std::env::var_os("PATH")?)
186        .map(|directory| directory.join(name))
187        .find(|candidate| fs::metadata(candidate).is_ok_and(|metadata| metadata.is_file()))
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn only_wasm_pack_test_is_learned() {
196        let words = |text: &str| text.split(' ').map(str::to_owned).collect::<Vec<_>>();
197        assert!(is_wasm_pack_test(&words(
198            "wasm-pack test --headless --chrome"
199        )));
200        assert!(is_wasm_pack_test(&words("/usr/bin/wasm-pack test --node")));
201        assert!(!is_wasm_pack_test(&words("wasm-pack build")));
202        assert!(!is_wasm_pack_test(&words("cargo test")));
203    }
204
205    /// The runner becomes a `--config` on the command, the driver's
206    /// variables go to the runner, and a crate below the root is named.
207    #[test]
208    fn a_record_becomes_the_cargo_test_it_stands_for() {
209        let root = std::env::temp_dir().join(format!("supercov-wasm-pack-{}", std::process::id()));
210        fs::create_dir_all(root.join("crates/web")).unwrap();
211        let record = format!(
212            "{}\ntest\n--target\nwasm32-unknown-unknown\n--env\nCARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=/c/wasm-bindgen-test-runner\nCHROMEDRIVER=/c/chromedriver\nWASM_BINDGEN_TEST_ONLY_WEB=1\n",
213            root.join("crates/web").display()
214        );
215        let learned = parse_record(&root, &record).unwrap();
216        // The manifest path in the platform's own separators.
217        let manifest = Path::new("crates").join("web").join("Cargo.toml");
218        assert_eq!(
219            learned.command,
220            [
221                "cargo",
222                "test",
223                "--target",
224                "wasm32-unknown-unknown",
225                "--config",
226                "target.wasm32-unknown-unknown.runner=\"/c/wasm-bindgen-test-runner\"",
227                "--manifest-path",
228                &manifest.to_string_lossy(),
229            ]
230        );
231        assert_eq!(
232            learned.runner_environment,
233            [
234                ("CHROMEDRIVER".to_owned(), "/c/chromedriver".to_owned()),
235                ("WASM_BINDGEN_TEST_ONLY_WEB".to_owned(), "1".to_owned()),
236            ]
237        );
238        fs::remove_dir_all(&root).unwrap();
239    }
240}