vanilla-test 2.1.0

Minimal, dependency-free testing for native Rust and browser WebAssembly
Documentation
#![forbid(unsafe_code)]

use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{self, Command, Stdio};

const TARGET: &str = "wasm32-unknown-unknown";
const RUNNER_HTML: &[u8] = include_bytes!("../../rust/browser/runner.html");
const BROWSER_JS: &[u8] = include_bytes!("../../rust/browser/browser.js");
const USAGE: &str = "cargo vanilla-test --browser [--out-dir PATH] [--manifest-path PATH] [--page PATH]\n\
\n\
Compile the selected package's existing library tests to browser WebAssembly.\n\
Use --page to replace the built-in runner with a compatible browser test page.\n\
Default output: dist/vanilla-test";

#[derive(Debug)]
struct Options {
    out_dir: PathBuf,
    manifest_path: PathBuf,
    page: Option<PathBuf>,
}

fn main() {
    if let Err(message) = run(env::args().skip(1)) {
        eprintln!("cargo vanilla-test: {message}\n\n{USAGE}");
        process::exit(2);
    }
}

fn run(arguments: impl Iterator<Item = String>) -> Result<(), String> {
    let mut arguments: Vec<_> = arguments.collect();
    if arguments
        .first()
        .is_some_and(|argument| argument == "vanilla-test")
    {
        arguments.remove(0);
    }
    if arguments
        .iter()
        .any(|argument| matches!(argument.as_str(), "--help" | "-h"))
    {
        println!("{USAGE}");
        return Ok(());
    }
    if arguments
        .iter()
        .any(|argument| matches!(argument.as_str(), "--version" | "-V"))
    {
        println!("{}", env!("CARGO_PKG_VERSION"));
        return Ok(());
    }

    let options = parse(arguments.into_iter())?;
    let project = options
        .manifest_path
        .parent()
        .ok_or("--manifest-path must name a Cargo.toml file")?;
    let page = options
        .page
        .as_deref()
        .map(|path| {
            let path = absolute(project, path);
            fs::read(&path).map_err(|error| format!("could not read {}: {error}", path.display()))
        })
        .transpose()?;
    let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
    let mut command = Command::new(cargo);
    command
        .current_dir(project)
        .args([
            "test",
            "--release",
            "--lib",
            "--target",
            TARGET,
            "--no-run",
            "--message-format=json-render-diagnostics",
        ])
        .arg("--manifest-path")
        .arg(&options.manifest_path)
        .stderr(Stdio::inherit());
    let output = command
        .output()
        .map_err(|error| format!("could not start Cargo: {error}"))?;
    if !output.status.success() {
        return Err(format!("Cargo exited with {}", output.status));
    }

    let artifact = wasm_artifact(&output.stdout)?;
    let out_dir = absolute(project, &options.out_dir);
    fs::create_dir_all(&out_dir)
        .map_err(|error| format!("could not create {}: {error}", out_dir.display()))?;
    copy(&artifact, &out_dir.join("vanilla-test-tests.wasm"))?;
    if let Some(page) = page {
        write(&out_dir.join("index.html"), &page)?;
    } else {
        write(&out_dir.join("index.html"), RUNNER_HTML)?;
    }
    write(&out_dir.join("browser.js"), BROWSER_JS)?;
    println!("Rust browser test ready at {}", out_dir.display());
    Ok(())
}

fn parse(mut arguments: impl Iterator<Item = String>) -> Result<Options, String> {
    let mut browser = false;
    let mut out_dir = PathBuf::from("dist/vanilla-test");
    let mut manifest_path = PathBuf::from("Cargo.toml");
    let mut page = None;
    while let Some(argument) = arguments.next() {
        match argument.as_str() {
            "--browser" => browser = true,
            "--out-dir" => out_dir = value(&mut arguments, "--out-dir")?.into(),
            "--manifest-path" => manifest_path = value(&mut arguments, "--manifest-path")?.into(),
            "--page" => page = Some(value(&mut arguments, "--page")?.into()),
            _ => return Err(format!("unknown option {argument}")),
        }
    }
    if !browser {
        return Err("--browser is required".into());
    }
    if !manifest_path.is_absolute() {
        manifest_path = env::current_dir()
            .map_err(|error| format!("could not read the current directory: {error}"))?
            .join(manifest_path);
    }
    Ok(Options {
        out_dir,
        manifest_path,
        page,
    })
}

fn value(arguments: &mut impl Iterator<Item = String>, option: &str) -> Result<String, String> {
    arguments
        .next()
        .filter(|value| !value.is_empty())
        .ok_or_else(|| format!("{option} requires a value"))
}

fn wasm_artifact(output: &[u8]) -> Result<PathBuf, String> {
    let output =
        std::str::from_utf8(output).map_err(|error| format!("invalid Cargo output: {error}"))?;
    output
        .lines()
        .filter(|line| line.contains("\"reason\":\"compiler-artifact\""))
        .filter_map(|line| json_executable(line).map(PathBuf::from))
        .find(|path| {
            path.extension()
                .is_some_and(|extension| extension == "wasm")
        })
        .ok_or_else(|| "Cargo did not report a WebAssembly test harness".into())
}

fn json_executable(line: &str) -> Option<String> {
    const FIELD: &str = "\"executable\":\"";
    let mut characters = line[line.find(FIELD)? + FIELD.len()..].chars();
    let mut value = String::new();
    while let Some(character) = characters.next() {
        match character {
            '"' => return Some(value),
            '\\' => match characters.next()? {
                '"' => value.push('"'),
                '\\' => value.push('\\'),
                '/' => value.push('/'),
                'b' => value.push('\u{0008}'),
                'f' => value.push('\u{000c}'),
                'n' => value.push('\n'),
                'r' => value.push('\r'),
                't' => value.push('\t'),
                _ => return None,
            },
            _ => value.push(character),
        }
    }
    None
}

fn absolute(project: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.into()
    } else {
        project.join(path)
    }
}

fn copy(source: &Path, destination: &Path) -> Result<(), String> {
    fs::copy(source, destination).map(|_| ()).map_err(|error| {
        format!(
            "could not copy {} to {}: {error}",
            source.display(),
            destination.display()
        )
    })
}

fn write(path: &Path, content: &[u8]) -> Result<(), String> {
    fs::write(path, content).map_err(|error| format!("could not write {}: {error}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn browser_defaults_need_no_project_configuration() {
        let options = parse(["--browser".into()].into_iter()).unwrap();
        assert_eq!(options.out_dir, PathBuf::from("dist/vanilla-test"));
        assert!(options.manifest_path.ends_with("Cargo.toml"));
        assert_eq!(options.page, None);

        let options = parse(
            ["--browser", "--page", "tests/browser.html"]
                .map(String::from)
                .into_iter(),
        )
        .unwrap();
        assert_eq!(options.page, Some(PathBuf::from("tests/browser.html")));
    }

    #[test]
    fn cargo_output_selects_the_exact_test_artifact() {
        let output = br#"{"reason":"compiler-artifact","executable":"C:\\work\\old.exe"}
{"reason":"compiler-artifact","executable":"C:\\work\\suite-a1b2.wasm"}"#;
        assert_eq!(
            wasm_artifact(output).unwrap(),
            PathBuf::from(r"C:\work\suite-a1b2.wasm")
        );
    }

    #[test]
    fn browser_flag_is_explicit() {
        assert_eq!(
            parse(std::iter::empty()).unwrap_err(),
            "--browser is required"
        );
    }
}