use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WasmPackTest {
pub command: Vec<String>,
pub runner_environment: Vec<(String, String)>,
}
const RUNNER_VARIABLES: &[&str] = &[
"CHROMEDRIVER",
"CHROMEDRIVER_ARGS",
"GECKODRIVER",
"GECKODRIVER_ARGS",
"SAFARIDRIVER",
"SAFARIDRIVER_ARGS",
"MSEDGEDRIVER",
"MSEDGEDRIVER_ARGS",
"NO_HEADLESS",
"WASM_BINDGEN_TEST_ONLY_WEB",
"WASM_BINDGEN_TEST_ONLY_NODE",
];
pub fn is_wasm_pack_test(command: &[String]) -> bool {
command.first().is_some_and(|program| {
Path::new(program)
.file_stem()
.is_some_and(|name| name == "wasm-pack")
}) && command.get(1).is_some_and(|word| word == "test")
}
pub fn learn(root: &Path, command: &[String], scratch: &Path) -> Result<WasmPackTest, String> {
if cfg!(windows) {
return Err("`wasm-pack test` is measured on Linux and macOS; on Windows, run the `cargo test --target wasm32-unknown-unknown` it runs".into());
}
let cargo = which("cargo").ok_or("no `cargo` on PATH for `wasm-pack test`")?;
let bin = scratch.join("wasm-pack-cargo");
fs::create_dir_all(&bin).map_err(|error| format!("{}: {error}", bin.display()))?;
let record = scratch.join("wasm-pack-test.txt");
let _ = fs::remove_file(&record);
write_cargo(&bin, &cargo, &record)?;
let mut path = vec![bin.clone()];
path.extend(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
));
let output = Command::new(&command[0])
.args(&command[1..])
.current_dir(root)
.env(
"PATH",
std::env::join_paths(path).map_err(|error| error.to_string())?,
)
.output()
.map_err(|error| format!("could not run {}: {error}", command[0]))?;
let text = fs::read_to_string(&record).map_err(|_| {
let said = String::from_utf8_lossy(&output.stderr);
let tail = said.lines().rev().take(8).collect::<Vec<_>>();
format!(
"`wasm-pack test` stopped before it ran `cargo test`:\n{}",
tail.into_iter().rev().collect::<Vec<_>>().join("\n")
)
})?;
parse_record(root, &text)
}
fn parse_record(root: &Path, text: &str) -> Result<WasmPackTest, String> {
let mut lines = text.lines();
let directory = PathBuf::from(lines.next().ok_or("an empty wasm-pack record")?);
let mut arguments = Vec::new();
let mut environment = Vec::new();
let mut in_environment = false;
for line in lines {
if line == "--env" {
in_environment = true;
} else if in_environment {
if let Some((name, value)) = line.split_once('=') {
environment.push((name.to_owned(), value.to_owned()));
}
} else {
arguments.push(line.to_owned());
}
}
let mut command = vec!["cargo".to_owned()];
command.extend(arguments);
let mut runner_environment = Vec::new();
for (name, value) in environment {
if let Some(target) = name
.strip_prefix("CARGO_TARGET_")
.and_then(|rest| rest.strip_suffix("_RUNNER"))
{
let triple = target.to_ascii_lowercase().replace('_', "-");
command.push("--config".into());
command.push(format!(
"target.{triple}.runner={}",
serde_json::to_string(&value).map_err(|error| error.to_string())?
));
} else if RUNNER_VARIABLES.contains(&name.as_str()) {
runner_environment.push((name, value));
}
}
let canonical_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_owned());
let canonical_directory = fs::canonicalize(&directory).unwrap_or(directory);
if canonical_directory != canonical_root
&& let Ok(relative) = canonical_directory.strip_prefix(&canonical_root)
{
command.push("--manifest-path".into());
command.push(relative.join("Cargo.toml").to_string_lossy().into_owned());
}
Ok(WasmPackTest {
command,
runner_environment,
})
}
fn write_cargo(bin: &Path, cargo: &Path, record: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let quote = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"));
let variables = RUNNER_VARIABLES.join("|");
let script = format!(
r#"#!/bin/sh
# Written by Supercov: records the `cargo test` wasm-pack runs, instead of
# running it, and skips the `cargo build --tests` that only warms a cache.
case "$1" in
test)
{{
pwd
for argument in "$@"; do printf '%s\n' "$argument"; done
echo --env
env | grep -E '^(CARGO_TARGET_[A-Z0-9_]+_RUNNER|{variables})='
}} > {record}
exit 0
;;
build) exit 0 ;;
esac
exec {cargo} "$@"
"#,
record = quote(record),
cargo = quote(cargo),
);
let path = bin.join("cargo");
fs::write(&path, script).map_err(|error| format!("{}: {error}", path.display()))?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
.map_err(|error| format!("{}: {error}", path.display()))?;
}
#[cfg(not(unix))]
{
let _ = (bin, cargo, record);
}
Ok(())
}
fn which(name: &str) -> Option<PathBuf> {
std::env::split_paths(&std::env::var_os("PATH")?)
.map(|directory| directory.join(name))
.find(|candidate| fs::metadata(candidate).is_ok_and(|metadata| metadata.is_file()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_wasm_pack_test_is_learned() {
let words = |text: &str| text.split(' ').map(str::to_owned).collect::<Vec<_>>();
assert!(is_wasm_pack_test(&words(
"wasm-pack test --headless --chrome"
)));
assert!(is_wasm_pack_test(&words("/usr/bin/wasm-pack test --node")));
assert!(!is_wasm_pack_test(&words("wasm-pack build")));
assert!(!is_wasm_pack_test(&words("cargo test")));
}
#[test]
fn a_record_becomes_the_cargo_test_it_stands_for() {
let root = std::env::temp_dir().join(format!("supercov-wasm-pack-{}", std::process::id()));
fs::create_dir_all(root.join("crates/web")).unwrap();
let record = format!(
"{}\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",
root.join("crates/web").display()
);
let learned = parse_record(&root, &record).unwrap();
let manifest = Path::new("crates").join("web").join("Cargo.toml");
assert_eq!(
learned.command,
[
"cargo",
"test",
"--target",
"wasm32-unknown-unknown",
"--config",
"target.wasm32-unknown-unknown.runner=\"/c/wasm-bindgen-test-runner\"",
"--manifest-path",
&manifest.to_string_lossy(),
]
);
assert_eq!(
learned.runner_environment,
[
("CHROMEDRIVER".to_owned(), "/c/chromedriver".to_owned()),
("WASM_BINDGEN_TEST_ONLY_WEB".to_owned(), "1".to_owned()),
]
);
fs::remove_dir_all(&root).unwrap();
}
}