1use std::collections::BTreeSet;
28use std::fmt::Write as _;
29use std::fs;
30use std::path::{Component, Path, PathBuf};
31use std::process::Command;
32
33use crate::export::paths;
34use crate::notices::{self, EngineFile, Section, Work};
35use crate::project::Web;
36
37const SHELL: [(&str, &str); 8] = [
40 ("index.html", include_str!("../web/index.html")),
41 ("mq_js_bundle.js", include_str!("../web/mq_js_bundle.js")),
42 ("sapp_jsutils.js", include_str!("../web/sapp_jsutils.js")),
43 ("quad-storage.js", include_str!("../web/quad-storage.js")),
44 ("gamepad.js", include_str!("../web/gamepad.js")),
45 ("sound.js", include_str!("../web/sound.js")),
46 ("download.js", include_str!("../web/download.js")),
47 ("page-files.js", include_str!("../web/page-files.js")),
48];
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52enum Origin {
53 Engine,
55 Vendored,
57}
58
59const SHELL_NOTICES: [(&str, Origin); 8] = [
61 ("index.html", Origin::Engine),
62 ("mq_js_bundle.js", Origin::Vendored),
63 ("sapp_jsutils.js", Origin::Vendored),
64 ("quad-storage.js", Origin::Vendored),
65 ("gamepad.js", Origin::Engine),
66 ("sound.js", Origin::Engine),
67 ("download.js", Origin::Engine),
68 ("page-files.js", Origin::Engine),
69];
70
71fn vendored(name: &str) -> Option<&'static EngineFile> {
73 notices::FILES
74 .iter()
75 .find(|file| file.file.strip_prefix("crates/cli/web/") == Some(name))
76}
77
78pub fn shell_works() -> Vec<Work> {
80 SHELL_NOTICES
81 .iter()
82 .map(|(file, origin)| match (origin, vendored(file)) {
83 (Origin::Vendored, Some(vendored)) => vendored.work(Section::Shell),
84 _ => Work {
85 section: Section::Shell,
86 name: format!("{file}, from Shuyi Engine"),
87 version: env!("CARGO_PKG_VERSION").to_owned(),
88 license: env!("CARGO_PKG_LICENSE").to_owned(),
89 source: env!("CARGO_PKG_REPOSITORY").to_owned(),
90 texts: notices::engine_texts(),
91 },
92 })
93 .collect()
94}
95
96fn shipped_shell_file(name: &str, text: &str) -> String {
101 match vendored(name) {
102 Some(file) => format!(
103 "/*! {name}: {} {}, {}. See {}. */\n{text}",
104 file.work,
105 file.version,
106 file.license,
107 notices::FILE
108 ),
109 None => text.to_owned(),
110 }
111}
112
113pub(crate) const TARGET: &str = "wasm32-unknown-unknown";
115
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct Options {
119 pub release: bool,
121 pub features: Vec<String>,
123}
124
125pub fn usage(rules: &Web, command: &str) -> String {
127 let mut out = format!("usage: {command} web [--release]");
128 for feature in &rules.features {
129 let _ = write!(out, " [--{feature}]");
131 }
132 out
133}
134
135pub fn parse_args(rules: &Web, command: &str, args: &[String]) -> Result<Options, String> {
139 let mut options = Options::default();
140 for arg in args {
141 let bad = || usage(rules, command);
142 if arg == "--release" {
143 if options.release {
144 return Err(bad());
145 }
146 options.release = true;
147 continue;
148 }
149 let feature = arg
150 .strip_prefix("--")
151 .filter(|name| rules.features.iter().any(|f| f == name))
152 .ok_or_else(bad)?;
153 if options.features.iter().any(|f| f == feature) {
154 return Err(bad());
155 }
156 options.features.push(feature.to_owned());
157 }
158 Ok(options)
159}
160
161pub(crate) fn cargo_args(rules: &Web, options: &Options) -> Vec<String> {
163 let mut args: Vec<String> = ["build", "-p", &rules.package, "--target", TARGET]
164 .map(str::to_owned)
165 .to_vec();
166 if options.release {
167 args.push("--release".to_owned());
168 }
169 for feature in &options.features {
170 args.extend(["--features".to_owned(), feature.clone()]);
171 }
172 args
173}
174
175fn profile_dir(release: bool) -> &'static str {
177 if release { "release" } else { "debug" }
178}
179
180fn wasm_artifact_path(rules: &Web, target: &Path, options: &Options) -> PathBuf {
183 target
184 .join(TARGET)
185 .join(profile_dir(options.release))
186 .join(format!("{}.wasm", rules.bin))
187}
188
189pub fn target_dir(root: &Path) -> PathBuf {
194 let cargo = crate::cargo();
195 let manifest = Command::new(cargo)
196 .args(["locate-project", "--workspace", "--message-format", "plain"])
197 .current_dir(root)
198 .output()
199 .ok()
200 .filter(|output| output.status.success())
201 .and_then(|output| String::from_utf8(output.stdout).ok())
202 .map(|text| PathBuf::from(text.trim()));
203 target_dir_of(
204 root,
205 std::env::var_os("CARGO_TARGET_DIR"),
206 manifest.as_deref(),
207 )
208}
209
210fn target_dir_of(
213 root: &Path,
214 variable: Option<std::ffi::OsString>,
215 manifest: Option<&Path>,
216) -> PathBuf {
217 if let Some(dir) = variable.filter(|dir| !dir.is_empty()) {
218 return root.join(dir);
219 }
220 manifest
221 .and_then(Path::parent)
222 .map_or_else(|| root.join("target"), |dir| dir.join("target"))
223}
224
225pub fn run(rules: &Web, root: &Path, options: &Options, game: &[Work]) -> Result<String, String> {
234 let cargo = crate::cargo();
235 let target = target_dir(root);
236 let workspace = workspace_root(root);
237 let mut build = Command::new(cargo);
238 build.args(cargo_args(rules, options)).current_dir(root);
239 if options.release {
240 let outside = paths::outside(root, &workspace);
241 let remaps = paths::remaps(&workspace, &target, &rules.package, &outside);
242 build.env(paths::ENCODED, paths::remapped_flags_here(&remaps));
243 }
244 let status = build
245 .status()
246 .map_err(|e| format!("spawn cargo build: {e}"))?;
247 if !status.success() {
248 return Err(format!("cargo build exited with {status}"));
249 }
250 if options.release {
251 let needles = paths::needles(&[root, &workspace]);
252 paths::check_file(&wasm_artifact_path(rules, &target, options), &needles)?;
253 }
254 let mut works = notices::crates(root, &rules.package, TARGET, &options.features)?;
255 let bare: Vec<&str> = works
256 .iter()
257 .filter(|work| work.texts.is_empty())
258 .map(|work| work.name.as_str())
259 .collect();
260 if !bare.is_empty() {
261 eprintln!(
262 "web: warning: no license text found for {}; {} lists them without one",
263 bare.join(", "),
264 notices::FILE
265 );
266 }
267 works.extend(shell_works());
268 works.extend(game.iter().cloned());
269 package(&RealWasmOpt, rules, root, &target, options, &works)
270}
271
272pub fn workspace_root(root: &Path) -> PathBuf {
275 Command::new(crate::cargo())
276 .args(["locate-project", "--workspace", "--message-format", "plain"])
277 .current_dir(root)
278 .output()
279 .ok()
280 .filter(|output| output.status.success())
281 .and_then(|output| String::from_utf8(output.stdout).ok())
282 .and_then(|text| PathBuf::from(text.trim()).parent().map(Path::to_path_buf))
283 .unwrap_or_else(|| root.to_path_buf())
284}
285
286pub(crate) fn package(
290 opt: &impl WasmOpt,
291 rules: &Web,
292 root: &Path,
293 target: &Path,
294 options: &Options,
295 works: &[Work],
296) -> Result<String, String> {
297 let dist = resolved(&root.join(&rules.dist));
298 let wasm_src = wasm_artifact_path(rules, target, options);
299 refuse_dist(rules, root, &dist, &wasm_src)?;
300 let name = dist.file_name().unwrap_or_default().to_string_lossy();
301 let staging = dist.with_file_name(format!("{name}.staging"));
302 if staging.exists() {
303 fs::remove_dir_all(&staging).map_err(|e| format!("clear {}: {e}", staging.display()))?;
304 }
305 fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?;
306 let made = fill(opt, rules, root, &staging, &wasm_src, options, works)
307 .and_then(|size| publish(&staging, &dist).map(|()| size));
308 let _ = fs::remove_dir_all(&staging);
310 Ok(format!("{} ({} bytes)", dist.display(), made?))
311}
312
313fn resolved(path: &Path) -> PathBuf {
316 let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
317 let mut out = PathBuf::new();
318 for part in absolute.components() {
319 match part {
320 Component::CurDir => {}
321 Component::ParentDir => {
322 out.pop();
323 }
324 other => out.push(other),
325 }
326 }
327 out
328}
329
330pub(crate) fn is_under(path: &Path, folder: &Path) -> bool {
332 resolved(path).starts_with(resolved(folder))
333}
334
335fn refuse_dist(rules: &Web, root: &Path, dist: &Path, wasm: &Path) -> Result<(), String> {
339 let configured = &rules.dist;
340 let sources = [Some(root.to_path_buf()), Some(wasm.to_path_buf())]
341 .into_iter()
342 .chain([&rules.music, &rules.voice].map(|folder| folder.as_ref().map(|f| root.join(f))));
343 for source in sources.flatten() {
344 if is_under(&source, dist) {
345 return Err(format!(
346 "`web.dist` is `{configured}`, and each build replaces all of it, which holds {}: \
347 give the web build a folder of its own, such as `dist/web`",
348 source.display()
349 ));
350 }
351 }
352 for marker in [".git", "Cargo.toml"] {
353 if dist.join(marker).exists() {
354 return Err(format!(
355 "`web.dist` is `{configured}`, and each build replaces all of it, which holds \
356 {marker}: give the web build a folder of its own, such as `dist/web`"
357 ));
358 }
359 }
360 Ok(())
361}
362
363fn publish(staging: &Path, dist: &Path) -> Result<(), String> {
366 fs::create_dir_all(dist).map_err(|e| format!("create {}: {e}", dist.display()))?;
367 let read = |dir: &Path| {
368 fs::read_dir(dir)
369 .and_then(Iterator::collect::<Result<Vec<_>, _>>)
370 .map_err(|e| format!("read {}: {e}", dir.display()))
371 };
372 for entry in read(dist)? {
373 let path = entry.path();
374 let removed = if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
375 fs::remove_dir_all(&path)
376 } else {
377 fs::remove_file(&path).or_else(|_| fs::remove_dir(&path))
379 };
380 removed.map_err(|e| format!("clear {}: {e}", path.display()))?;
381 }
382 for entry in read(staging)? {
383 let to = dist.join(entry.file_name());
384 fs::rename(entry.path(), &to).map_err(|e| format!("move to {}: {e}", to.display()))?;
385 }
386 Ok(())
387}
388
389fn fill(
393 opt: &impl WasmOpt,
394 rules: &Web,
395 root: &Path,
396 dist: &Path,
397 wasm_src: &Path,
398 options: &Options,
399 works: &[Work],
400) -> Result<u64, String> {
401 let wasm_name = format!("{}.wasm", rules.bin);
402 let wasm_dst = dist.join(&wasm_name);
403 fs::copy(wasm_src, &wasm_dst)
404 .map_err(|e| format!("copy {} to {}: {e}", wasm_src.display(), wasm_dst.display()))?;
405
406 for (name, text) in SHELL {
407 let text = if name == "index.html" {
408 text.replace("%TITLE%", &rules.title)
409 .replace("%WASM%", &wasm_name)
410 } else {
411 shipped_shell_file(name, text)
412 };
413 let shell_dst = dist.join(name);
414 fs::write(&shell_dst, text).map_err(|e| format!("write {}: {e}", shell_dst.display()))?;
415 }
416 notices::write(dist, &rules.title, works)?;
417
418 if let Some(music) = &rules.music {
419 copy_music(&root.join(music), &dist.join(music))?;
420 }
421 if let Some(voice) = &rules.voice {
422 copy_voice(&root.join(voice), &dist.join(voice))?;
423 }
424
425 if options.release {
426 run_wasm_opt(opt, &wasm_dst);
427 }
428
429 Ok(fs::metadata(&wasm_dst)
430 .map_err(|e| format!("stat {}: {e}", wasm_dst.display()))?
431 .len())
432}
433
434fn is_track(path: &Path) -> bool {
436 path.is_file() && path.extension().is_some_and(|ext| ext == "ogg")
437}
438
439fn is_voice_file(path: &Path) -> bool {
441 path.extension()
442 .is_some_and(|ext| ext == "ogg" || ext == "ron")
443}
444
445pub(crate) fn current_files(rules: &Web, root: &Path) -> Result<BTreeSet<String>, String> {
449 let mut files: BTreeSet<String> = SHELL.iter().map(|(name, _)| (*name).to_owned()).collect();
450 files.insert(format!("{}.wasm", rules.bin));
451 files.insert(notices::FILE.to_owned());
452 files.extend(game_files(rules, root)?);
453 Ok(files)
454}
455
456pub(crate) fn game_files(rules: &Web, root: &Path) -> Result<BTreeSet<String>, String> {
459 let mut files = BTreeSet::new();
460 if let Some(music) = &rules.music {
461 for entry in fs::read_dir(root.join(music)).into_iter().flatten() {
463 let path = entry.map_err(|e| format!("read {music}: {e}"))?.path();
464 if is_track(&path) {
465 let name = path.file_name().unwrap_or_default().to_string_lossy();
466 files.insert(format!("{}/{name}", package_path(music)));
467 }
468 }
469 }
470 if let Some(voice) = &rules.voice {
471 let src = root.join(voice);
472 if src.is_dir() {
473 list_voice(&src, &package_path(voice), &mut files)?;
474 }
475 }
476 Ok(files)
477}
478
479fn list_voice(dir: &Path, prefix: &str, files: &mut BTreeSet<String>) -> Result<(), String> {
481 for entry in fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))? {
482 let path = entry
483 .map_err(|e| format!("read {}: {e}", dir.display()))?
484 .path();
485 let name = path.file_name().unwrap_or_default().to_string_lossy();
486 let inner = format!("{prefix}/{name}");
487 if path.is_dir() {
488 list_voice(&path, &inner, files)?;
489 } else if is_voice_file(&path) {
490 files.insert(inner);
491 }
492 }
493 Ok(())
494}
495
496pub(crate) fn package_path(folder: &str) -> String {
498 let path = folder.replace('\\', "/");
499 let parts: Vec<&str> = path
500 .split('/')
501 .filter(|part| !part.is_empty() && *part != ".")
502 .collect();
503 parts.join("/")
504}
505
506fn copy_music(src: &Path, dst: &Path) -> Result<(), String> {
509 fs::create_dir_all(dst).map_err(|e| format!("create {}: {e}", dst.display()))?;
510 let Ok(entries) = fs::read_dir(src) else {
511 return Ok(());
512 };
513 for entry in entries {
514 let path = entry
515 .map_err(|e| format!("read {}: {e}", src.display()))?
516 .path();
517 if is_track(&path) {
518 let name = path.file_name().unwrap_or_default();
519 fs::copy(&path, dst.join(name)).map_err(|e| format!("copy {}: {e}", path.display()))?;
520 }
521 }
522 Ok(())
523}
524
525fn copy_voice(src: &Path, dst: &Path) -> Result<(), String> {
529 if !src.is_dir() {
530 return Ok(());
531 }
532 copy_tree(src, dst, &is_voice_file).map(|_| ())
533}
534
535pub fn copy_tree(src: &Path, dst: &Path, keep: &dyn Fn(&Path) -> bool) -> Result<usize, String> {
539 fs::create_dir_all(dst).map_err(|e| format!("create {}: {e}", dst.display()))?;
540 let entries = fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
541 let mut copied = 0;
542 for entry in entries {
543 let path = entry
544 .map_err(|e| format!("read {}: {e}", src.display()))?
545 .path();
546 let to = dst.join(path.file_name().unwrap_or_default());
547 if path.is_dir() {
548 copied += copy_tree(&path, &to, keep)?;
549 } else if keep(&path) {
550 fs::copy(&path, &to).map_err(|e| format!("copy {}: {e}", path.display()))?;
551 copied += 1;
552 }
553 }
554 Ok(copied)
555}
556
557pub(crate) trait WasmOpt {
561 fn optimize(&self, input: &Path, output: &Path) -> Result<bool, String>;
566}
567
568pub(crate) struct RealWasmOpt;
570
571impl WasmOpt for RealWasmOpt {
572 fn optimize(&self, input: &Path, output: &Path) -> Result<bool, String> {
578 Command::new("wasm-opt")
579 .args(["-Oz", "-o"])
580 .arg(output)
581 .arg(input)
582 .status()
583 .map(|status| status.success())
584 .map_err(|e| e.to_string())
585 }
586}
587
588fn run_wasm_opt(opt: &impl WasmOpt, wasm_path: &Path) {
592 let optimized = wasm_path.with_extension("wasm.opt");
593 match opt.optimize(wasm_path, &optimized) {
594 Ok(true) => {
595 if let Err(e) = fs::rename(&optimized, wasm_path) {
596 eprintln!("web: wasm-opt ran but replacing the binary failed: {e}");
597 }
598 }
599 Ok(false) => eprintln!("web: wasm-opt exited with a failure, skipping optimization"),
600 Err(_) => {} }
602 let _ = fs::remove_file(&optimized);
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 const PADS_WEB: &str = include_str!("../../app/src/pads/web.rs");
611 const SOUND_WEB: &str = include_str!("../../sound-out/src/sys.rs");
613
614 const SESSIONS_WEB: &str = include_str!("../../app/src/sessions/web.rs");
616
617 const PAGE: &str = include_str!("../../app/src/page.rs");
619
620 struct FakeWasmOpt<F>(F);
621
622 impl<F: Fn(&Path, &Path) -> Result<bool, String>> WasmOpt for FakeWasmOpt<F> {
623 fn optimize(&self, input: &Path, output: &Path) -> Result<bool, String> {
624 (self.0)(input, output)
625 }
626 }
627
628 fn rules() -> Web {
630 Web {
631 package: "game-app".to_owned(),
632 bin: "the-game".to_owned(),
633 title: "The Game".to_owned(),
634 features: vec!["debug-tools".to_owned(), "bought-art".to_owned()],
635 music: Some("music".to_owned()),
636 voice: Some("voice".to_owned()),
637 dist: "dist/web".to_owned(),
638 }
639 }
640
641 fn args(items: &[&str]) -> Vec<String> {
642 items.iter().map(|s| (*s).to_owned()).collect()
643 }
644
645 fn parse(items: &[&str]) -> Result<Options, String> {
646 parse_args(&rules(), "shuyi", &args(items))
647 }
648
649 fn debug() -> Options {
650 Options::default()
651 }
652
653 fn release() -> Options {
654 Options {
655 release: true,
656 features: Vec::new(),
657 }
658 }
659
660 fn shell(name: &str) -> &'static str {
661 SHELL.iter().find(|(n, _)| *n == name).unwrap().1
662 }
663
664 #[test]
665 fn index_html_keeps_resuming_audio_until_it_runs() {
666 let html = shell("index.html");
669 let shim = html
670 .find("class TrackedAudioContext extends Base")
671 .expect("index.html wraps AudioContext");
672 let bundle = html
673 .find(r#"<script src="mq_js_bundle.js">"#)
674 .expect("index.html loads mq_js_bundle.js");
675 assert!(shim < bundle, "wrap AudioContext before the bundle loads");
676 assert!(html.contains(r#"for (const type of ["keydown", "mousedown", "touchend"])"#));
677 }
678
679 fn fixture(name: &str) -> PathBuf {
681 let dir = std::env::temp_dir().join(format!("shuyi-web-{name}-{}", std::process::id()));
682 let _ = fs::remove_dir_all(&dir);
683 fs::create_dir_all(&dir).unwrap();
684 dir
685 }
686
687 fn write_wasm(root: &Path, options: &Options, content: &[u8]) {
688 let path = wasm_artifact_path(&rules(), &root.join("target"), options);
689 fs::create_dir_all(path.parent().unwrap()).unwrap();
690 fs::write(path, content).unwrap();
691 }
692
693 #[test]
694 fn parse_args_with_no_args_is_debug() {
695 assert_eq!(parse(&[]), Ok(debug()));
696 assert_eq!(parse(&["--release"]), Ok(release()));
697 }
698
699 #[test]
700 fn parse_args_takes_the_listed_features_in_any_order_once() {
701 let usage = "usage: shuyi web [--release] [--debug-tools] [--bought-art]";
702 assert_eq!(usage, super::usage(&rules(), "shuyi"));
703 let both = |features: &[&str]| Options {
704 release: true,
705 features: args(features),
706 };
707 assert_eq!(
708 parse(&["--release", "--debug-tools"]),
709 Ok(both(&["debug-tools"]))
710 );
711 assert_eq!(
712 parse(&["--bought-art", "--release", "--debug-tools"]),
713 Ok(both(&["bought-art", "debug-tools"]))
714 );
715 for bad in [
716 &["--release", "--release"][..],
717 &["--debug-tools", "--debug-tools"],
718 &["--bogus"],
719 &["--release", "extra"],
720 &["--features"],
721 ] {
722 assert_eq!(parse(bad), Err(usage.to_owned()), "{bad:?}");
723 }
724 }
725
726 #[test]
727 fn cargo_args_add_release_and_the_features() {
728 let base = [
729 "build",
730 "-p",
731 "game-app",
732 "--target",
733 "wasm32-unknown-unknown",
734 ];
735 assert_eq!(cargo_args(&rules(), &debug()), base);
736 assert_eq!(cargo_args(&rules(), &release())[5..], ["--release"]);
737 let all = Options {
738 release: true,
739 features: args(&["debug-tools", "bought-art"]),
740 };
741 assert_eq!(
742 cargo_args(&rules(), &all)[5..],
743 [
744 "--release",
745 "--features",
746 "debug-tools",
747 "--features",
748 "bought-art"
749 ]
750 );
751 }
752
753 #[test]
754 fn wasm_artifact_path_uses_the_profile() {
755 let root = Path::new("/repo/target");
756 assert_eq!(
757 wasm_artifact_path(&rules(), root, &debug()),
758 Path::new("/repo/target/wasm32-unknown-unknown/debug/the-game.wasm")
759 );
760 assert_eq!(
761 wasm_artifact_path(&rules(), root, &release()),
762 Path::new("/repo/target/wasm32-unknown-unknown/release/the-game.wasm")
763 );
764 }
765
766 #[test]
767 fn the_target_folder_is_the_variables_else_the_workspaces() {
768 let root = Path::new("/game");
769 let manifest = Some(Path::new("/repo/Cargo.toml"));
770 assert_eq!(
771 target_dir_of(root, Some("/out".into()), manifest),
772 Path::new("/out")
773 );
774 assert_eq!(
775 target_dir_of(root, Some("out".into()), manifest),
776 Path::new("/game/out")
777 );
778 assert_eq!(
779 target_dir_of(root, Some("".into()), manifest),
780 Path::new("/repo/target")
781 );
782 assert_eq!(
783 target_dir_of(root, None, manifest),
784 Path::new("/repo/target")
785 );
786 assert_eq!(target_dir_of(root, None, None), Path::new("/game/target"));
787 }
788
789 #[test]
790 fn the_target_folder_of_a_crate_in_a_workspace_is_the_workspaces() {
791 let here = Path::new(env!("CARGO_MANIFEST_DIR"));
794 let target = target_dir(here);
795 if std::env::var_os("CARGO_TARGET_DIR").is_none() {
796 assert_eq!(target.file_name().unwrap(), "target");
797 assert!(here.starts_with(target.parent().unwrap()));
798 assert_ne!(target, here.join("target"));
799 }
800 let alone = fixture("target-alone");
802 if std::env::var_os("CARGO_TARGET_DIR").is_none() {
803 assert_eq!(target_dir(&alone), alone.join("target"));
804 }
805 fs::remove_dir_all(&alone).unwrap();
806 }
807
808 #[test]
809 fn package_copies_wasm_and_writes_the_shell() {
810 let root = fixture("package-basic");
811 write_wasm(&root, &debug(), b"wasm-bytes");
812 let opt = FakeWasmOpt(|_: &Path, _: &Path| -> Result<bool, String> {
813 panic!("wasm-opt should not run for a debug build")
814 });
815 let summary = package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap();
816 assert!(summary.contains("10 bytes"), "{summary}");
817 let dist = root.join("dist/web");
818 assert_eq!(fs::read(dist.join("the-game.wasm")).unwrap(), b"wasm-bytes");
819 let html = fs::read_to_string(dist.join("index.html")).unwrap();
820 assert!(html.contains("<title>The Game</title>"), "{html}");
821 assert!(html.contains(r#"load("the-game.wasm");"#), "{html}");
822 assert!(!html.contains("%TITLE%") && !html.contains("%WASM%"));
823 assert!(
824 html.contains(r#"<link rel="license" href="NOTICES.txt">"#),
825 "{html}"
826 );
827 for (name, header) in [
828 (
829 "mq_js_bundle.js",
830 "/*! mq_js_bundle.js: macroquad 0.4.16, MIT. See NOTICES.txt. */\n",
831 ),
832 (
833 "sapp_jsutils.js",
834 "/*! sapp_jsutils.js: sapp-jsutils 0.1.7, MIT. See NOTICES.txt. */\n",
835 ),
836 (
837 "quad-storage.js",
838 "/*! quad-storage.js: quad-storage 0.1.3, MIT. See NOTICES.txt. */\n",
839 ),
840 ("gamepad.js", ""),
841 ("sound.js", ""),
842 ("download.js", ""),
843 ("page-files.js", ""),
844 ] {
845 assert_eq!(
846 fs::read_to_string(dist.join(name)).unwrap(),
847 format!("{header}{}", shell(name)),
848 "{name}"
849 );
850 }
851 let notices = fs::read_to_string(dist.join("NOTICES.txt")).unwrap();
853 assert!(notices.starts_with("Notices for The Game\n"), "{notices}");
854 fs::remove_dir_all(&root).unwrap();
855 }
856
857 #[test]
858 fn package_writes_the_works_into_notices_txt() {
859 let root = fixture("package-notices");
860 write_wasm(&root, &debug(), b"w");
861 let opt = FakeWasmOpt(|_: &Path, _: &Path| Ok(false));
862 let mut font = Work::new(Section::Game, "files/font/OFL.txt");
863 font.texts = vec![notices::Text::new(
864 "files/font/OFL.txt",
865 "Open Font License\n",
866 )];
867 let mut works = shell_works();
868 works.push(font);
869 package(
870 &opt,
871 &rules(),
872 &root,
873 &root.join("target"),
874 &debug(),
875 &works,
876 )
877 .unwrap();
878 let notices = fs::read_to_string(root.join("dist/web/NOTICES.txt")).unwrap();
879 for needle in [
880 "- mq_js_bundle.js, from macroquad 0.4.16, MIT, https://github.com/not-fl3/macroquad\n",
881 "- index.html, from Shuyi Engine ",
882 "- files/font/OFL.txt\n",
883 " mq_js_bundle.js, from macroquad 0.4.16 (mq_js_bundle-LICENSE-MIT.txt)\n",
884 "Open Font License\n",
885 "Copyright (c) 2026 Nick Wentz\n",
886 "Apache License",
887 ] {
888 assert!(notices.contains(needle), "{needle} in {notices}");
889 }
890 fs::remove_dir_all(&root).unwrap();
891 }
892
893 #[test]
894 fn every_shell_file_has_its_notice_and_the_vendored_ones_are_the_web_files() {
895 let table: Vec<&str> = SHELL_NOTICES.iter().map(|(file, _)| *file).collect();
896 let shell: Vec<&str> = SHELL.iter().map(|(file, _)| *file).collect();
897 assert_eq!(
898 table, shell,
899 "a file of SHELL needs its row in SHELL_NOTICES"
900 );
901 let mut vendored: Vec<&str> = SHELL_NOTICES
904 .iter()
905 .filter(|(_, origin)| *origin == Origin::Vendored)
906 .map(|(file, _)| *file)
907 .collect();
908 vendored.sort_unstable();
909 let mut web: Vec<&str> = notices::FILES
910 .iter()
911 .filter(|file| file.builds.contains(¬ices::Build::Web))
912 .filter_map(|file| file.file.strip_prefix("crates/cli/web/"))
913 .collect();
914 web.sort_unstable();
915 assert_eq!(vendored, web);
916 for (file, origin) in &SHELL_NOTICES {
917 assert_eq!(
918 super::vendored(file).is_some(),
919 *origin == Origin::Vendored,
920 "{file}"
921 );
922 }
923 }
924
925 #[test]
926 fn a_vendored_script_starts_with_a_one_line_header_and_the_engines_files_get_none() {
927 for (file, _) in &SHELL_NOTICES {
928 let shipped = shipped_shell_file(file, shell(file));
929 if let Some(EngineFile { work, version, .. }) = vendored(file) {
930 let (first, rest) = shipped.split_once('\n').unwrap();
931 assert!(first.starts_with("/*! "), "{file}: {first}");
932 assert!(first.ends_with(" */"), "{file}: {first}");
933 assert_eq!(first.matches("*/").count(), 1, "{file}: {first}");
934 assert!(first.contains(&format!("{work} {version}, MIT")), "{first}");
935 assert!(first.contains(notices::FILE), "{first}");
936 assert_eq!(rest, shell(file), "{file}");
937 } else {
938 assert_eq!(shipped, shell(file), "{file}");
939 }
940 }
941 }
942
943 #[test]
944 fn a_game_without_music_or_voices_gets_no_folders() {
945 let root = fixture("package-bare");
946 write_wasm(&root, &debug(), b"w");
947 let bare = Web {
948 music: None,
949 voice: None,
950 ..rules()
951 };
952 fs::create_dir_all(root.join("music")).unwrap();
953 let opt = FakeWasmOpt(|_: &Path, _: &Path| Ok(false));
954 package(&opt, &bare, &root, &root.join("target"), &debug(), &[]).unwrap();
955 assert!(!root.join("dist/web/music").exists());
956 assert!(!root.join("dist/web/voice").exists());
957 fs::remove_dir_all(&root).unwrap();
958 }
959
960 #[test]
961 fn package_copies_the_music_tracks_only() {
962 let root = fixture("package-music");
963 write_wasm(&root, &debug(), b"w");
964 let opt = FakeWasmOpt(|_: &Path, _: &Path| Ok(false));
965 package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap();
967 let music = root.join("dist/web/music");
968 assert!(music.is_dir());
969 assert_eq!(fs::read_dir(&music).unwrap().count(), 0);
970 fs::create_dir_all(root.join("music/sub")).unwrap();
971 fs::write(root.join("music/title.ogg"), "ogg").unwrap();
972 fs::write(root.join("music/README.md"), "doc").unwrap();
973 fs::write(root.join("music/sub/x.ogg"), "nested").unwrap();
974 fs::write(music.join("stale.ogg"), "old").unwrap();
975 package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap();
976 let mut names: Vec<_> = fs::read_dir(&music)
977 .unwrap()
978 .map(|e| e.unwrap().file_name().into_string().unwrap())
979 .collect();
980 names.sort();
981 assert_eq!(names, ["title.ogg"]);
982 assert_eq!(fs::read_to_string(music.join("title.ogg")).unwrap(), "ogg");
983 fs::remove_dir_all(&root).unwrap();
984 }
985
986 #[test]
989 fn package_copies_the_voice_folder_if_there_is_one() {
990 let root = fixture("package-voice");
991 write_wasm(&root, &debug(), b"w");
992 let opt = FakeWasmOpt(|_: &Path, _: &Path| Ok(false));
993 let voice = root.join("dist/web/voice");
994 fs::create_dir_all(voice.join("en")).unwrap();
996 fs::write(voice.join("en/stale.ogg"), "old").unwrap();
997 package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap();
998 assert!(!voice.exists());
999 fs::create_dir_all(root.join("voice/en/scene")).unwrap();
1000 fs::write(root.join("voice/en/voice.ron"), "manifest").unwrap();
1001 fs::write(root.join("voice/en/cast.ron"), "cast").unwrap();
1002 fs::write(root.join("voice/en/scene/line.ogg"), "clip").unwrap();
1003 fs::write(root.join("voice/en/scene/line.m.ogg"), "clip m").unwrap();
1004 fs::write(root.join("voice/en/scene/take.wav"), "source").unwrap();
1005 fs::write(root.join("voice/notes.md"), "doc").unwrap();
1006 fs::write(root.join("voice/ogg"), "no extension").unwrap();
1007 package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap();
1008 let read = |path: &str| fs::read_to_string(voice.join(path)).ok();
1009 assert_eq!(read("en/voice.ron").as_deref(), Some("manifest"));
1010 assert_eq!(read("en/cast.ron").as_deref(), Some("cast"));
1011 assert_eq!(read("en/scene/line.ogg").as_deref(), Some("clip"));
1012 assert_eq!(read("en/scene/line.m.ogg").as_deref(), Some("clip m"));
1013 assert_eq!(read("en/scene/take.wav"), None);
1014 assert_eq!(read("notes.md"), None);
1015 assert_eq!(read("ogg"), None);
1016 fs::remove_dir_all(root.join("voice")).unwrap();
1018 fs::write(root.join("voice"), "a file").unwrap();
1019 package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap();
1020 assert!(!voice.exists());
1021 fs::remove_dir_all(&root).unwrap();
1022 }
1023
1024 #[test]
1025 fn copy_tree_counts_the_files_it_copies_and_reports_a_missing_source() {
1026 let root = fixture("copy-tree");
1027 fs::create_dir_all(root.join("src/a/b")).unwrap();
1028 fs::write(root.join("src/one.ogg"), "1").unwrap();
1029 fs::write(root.join("src/a/two.ogg"), "2").unwrap();
1030 fs::write(root.join("src/a/b/three.txt"), "3").unwrap();
1031 let ogg = |path: &Path| path.extension().is_some_and(|ext| ext == "ogg");
1032 assert_eq!(copy_tree(&root.join("src"), &root.join("dst"), &ogg), Ok(2));
1033 assert!(root.join("dst/a/two.ogg").is_file());
1034 assert!(root.join("dst/a/b").is_dir());
1035 assert!(!root.join("dst/a/b/three.txt").exists());
1036 let all = copy_tree(&root.join("src"), &root.join("all"), &|_| true);
1037 assert_eq!(all, Ok(3));
1038 let error = copy_tree(&root.join("nowhere"), &root.join("dst2"), &ogg).unwrap_err();
1039 assert!(error.starts_with("read "), "{error}");
1040 fs::remove_dir_all(&root).unwrap();
1041 }
1042
1043 #[test]
1044 fn package_runs_wasm_opt_on_release_and_uses_its_output() {
1045 let root = fixture("package-release-ok");
1046 write_wasm(&root, &release(), b"unoptimized-bytes");
1047 let opt = FakeWasmOpt(|_input: &Path, output: &Path| {
1048 fs::write(output, b"opt").unwrap();
1049 Ok(true)
1050 });
1051 let summary =
1052 package(&opt, &rules(), &root, &root.join("target"), &release(), &[]).unwrap();
1053 assert!(summary.contains("3 bytes"), "{summary}");
1054 assert_eq!(
1055 fs::read(root.join("dist/web/the-game.wasm")).unwrap(),
1056 b"opt"
1057 );
1058 fs::remove_dir_all(&root).unwrap();
1059 }
1060
1061 #[test]
1062 fn package_keeps_original_when_wasm_opt_is_not_installed_or_fails() {
1063 for (name, outcome) in [
1064 ("missing", Err("not found".to_owned())),
1065 ("fails", Ok(false)),
1066 ] {
1067 let root = fixture(&format!("package-release-{name}"));
1068 write_wasm(&root, &release(), b"unoptimized");
1069 let opt = FakeWasmOpt(|_: &Path, _: &Path| outcome.clone());
1070 let summary =
1071 package(&opt, &rules(), &root, &root.join("target"), &release(), &[]).unwrap();
1072 assert!(summary.contains("11 bytes"), "{summary}");
1073 assert_eq!(
1074 fs::read(root.join("dist/web/the-game.wasm")).unwrap(),
1075 b"unoptimized"
1076 );
1077 fs::remove_dir_all(&root).unwrap();
1078 }
1079 }
1080
1081 #[test]
1082 fn package_reports_a_missing_wasm_artifact() {
1083 let root = fixture("package-missing-wasm");
1084 let opt = FakeWasmOpt(|_: &Path, _: &Path| panic!("not reached"));
1085 let err = package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap_err();
1086 assert!(err.contains("copy"), "{err}");
1087 fs::remove_dir_all(&root).unwrap();
1088 }
1089
1090 fn tree(dir: &Path) -> Vec<String> {
1092 let entries = crate::export::archive::entries(dir, "", &|_| false).unwrap();
1093 entries.into_iter().map(|entry| entry.name).collect()
1094 }
1095
1096 #[test]
1097 fn a_build_replaces_everything_in_dist_and_leaves_no_staging() {
1098 let root = fixture("package-stale");
1099 write_wasm(&root, &debug(), b"w");
1100 let opt = FakeWasmOpt(|_: &Path, _: &Path| Ok(false));
1101 let dist = root.join("dist/web");
1102 fs::create_dir_all(dist.join("old/deep")).unwrap();
1103 fs::write(dist.join("canary.txt"), "old").unwrap();
1104 fs::write(dist.join("old/deep/canary.txt"), "old").unwrap();
1105 fs::create_dir_all(root.join("dist/web.staging")).unwrap();
1107 fs::write(root.join("dist/web.staging/canary.txt"), "old").unwrap();
1108 package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap();
1109 let mut files: Vec<String> = current_files(&rules(), &root)
1110 .unwrap()
1111 .into_iter()
1112 .collect();
1113 files.sort();
1114 assert_eq!(tree(&dist), files);
1115 assert!(!dist.join("old").exists());
1116 assert!(!root.join("dist/web.staging").exists());
1117 fs::remove_dir_all(&root).unwrap();
1118 }
1119
1120 #[test]
1121 fn a_failed_build_leaves_the_last_one_as_it_was() {
1122 let root = fixture("package-failed");
1123 let opt = FakeWasmOpt(|_: &Path, _: &Path| Ok(false));
1124 let dist = root.join("dist/web");
1125 fs::create_dir_all(&dist).unwrap();
1126 fs::write(dist.join("index.html"), "the last build").unwrap();
1127 package(&opt, &rules(), &root, &root.join("target"), &debug(), &[]).unwrap_err();
1129 assert_eq!(tree(&dist), ["index.html"]);
1130 assert!(!root.join("dist/web.staging").exists());
1131 fs::remove_dir_all(&root).unwrap();
1132 }
1133
1134 #[test]
1135 fn a_failed_wasm_opt_leaves_nothing_in_the_package() {
1136 let root = fixture("package-opt-leftover");
1137 write_wasm(&root, &release(), b"w");
1138 let opt = FakeWasmOpt(|_: &Path, output: &Path| {
1139 fs::write(output, b"partial").unwrap();
1140 Ok(false)
1141 });
1142 package(&opt, &rules(), &root, &root.join("target"), &release(), &[]).unwrap();
1143 assert!(!root.join("dist/web/the-game.wasm.opt").exists());
1144 fs::remove_dir_all(&root).unwrap();
1145 }
1146
1147 #[test]
1148 fn a_dist_that_holds_what_must_stay_is_refused_and_untouched() {
1149 let root = fixture("package-refused");
1150 write_wasm(&root, &debug(), b"w");
1151 fs::write(root.join("keep.txt"), "kept").unwrap();
1152 let opt = FakeWasmOpt(|_: &Path, _: &Path| panic!("not reached"));
1153 for (dist, holds) in [
1154 (".", "keep"),
1155 ("sub/..", "keep"),
1156 ("..", "keep"),
1157 ("target", "wasm"),
1158 ("music/..", "keep"),
1159 ] {
1160 let rules = Web {
1161 dist: dist.to_owned(),
1162 ..rules()
1163 };
1164 let err =
1165 package(&opt, &rules, &root, &root.join("target"), &debug(), &[]).unwrap_err();
1166 assert!(
1167 err.contains(&format!("`web.dist` is `{dist}`")),
1168 "{holds}: {err}"
1169 );
1170 }
1171 let nested = Web {
1173 dist: "assets".to_owned(),
1174 music: Some("assets/music".to_owned()),
1175 ..rules()
1176 };
1177 let err = package(&opt, &nested, &root, &root.join("target"), &debug(), &[]).unwrap_err();
1178 assert!(err.contains("music"), "{err}");
1179 for marker in [".git", "Cargo.toml"] {
1181 let folder = root.join(format!("site-{marker}"));
1182 fs::create_dir_all(&folder).unwrap();
1183 fs::write(folder.join(marker), "").unwrap();
1184 let rules = Web {
1185 dist: format!("site-{marker}"),
1186 ..rules()
1187 };
1188 let err =
1189 package(&opt, &rules, &root, &root.join("target"), &debug(), &[]).unwrap_err();
1190 assert!(err.contains(marker), "{err}");
1191 assert!(folder.join(marker).exists());
1192 }
1193 assert_eq!(fs::read_to_string(root.join("keep.txt")).unwrap(), "kept");
1194 fs::remove_dir_all(&root).unwrap();
1195 }
1196
1197 #[test]
1198 fn the_current_files_are_what_a_build_writes() {
1199 let root = fixture("current-files");
1200 write_wasm(&root, &debug(), b"w");
1201 let rules = Web {
1202 music: Some("./audio/music/".to_owned()),
1203 ..rules()
1204 };
1205 fs::create_dir_all(root.join("audio/music")).unwrap();
1206 fs::write(root.join("audio/music/a.ogg"), "a").unwrap();
1207 fs::write(root.join("audio/music/notes.md"), "doc").unwrap();
1208 fs::create_dir_all(root.join("voice/en")).unwrap();
1209 fs::write(root.join("voice/en/voice.ron"), "m").unwrap();
1210 fs::write(root.join("voice/en/take.wav"), "source").unwrap();
1211 let opt = FakeWasmOpt(|_: &Path, _: &Path| Ok(false));
1212 package(&opt, &rules, &root, &root.join("target"), &debug(), &[]).unwrap();
1213 let files = current_files(&rules, &root).unwrap();
1214 assert!(files.contains("audio/music/a.ogg"), "{files:?}");
1215 assert!(files.contains("voice/en/voice.ron"), "{files:?}");
1216 assert_eq!(
1217 tree(&root.join("dist/web")),
1218 files.into_iter().collect::<Vec<_>>()
1219 );
1220 fs::remove_dir_all(&root).unwrap();
1221 }
1222
1223 #[test]
1224 fn is_under_works_out_dots_and_needs_no_files() {
1225 assert!(is_under(Path::new("/a/b/c"), Path::new("/a/b")));
1226 assert!(is_under(Path::new("/a/b"), Path::new("/a/x/../b")));
1227 assert!(!is_under(Path::new("/a/bc"), Path::new("/a/b")));
1228 assert!(!is_under(Path::new("/a"), Path::new("/a/b")));
1229 }
1230
1231 #[test]
1232 fn run_reports_cargo_build_failure() {
1233 let root = fixture("run-no-manifest");
1237 let err = run(&rules(), &root, &debug(), &[]).unwrap_err();
1238 assert!(err.contains("cargo build"), "{err}");
1239 fs::remove_dir_all(&root).unwrap();
1240 }
1241
1242 fn locked_quad_storage_sys_version(lock: &str) -> u32 {
1246 let version = lock
1247 .split("[[package]]")
1248 .find(|pkg| pkg.contains("name = \"quad-storage-sys\""))
1249 .and_then(|pkg| pkg.lines().find_map(|l| l.strip_prefix("version = \"")))
1250 .and_then(|v| v.strip_suffix('"'))
1251 .expect("quad-storage-sys is in Cargo.lock");
1252 let parts: Vec<u32> = version.split('.').map(|p| p.parse().unwrap()).collect();
1253 (parts[0] << 24) + (parts[1] << 16) + parts[2]
1254 }
1255
1256 #[test]
1257 fn locked_quad_storage_sys_version_encodes_major_minor_patch() {
1258 let lock = r#"
1259[[package]]
1260name = "quad-storage"
1261version = "9.9.9"
1262
1263[[package]]
1264name = "quad-storage-sys"
1265version = "1.2.3"
1266"#;
1267 assert_eq!(
1268 locked_quad_storage_sys_version(lock),
1269 (1 << 24) + (2 << 16) + 3
1270 );
1271 }
1272
1273 #[test]
1274 fn index_html_declares_register_plugin_before_the_bundle() {
1275 let html = shell("index.html");
1278 let declared = html
1279 .find("var register_plugin;")
1280 .expect("index.html declares `var register_plugin;`");
1281 let bundle = html
1282 .find(r#"<script src="mq_js_bundle.js">"#)
1283 .expect("index.html loads mq_js_bundle.js");
1284 assert!(
1285 declared < bundle,
1286 "declare register_plugin before the bundle"
1287 );
1288 }
1289
1290 fn identifiers_starting(text: &str, prefix: &str) -> Vec<String> {
1293 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1294 let mut found: Vec<String> = text
1295 .match_indices(prefix)
1296 .filter(|&(at, _)| !text[..at].chars().next_back().is_some_and(is_ident))
1297 .map(|(at, _)| {
1298 let rest = text[at + prefix.len()..].chars();
1299 let name: String = rest.take_while(|&c| is_ident(c)).collect();
1300 format!("{prefix}{name}")
1301 })
1302 .collect();
1303 found.sort();
1304 found.dedup();
1305 found
1306 }
1307
1308 #[test]
1309 fn identifiers_starting_finds_whole_names_once() {
1310 let text = "a.shuyi_pad_poll = f; x_shuyi_pad_no(); shuyi_pad_axis(shuyi_pad_poll())";
1311 assert_eq!(
1312 identifiers_starting(text, "shuyi_pad_"),
1313 ["shuyi_pad_axis", "shuyi_pad_poll"]
1314 );
1315 let got = identifiers_starting(text, "nope_");
1316 assert!(got.is_empty(), "{got:?}");
1317 }
1318
1319 fn number_after(text: &str, before: &str) -> u32 {
1321 let line = text
1322 .lines()
1323 .find_map(|l| l.trim().strip_prefix(before))
1324 .unwrap_or_else(|| panic!("no line starting `{before}`"));
1325 line.trim_end_matches(';').parse().unwrap()
1326 }
1327
1328 #[test]
1329 fn gamepad_js_provides_exactly_the_functions_the_app_imports() {
1330 let js = shell("gamepad.js");
1333 let provided: Vec<String> = identifiers_starting(js, "importObject.env.shuyi_pad_")
1334 .iter()
1335 .map(|name| name.replace("importObject.env.", ""))
1336 .collect();
1337 let imported: Vec<String> = identifiers_starting(PADS_WEB, "safe fn shuyi_pad_")
1338 .iter()
1339 .map(|name| name.replace("safe fn ", ""))
1340 .collect();
1341 assert!(
1342 imported.len() >= 5,
1343 "found only {imported:?} in pads/web.rs"
1344 );
1345 assert_eq!(provided, imported);
1346 }
1347
1348 #[test]
1349 fn gamepad_js_version_matches_the_app() {
1350 let js = shell("gamepad.js");
1353 assert_eq!(
1354 number_after(js, "var VERSION = "),
1355 number_after(PADS_WEB, "const PLUGIN_VERSION: u32 = ")
1356 );
1357 assert!(js.contains("version: VERSION"));
1358 assert!(js.contains(r#"name: "shuyi_gamepad""#));
1359 assert!(PADS_WEB.contains("fn shuyi_gamepad_crate_version() -> u32"));
1360 }
1361
1362 #[test]
1363 fn sound_js_provides_exactly_the_functions_the_output_imports() {
1364 let js = shell("sound.js");
1367 let provided: Vec<String> = identifiers_starting(js, "importObject.env.shuyi_sound_")
1368 .iter()
1369 .map(|name| name.replace("importObject.env.", ""))
1370 .collect();
1371 let imported: Vec<String> = identifiers_starting(SOUND_WEB, "safe fn shuyi_sound_")
1372 .iter()
1373 .map(|name| name.replace("safe fn ", ""))
1374 .collect();
1375 assert_eq!(
1376 imported.len(),
1377 4,
1378 "found {imported:?} in sound-out/src/sys.rs"
1379 );
1380 assert_eq!(provided, imported);
1381 assert!(js.contains("wasm_exports.shuyi_sound_fill("));
1383 assert!(SOUND_WEB.contains("pub extern \"C\" fn shuyi_sound_fill(frames: u32)"));
1384 }
1385
1386 #[test]
1387 fn sound_js_version_matches_the_output() {
1388 let js = shell("sound.js");
1389 assert_eq!(
1390 number_after(js, "var VERSION = "),
1391 number_after(SOUND_WEB, "const PLUGIN_VERSION: u32 = ")
1392 );
1393 assert!(js.contains("version: VERSION"));
1394 assert!(js.contains(r#"name: "shuyi_sound""#));
1395 assert!(SOUND_WEB.contains("fn shuyi_sound_crate_version() -> u32"));
1396 }
1397
1398 #[test]
1399 fn index_html_loads_sound_js_between_the_bundle_and_the_game() {
1400 let html = shell("index.html");
1401 let at = |needle: &str| {
1402 html.find(needle)
1403 .unwrap_or_else(|| panic!("index.html has `{needle}`"))
1404 };
1405 let bundle = at(r#"<script src="mq_js_bundle.js">"#);
1406 let sound = at(r#"<script src="sound.js">"#);
1407 let load = at(r#"load("%WASM%")"#);
1408 assert!(bundle < sound && sound < load);
1409 }
1410
1411 #[test]
1412 fn index_html_loads_gamepad_js_between_the_bundle_and_the_game() {
1413 let html = shell("index.html");
1416 let at = |needle: &str| {
1417 html.find(needle)
1418 .unwrap_or_else(|| panic!("index.html has `{needle}`"))
1419 };
1420 let bundle = at(r#"<script src="mq_js_bundle.js">"#);
1421 let gamepad = at(r#"<script src="gamepad.js">"#);
1422 let load = at(r#"load("%WASM%")"#);
1423 assert!(bundle < gamepad && gamepad < load);
1424 }
1425
1426 #[test]
1427 fn download_js_provides_exactly_the_function_the_app_imports() {
1428 let provided = identifiers_starting(shell("download.js"), "importObject.env.shuyi_");
1429 assert_eq!(provided, ["importObject.env.shuyi_download"]);
1430 let imported = identifiers_starting(SESSIONS_WEB, "fn shuyi_d");
1432 assert_eq!(
1433 imported,
1434 ["fn shuyi_download", "fn shuyi_download_crate_version"]
1435 );
1436 }
1437
1438 #[test]
1439 fn download_js_version_matches_the_app() {
1440 let js = shell("download.js");
1441 assert_eq!(
1442 number_after(js, "var VERSION = "),
1443 number_after(SESSIONS_WEB, "const PLUGIN_VERSION: u32 = ")
1444 );
1445 assert!(js.contains("version: VERSION"));
1446 assert!(js.contains(r#"name: "shuyi_download""#));
1447 assert!(SESSIONS_WEB.contains("fn shuyi_download_crate_version() -> u32"));
1448 }
1449
1450 #[test]
1451 fn index_html_loads_download_js_between_the_bundle_and_the_game() {
1452 let html = shell("index.html");
1453 let at = |needle: &str| {
1454 html.find(needle)
1455 .unwrap_or_else(|| panic!("index.html has `{needle}`"))
1456 };
1457 let bundle = at(r#"<script src="mq_js_bundle.js">"#);
1458 let download = at(r#"<script src="download.js">"#);
1459 let load = at(r#"load("%WASM%")"#);
1460 assert!(bundle < download && download < load);
1461 }
1462
1463 #[test]
1464 fn page_files_js_provides_exactly_the_functions_the_app_imports() {
1465 let js = shell("page-files.js");
1468 let provided: Vec<String> = identifiers_starting(js, "importObject.env.shuyi_page_")
1469 .iter()
1470 .map(|name| name.replace("importObject.env.", ""))
1471 .collect();
1472 let imported: Vec<String> = identifiers_starting(PAGE, "safe fn shuyi_page_")
1473 .iter()
1474 .map(|name| name.replace("safe fn ", ""))
1475 .collect();
1476 assert_eq!(
1477 imported,
1478 [
1479 "shuyi_page_file_count",
1480 "shuyi_page_file_path",
1481 "shuyi_page_file_text",
1482 "shuyi_page_report"
1483 ]
1484 );
1485 assert_eq!(provided, imported);
1486 }
1487
1488 #[test]
1489 fn page_files_js_version_matches_the_app() {
1490 let js = shell("page-files.js");
1491 assert_eq!(
1492 number_after(js, "var VERSION = "),
1493 number_after(PAGE, "const PLUGIN_VERSION: u32 = ")
1494 );
1495 assert!(js.contains("version: VERSION"));
1496 assert!(js.contains(r#"name: "shuyi_page_files""#));
1497 assert!(PAGE.contains("fn shuyi_page_files_crate_version() -> u32"));
1498 }
1499
1500 #[test]
1501 fn index_html_loads_page_files_js_after_sapp_jsutils_and_before_the_game() {
1502 let html = shell("index.html");
1504 let at = |needle: &str| {
1505 html.find(needle)
1506 .unwrap_or_else(|| panic!("index.html has `{needle}`"))
1507 };
1508 let jsutils = at(r#"<script src="sapp_jsutils.js">"#);
1509 let page = at(r#"<script src="page-files.js">"#);
1510 let load = at(r#"load("%WASM%")"#);
1511 assert!(jsutils < page && page < load);
1512 }
1513
1514 #[test]
1515 fn quad_storage_js_version_matches_the_locked_crate() {
1516 let lock = Path::new(env!("CARGO_MANIFEST_DIR"))
1519 .ancestors()
1520 .map(|dir| dir.join("Cargo.lock"))
1521 .find(|lock| lock.is_file())
1522 .expect("the workspace has a Cargo.lock");
1523 let lock = fs::read_to_string(lock).unwrap();
1524 let expected = format!("version: {}", locked_quad_storage_sys_version(&lock));
1525 assert!(
1526 shell("quad-storage.js")
1527 .lines()
1528 .any(|line| line.trim() == expected),
1529 "web/quad-storage.js must declare `{}`",
1530 expected.trim_end()
1531 );
1532 }
1533}