Skip to main content

xtask/
test_wasm.rs

1//! `cargo xtask test-wasm`: run the GPU tests in a real browser.
2//!
3//! The tests themselves are the same files `cargo nextest` runs natively — see
4//! `unlit_wgpu_test_util`'s registry — but built for `wasm32-unknown-unknown`
5//! and driven from a page. The pieces are:
6//!
7//! 1. The wasm test binaries, built by `cargo nextest list` so that the same
8//!    target list, features and names are used as natively.
9//! 2. `wasm-bindgen`, which turns each into a JS module exporting `run_test`.
10//! 3. The test page, which loads one module and calls that export.
11//! 4. A Playwright runner, which opens the page once per test.
12//! 5. The *same* native test binaries, with `UNLIT3D_WASM_TEST` set, which turns
13//!    them into a proxy: `cargo nextest` drives them, and each trial asks the
14//!    runner to run that test in the browser. Nextest's listing, filtering and
15//!    exit code then all work as they do for an ordinary test suite.
16//!
17//! Steps 1-4 set up the browser; step 5 is the part nextest actually runs.
18
19use std::net::TcpStream;
20use std::path::{Path, PathBuf};
21use std::process::{Child, Command, Stdio};
22use std::time::{Duration, Instant};
23
24use crate::{TestWasmArgs, step};
25
26/// The packages whose tests can run in a browser.
27///
28/// Named rather than taken from the workspace, because these are the ones whose
29/// test targets are wasm-test binaries. The example's own snapshot tests are
30/// among them: its scenes run in the browser the same way the crates' tests do.
31const PACKAGES: &[&str] = &[
32    "unlit_wgpu",
33    "unlit3d",
34    "unlit3d_examples",
35    "unlit3dx_examples",
36];
37
38/// The target the tests are built for.
39const TARGET: &str = "wasm32-unknown-unknown";
40
41/// Where the page, the built modules and `wasm_paths.json` are assembled.
42const DIST: &str = "tests/wasm/dist";
43
44/// Where the built examples are assembled for the runner to serve.
45///
46/// The runner serves them from its own tree rather than reaching into
47/// `target/`, so one directory holds everything a page needs and the runner
48/// has no reason to know how the build laid it out.
49const EXAMPLE_DIST: &str = "tests/wasm/examples";
50
51/// The page the runner serves, copied into [`DIST`] as-is.
52const WEB: &str = "tests/wasm/web";
53
54/// The Playwright runner, which serves [`DIST`] and drives a browser.
55const RUNNER: &str = "tests/wasm/runner";
56
57/// The environment variable that collects mismatched snapshot frames.
58///
59/// Defined by `unlit_wgpu_test_util` for the host pass, and read by the Node
60/// runner for the browser one — which is the process that can write, since the
61/// page has no filesystem. Repeated here as a literal because this is a
62/// separate crate that does not depend on the harness.
63const MISMATCH_DIR_ENV: &str = "UNLIT3D_SNAPSHOT_MISMATCH_DIR";
64
65/// Where this pass collects the frames that failed their comparison.
66///
67/// A directory of its own, so a browser run's frames are not mixed in with the
68/// host run's: the two can legitimately differ, and telling them apart is the
69/// first thing a reader wants to do.
70const MISMATCH_DIR: &str = "target/snapshot-mismatches-wasm";
71
72/// The environment variable that puts a test binary in proxy mode.
73///
74/// Defined by `unlit_wgpu_test_util`, and repeated here as a literal because
75/// this is a separate crate that does not depend on it — the same reason
76/// `test.rs` repeats the device-tier variable.
77const WASM_TEST_ENV: &str = "UNLIT3D_WASM_TEST";
78
79/// The port the runner listens on; also fixed in the harness's proxy.
80const PORT: u16 = 3000;
81
82/// The runner's own address.
83const BASE_URL: &str = "http://127.0.0.1:3000";
84
85/// How long the runner is given to start listening.
86const STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
87
88/// How long the runner is given to close its browser and exit.
89const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
90
91/// Run the GPU tests in a browser.
92///
93/// Each phase is announced before it starts. Several of them compile for
94/// minutes, and without a banner the terminal would sit silent for that whole
95/// time, which reads as a hang rather than as work.
96pub fn run(args: &TestWasmArgs) -> Result<(), String> {
97    let started = Instant::now();
98
99    println!("==> installing the runner's dependencies");
100    install_runner()?;
101
102    println!("==> checking wasm-bindgen");
103    check_wasm_bindgen()?;
104
105    println!("==> building the workspace for wasm");
106    build_workspace()?;
107
108    println!("==> building the examples");
109    copy_examples()?;
110
111    println!("==> building the wasm test binaries and binding them");
112    let modules = build_wasm_tests()?;
113
114    println!("==> assembling the test page ({} modules)", modules.len());
115    write_page(&modules)?;
116
117    println!("==> starting the browser runner");
118    let mut runner = Runner::start(args.show)?;
119    runner.wait_until_listening()?;
120
121    println!("==> describing the device the browser draws with");
122    print_browser_gpu(&modules)?;
123
124    println!("==> running the examples in the browser");
125    check_examples()?;
126
127    println!("==> running the tests in the browser");
128    let result = run_through_nextest();
129    runner.stop();
130
131    match &result {
132        Ok(()) => println!("==> all wasm tests passed in {:?}", started.elapsed()),
133        Err(_) => eprintln!("==> wasm tests failed after {:?}", started.elapsed()),
134    }
135    result
136}
137
138/// Install the runner's JS dependencies, and a browser to drive.
139///
140/// Reuses a browser when `CHROME_PATH` names one, which is how a developer who
141/// already has Chrome avoids a second copy.
142fn install_runner() -> Result<(), String> {
143    let mut install = Command::new(pnpm());
144    install.arg("install").current_dir(RUNNER);
145    step::run(
146        &mut install,
147        "installing the wasm test runner's dependencies",
148    )?;
149
150    if std::env::var_os("CHROME_PATH").is_some() {
151        println!("using the browser named by CHROME_PATH; not installing one");
152        return Ok(());
153    }
154
155    let mut playwright = Command::new(pnpm());
156    playwright
157        .args(["exec", "playwright", "install", "chromium"])
158        .current_dir(RUNNER);
159    step::run(&mut playwright, "installing Chromium for Playwright")
160}
161
162/// Reject a `wasm-bindgen` whose version differs from the library's.
163///
164/// The CLI and the `wasm-bindgen` crate must match, and a mismatch produces a
165/// version error from deep inside the generated JS that says nothing about the
166/// cause, so it is caught here with the command that fixes it.
167fn check_wasm_bindgen() -> Result<(), String> {
168    let expected = resolved_wasm_bindgen()?;
169    let output = Command::new("wasm-bindgen")
170        .arg("--version")
171        .output()
172        .map_err(|error| {
173            format!(
174                "running `wasm-bindgen --version`: {error}\n\
175                 install it with `cargo install wasm-bindgen-cli --version {expected}`"
176            )
177        })?;
178    let actual = String::from_utf8_lossy(&output.stdout);
179    if !actual.contains(expected.as_str()) {
180        return Err(format!(
181            "the `wasm-bindgen` CLI does not match the `wasm-bindgen` the workspace \
182             builds against.\n\
183             the CLI is {}, but the workspace resolves {expected}.\n\
184             fix it with `cargo install wasm-bindgen-cli --version {expected}`",
185            actual.trim()
186        ));
187    }
188    Ok(())
189}
190
191/// The `wasm-bindgen` version the workspace resolves, from `cargo metadata`.
192///
193/// Asked of cargo rather than read out of `Cargo.lock`, which is not tracked
194/// here: a fresh checkout has none until something builds, and a developer
195/// whose lockfile is stale would be told to install the wrong CLI.
196fn resolved_wasm_bindgen() -> Result<String, String> {
197    let mut metadata = Command::new("cargo");
198    metadata.args(["metadata", "--format-version", "1", "--all-features"]);
199    let output = metadata
200        .output()
201        .map_err(|error| format!("running `cargo metadata`: {error}"))?;
202    if !output.status.success() {
203        return Err(format!(
204            "`cargo metadata` failed:\n{}",
205            String::from_utf8_lossy(&output.stderr)
206        ));
207    }
208
209    let value: serde_json::Value = serde_json::from_slice(&output.stdout)
210        .map_err(|error| format!("parsing `cargo metadata`'s output: {error}"))?;
211    value
212        .get("packages")
213        .and_then(serde_json::Value::as_array)
214        .and_then(|packages| {
215            packages.iter().find(|package| {
216                package.get("name").and_then(serde_json::Value::as_str) == Some("wasm-bindgen")
217            })
218        })
219        .and_then(|package| package.get("version"))
220        .and_then(serde_json::Value::as_str)
221        .map(str::to_owned)
222        .ok_or_else(|| "`cargo metadata` lists no `wasm-bindgen` package".to_owned())
223}
224
225/// Build every wasm target in the workspace, not just the test binaries.
226///
227/// The test binaries above are built with `--all-features`, which the example
228/// cannot take on this target: its `snapshot` feature reaches the harness's
229/// readback and WebP scoring, which are native-only. So the workspace is built
230/// here under the default features, which is what the browser example and the
231/// Android library actually ship — and is what proves a change has not broken
232/// them for wasm at all.
233fn build_workspace() -> Result<(), String> {
234    // Not `--lib`: the `*_android` crates are `cdylib`s built for one platform
235    // alone, and a browser loads the binary. Naming the kinds that never
236    // produce a shared library leaves them out.
237    //
238    // `xtask` is excluded because it is a host-only task runner: its HTTP server
239    // reaches `async-io` and so `errno`, which does not build for this target.
240    // Nothing in the browser or the APK depends on it.
241    //
242    // The MCP server speaks the Model Context Protocol over a native stdio
243    // pipe, and rmcp's server side pulls `uuid` v4, whose wasm randomness
244    // source nothing asks for. Its module is switched off on this target, so a
245    // browser drives the world through the example's own UI, not the protocol.
246    //
247    // `unlit3dx_examples` runs its worlds inline on this target: the library
248    // spawns them through its stepped runner, so the host carries the same
249    // entities a threaded run would and the browser needs no thread support.
250    // Its GPU request runs on the browser's task queue, and its `--mcp`
251    // dependency is target-gated away.
252    let mut build = Command::new("cargo");
253    build
254        .args(["build", "--workspace", "--exclude", "xtask"])
255        .args(["--target", TARGET])
256        .args(["--profile", crate::run_wasm::PROFILE])
257        .args(["--bins", "--tests", "--benches", "--examples"]);
258    step::run(&mut build, "building the workspace for wasm")
259}
260
261/// Build every wasm test binary, bindgen it, and return the module names.
262fn build_wasm_tests() -> Result<Vec<String>, String> {
263    // `cargo nextest list` is what knows which binaries the test targets are,
264    // and it builds them as a side effect. Asking cargo directly would mean
265    // repeating the target list and features, which is how the two drift.
266    //
267    // This is the slow step — every test binary for the target, from scratch on
268    // a cold cache. Its progress goes to stderr, so stderr is inherited rather
269    // than captured: capturing it would leave the terminal blank for minutes,
270    // and the failure path below would report it only after the fact. stdout is
271    // captured because that is where the JSON goes.
272    let mut list = Command::new("cargo");
273    list.args(["nextest", "list"])
274        .args(PACKAGES.iter().flat_map(|package| ["-p", package]))
275        .args(["--tests", "--all-features"])
276        .args(["--list-type", "binaries-only", "--target", TARGET])
277        .args(["--cargo-profile", crate::run_wasm::PROFILE])
278        .args(["--message-format=json", "-v"])
279        .stdout(Stdio::piped())
280        .stderr(Stdio::inherit());
281    let output = list
282        .output()
283        .map_err(|error| format!("listing the wasm test binaries: {error}"))?;
284    if !output.status.success() {
285        return Err(format!(
286            "listing the wasm test binaries failed: {}",
287            output.status
288        ));
289    }
290
291    let binaries = parse_binaries(&String::from_utf8_lossy(&output.stdout))?;
292    if binaries.is_empty() {
293        return Err("no wasm test binaries were built".to_owned());
294    }
295
296    let dist = Path::new(DIST);
297    std::fs::create_dir_all(dist).map_err(|error| format!("creating {DIST}: {error}"))?;
298
299    let mut modules = Vec::new();
300    for (module, path) in binaries {
301        let mut bindgen = Command::new("wasm-bindgen");
302        bindgen
303            .arg(&path)
304            .args(["--target", "web", "--no-typescript"])
305            .arg("--out-dir")
306            .arg(dist)
307            .arg("--out-name")
308            .arg(&module);
309        step::run(&mut bindgen, &format!("wasm-bindgen for {module}"))?;
310        modules.push(module);
311    }
312    modules.sort();
313    Ok(modules)
314}
315
316/// The module name and wasm path of each test binary, from nextest's JSON.
317///
318/// The name is the target's, because that is what the harness's proxy sends and
319/// what the page uses to pick a module: `env!("CARGO_CRATE_NAME")` in the test
320/// target's own `gpu_test_main!`.
321fn parse_binaries(json: &str) -> Result<Vec<(String, PathBuf)>, String> {
322    let value: serde_json::Value = serde_json::from_str(json)
323        .map_err(|error| format!("parsing cargo nextest's output: {error}"))?;
324    let binaries = value
325        .get("rust-binaries")
326        .and_then(serde_json::Value::as_object)
327        .ok_or("cargo nextest's output has no `rust-binaries`")?;
328
329    let mut found = Vec::new();
330    for binary in binaries.values() {
331        // Only test targets export `run_test`; a library has no page to call.
332        let is_test = binary.get("kind").and_then(serde_json::Value::as_str) == Some("test");
333        let Some(name) = binary
334            .get("binary-name")
335            .and_then(serde_json::Value::as_str)
336        else {
337            continue;
338        };
339        let Some(path) = binary
340            .get("binary-path")
341            .and_then(serde_json::Value::as_str)
342        else {
343            continue;
344        };
345        if is_test && path.ends_with(".wasm") {
346            found.push((name.to_owned(), PathBuf::from(path)));
347        }
348    }
349    if found.is_empty() {
350        return Err("cargo nextest listed no wasm test binaries".to_owned());
351    }
352    Ok(found)
353}
354
355/// Copy the page into the served directory and write the module map.
356fn write_page(modules: &[String]) -> Result<(), String> {
357    let dist = Path::new(DIST);
358    let web = Path::new(WEB);
359    for entry in std::fs::read_dir(web).map_err(|error| format!("reading {WEB}: {error}"))? {
360        let entry = entry.map_err(|error| format!("reading {WEB}: {error}"))?;
361        let target = dist.join(entry.file_name());
362        std::fs::copy(entry.path(), &target)
363            .map_err(|error| format!("copying to {}: {error}", target.display()))?;
364    }
365
366    // The page fetches this to turn the module name from the query string into
367    // a script path, so it never builds a URL out of untrusted input.
368    let paths: serde_json::Map<String, serde_json::Value> = modules
369        .iter()
370        .map(|module| {
371            (
372                module.clone(),
373                serde_json::Value::String(format!("./{module}.js")),
374            )
375        })
376        .collect();
377    let path = dist.join("wasm_paths.json");
378    let json = serde_json::to_string_pretty(&paths)
379        .map_err(|error| format!("serializing wasm_paths.json: {error}"))?;
380    std::fs::write(&path, json).map_err(|error| format!("writing {}: {error}", path.display()))
381}
382
383/// Build every example the site shows and copy it where the runner serves it.
384///
385/// The same pages `cargo xtask run-wasm` writes are built here, from the same
386/// description, so the program under test is the one the site publishes.
387fn copy_examples() -> Result<(), String> {
388    let root = Path::new(EXAMPLE_DIST);
389    // Removed first: a stale module would keep being served after the build
390    // that produced it stopped being the one on disk.
391    std::fs::remove_dir_all(root).ok();
392    std::fs::create_dir_all(root).map_err(|error| format!("creating {EXAMPLE_DIST}: {error}"))?;
393
394    for example in crate::run_wasm::EXAMPLES {
395        let built = Path::new("target")
396            .join(crate::run_wasm::OUT_DIR)
397            .join(crate::run_wasm::PROFILE);
398        crate::run_wasm::build_example(example, &[], &built.join(example.dir))?;
399        copy_example(&built.join(example.dir), &root.join(example.dir))?;
400    }
401    Ok(())
402}
403
404/// Copy one built example into the runner's tree.
405fn copy_example(built: &Path, target: &Path) -> Result<(), String> {
406    std::fs::create_dir_all(target)
407        .map_err(|error| format!("creating {}: {error}", target.display()))?;
408
409    for entry in
410        std::fs::read_dir(built).map_err(|error| format!("reading {}: {error}", built.display()))?
411    {
412        let entry = entry.map_err(|error| format!("reading {}: {error}", built.display()))?;
413        let destination = target.join(entry.file_name());
414        std::fs::copy(entry.path(), &destination)
415            .map_err(|error| format!("copying to {}: {error}", destination.display()))?;
416    }
417    Ok(())
418}
419
420/// Ask the runner to load each example and confirm it drew something.
421///
422/// The GPU tests cover the rendering paths; this covers the programs a browser
423/// actually runs, which they never start. A change that compiles but leaves a
424/// page blank passes those and fails here.
425fn check_examples() -> Result<(), String> {
426    for example in crate::run_wasm::EXAMPLES {
427        check_example(example)?;
428    }
429    Ok(())
430}
431
432/// Print the device the browser draws with, as the page describes it.
433///
434/// The browser is a backend the host pass cannot report on — a WebGL2 adapter
435/// only exists inside the page — so this is the browser's half of what
436/// `cargo xtask print-gpu` prints for the host. Any test module answers, since
437/// the description comes from the harness rather than from the test.
438fn print_browser_gpu(modules: &[String]) -> Result<(), String> {
439    let Some(module) = modules.first() else {
440        return Err("no wasm test binary was built to ask for the device".to_owned());
441    };
442    let response = ureq::get(&format!("{BASE_URL}/gpu_info?wasm={module}"))
443        .config()
444        .http_status_as_error(false)
445        .build()
446        .call()
447        .map_err(|error| format!("describing the browser's device: {error}"))?;
448
449    // The runner reports a failure as a 500 whose body is a stack, which a
450    // caller that only reads the body would print as the device it drew with.
451    if !response.status().is_success() {
452        let message = response
453            .into_body()
454            .read_to_string()
455            .unwrap_or_else(|error| {
456                format!("the runner's failure message could not be read: {error}")
457            });
458        return Err(format!("the browser described no device:\n{message}"));
459    }
460
461    let body = response
462        .into_body()
463        .read_to_string()
464        .unwrap_or_else(|error| format!("the description could not be read: {error}"));
465    if body.is_empty() {
466        return Err("the browser described no device".to_owned());
467    }
468    println!("{body}");
469    Ok(())
470}
471
472/// Ask the runner to load one example and confirm it drew something.
473fn check_example(example: &crate::run_wasm::Example) -> Result<(), String> {
474    let response = ureq::get(&format!("{BASE_URL}/run_example?app={}", example.dir))
475        .config()
476        .http_status_as_error(false)
477        .build()
478        .call()
479        .map_err(|error| {
480            format!(
481                "running the {} example in the browser: {error}",
482                example.label
483            )
484        })?;
485
486    if response.status().is_success() {
487        return Ok(());
488    }
489    let message = response
490        .into_body()
491        .read_to_string()
492        .unwrap_or_else(|error| format!("the runner's failure message could not be read: {error}"));
493    Err(format!(
494        "the {} example did not render:\n{message}",
495        example.label
496    ))
497}
498
499/// Run the tests through the proxy, so nextest reports the browser's results.
500fn run_through_nextest() -> Result<(), String> {
501    let mut nextest = Command::new("cargo");
502    nextest
503        .args(["nextest", "run", "--profile", "wasm"])
504        .args(PACKAGES.iter().flat_map(|package| ["-p", package]))
505        .args(["--tests", "--all-features"])
506        .env(WASM_TEST_ENV, "1");
507    step::run(&mut nextest, "the wasm tests in the browser")
508}
509
510/// The Playwright runner, stopped when the run is over.
511struct Runner(Child);
512
513impl Runner {
514    /// Start the runner, with a visible window when `--show` was asked for.
515    fn start(show: bool) -> Result<Self, String> {
516        let mut child = Command::new("node")
517            .arg(Path::new(RUNNER).join("index.js"))
518            .args(if show { &["--show"][..] } else { &[][..] })
519            .env(MISMATCH_DIR_ENV, MISMATCH_DIR)
520            .stdout(Stdio::inherit())
521            .stderr(Stdio::inherit())
522            .spawn()
523            .map_err(|error| {
524                format!("starting the runner: {error}\nrun the dependencies first, or check that `node` is on PATH")
525            })?;
526
527        // A runner that dies at startup — a missing dependency, a port already
528        // taken — would otherwise look like a timeout.
529        if let Some(status) = child
530            .try_wait()
531            .map_err(|error| format!("checking the runner: {error}"))?
532        {
533            return Err(format!("the runner exited immediately with {status}"));
534        }
535        Ok(Self(child))
536    }
537
538    /// Wait for the runner to accept a connection.
539    fn wait_until_listening(&mut self) -> Result<(), String> {
540        // Hold the port's own lock while probing it. A connect that succeeds
541        // only means *something* is listening, and a stale runner from an
542        // earlier run would answer the requests with a stale page.
543        let start = Instant::now();
544        loop {
545            if let Some(status) = self
546                .0
547                .try_wait()
548                .map_err(|error| format!("checking the runner: {error}"))?
549            {
550                return Err(format!("the runner exited with {status} before listening"));
551            }
552            if TcpStream::connect(("127.0.0.1", PORT)).is_ok() {
553                return Ok(());
554            }
555            if start.elapsed() > STARTUP_TIMEOUT {
556                return Err(format!(
557                    "the runner did not listen on port {PORT} within {STARTUP_TIMEOUT:?}"
558                ));
559            }
560            std::thread::sleep(Duration::from_millis(100));
561        }
562    }
563
564    /// Stop the runner, and its browser with it.
565    ///
566    /// Through the runner's own shutdown route rather than a signal, so the
567    /// browser is closed first: killing the process outright would leave a
568    /// browser behind on every run.
569    fn stop(&mut self) {
570        let _ = ureq::get(&format!("{BASE_URL}/shutdown")).call();
571        // The runner exits once its browser has closed; if it does not, the
572        // kill below is the fallback.
573        let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
574        while Instant::now() < deadline {
575            if self.0.try_wait().ok().flatten().is_some() {
576                return;
577            }
578            std::thread::sleep(Duration::from_millis(50));
579        }
580        let _ = self.0.kill();
581        let _ = self.0.wait();
582    }
583}
584
585impl Drop for Runner {
586    fn drop(&mut self) {
587        // A run that failed before `stop` must not leave the browser behind.
588        if self.0.try_wait().ok().flatten().is_none() {
589            self.stop();
590        }
591    }
592}
593
594/// The `pnpm` to run, which is `pnpm.cmd` on Windows.
595fn pnpm() -> &'static str {
596    if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }
597}