1use 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
26const PACKAGES: &[&str] = &[
32 "unlit_wgpu",
33 "unlit3d",
34 "unlit3d_examples",
35 "unlit3dx_examples",
36];
37
38const TARGET: &str = "wasm32-unknown-unknown";
40
41const DIST: &str = "tests/wasm/dist";
43
44const EXAMPLE_DIST: &str = "tests/wasm/examples";
50
51const WEB: &str = "tests/wasm/web";
53
54const RUNNER: &str = "tests/wasm/runner";
56
57const MISMATCH_DIR_ENV: &str = "UNLIT3D_SNAPSHOT_MISMATCH_DIR";
64
65const MISMATCH_DIR: &str = "target/snapshot-mismatches-wasm";
71
72const WASM_TEST_ENV: &str = "UNLIT3D_WASM_TEST";
78
79const PORT: u16 = 3000;
81
82const BASE_URL: &str = "http://127.0.0.1:3000";
84
85const STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
87
88const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
90
91pub 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
138fn 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
162fn 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
191fn 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
225fn build_workspace() -> Result<(), String> {
234 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
261fn build_wasm_tests() -> Result<Vec<String>, String> {
263 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
316fn 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 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
355fn 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 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
383fn copy_examples() -> Result<(), String> {
388 let root = Path::new(EXAMPLE_DIST);
389 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
404fn 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
420fn check_examples() -> Result<(), String> {
426 for example in crate::run_wasm::EXAMPLES {
427 check_example(example)?;
428 }
429 Ok(())
430}
431
432fn 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 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
472fn 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
499fn 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
510struct Runner(Child);
512
513impl Runner {
514 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 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 fn wait_until_listening(&mut self) -> Result<(), String> {
540 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 fn stop(&mut self) {
570 let _ = ureq::get(&format!("{BASE_URL}/shutdown")).call();
571 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 if self.0.try_wait().ok().flatten().is_none() {
589 self.stop();
590 }
591 }
592}
593
594fn pnpm() -> &'static str {
596 if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }
597}