Skip to main content

shuyi_cli/
web.rs

1//! `web [--release] [--<feature>...]`: builds the game's binary
2//! ([`crate::project::Web`]) for `wasm32-unknown-unknown` and packages it
3//! with the web shell into the project's `web.dist` folder: the page
4//! (`web/index.html`, with the game's title and binary written in), the
5//! vendored JS loaders (`web/mq_js_bundle.js`, and `web/sapp_jsutils.js`
6//! with `web/quad-storage.js` for `localStorage`) and the engine's own
7//! plugins (`web/gamepad.js` for controllers, `web/page-files.js` for the
8//! files the page hands the game, and the rest). The shell is built into this tool; see
9//! `web/README.md`, which also says how a game's binary gets the link
10//! argument these plugins need. Each `--<feature>` turns on a feature of
11//! the game's package that `web.features` lists (a debug build's tools,
12//! say). The game's music tracks (`.ogg` in `web.music`, not embedded) are
13//! copied beside the page into a folder of the same name, which exists
14//! even when there are none; its voice clips (`web.voice`, `.ogg` and
15//! `.ron`) are copied the same way when there is such a folder.
16//!
17//! Every build also writes `NOTICES.txt` beside the page ([`notices`]):
18//! the crates compiled in, the shell's files (`SHELL_NOTICES`) and the
19//! game's own license texts.
20//!
21//! A build is made whole in a staging folder beside `web.dist`, then
22//! replaces everything in `web.dist`, so nothing of an older build stays
23//! and a failed build leaves the last one as it was. The folder itself
24//! stays, so a server running in it keeps serving. A `web.dist` that holds
25//! the project, a source of the build or a repository is refused.
26
27use 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
37/// The shell's files: name and contents. `index.html` has `%TITLE%` and
38/// `%WASM%` in it, for the game's title and binary.
39const 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/// Where a file of [`SHELL`] comes from, for `NOTICES.txt`.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52enum Origin {
53    /// The engine's own code, under the engine's licenses.
54    Engine,
55    /// Vendored from another work: its row of [`notices::FILES`].
56    Vendored,
57}
58
59/// Each file of [`SHELL`], by name, and where it comes from.
60const 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
71/// The row of [`notices::FILES`] of the shell's file `name`.
72fn 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
78/// The shell's files as works of `NOTICES.txt`, in the shell's order.
79pub 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
96/// A file of [`SHELL`] as `dist` gets it: a vendored script starts with
97/// a `/*!` line naming its work and pointing at `NOTICES.txt` (a comment
98/// before `"use strict";` keeps it strict, and `/*!` survives
99/// minifiers); the engine's own files are as committed.
100fn 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
113/// The target the game is built for.
114pub(crate) const TARGET: &str = "wasm32-unknown-unknown";
115
116/// Parsed `web` arguments.
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct Options {
119    /// Whether to build with `--release` (and run `wasm-opt` afterwards).
120    pub release: bool,
121    /// The features to turn on, in the order given.
122    pub features: Vec<String>,
123}
124
125/// The usage line, with `command` as the tool's name.
126pub fn usage(rules: &Web, command: &str) -> String {
127    let mut out = format!("usage: {command} web [--release]");
128    for feature in &rules.features {
129        // Writing to a `String` can't fail.
130        let _ = write!(out, " [--{feature}]");
131    }
132    out
133}
134
135/// Parses the arguments after `web`: `--release` and a `--<feature>` for
136/// each feature `rules` list, each at most once, in any order. The error
137/// is the usage line.
138pub 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
161/// The `cargo` arguments that build the wasm binary for `options`.
162pub(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
175/// Cargo's build profile directory name for a given release flag.
176fn profile_dir(release: bool) -> &'static str {
177    if release { "release" } else { "debug" }
178}
179
180/// Where cargo places the wasm binary for the given options, in its build
181/// folder `target`.
182fn 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
189/// The folder cargo builds the project at `root` into: `CARGO_TARGET_DIR`
190/// when it is set (from `root`, if relative), else `target` beside the
191/// workspace's manifest, since a project may be one folder of a bigger
192/// workspace (`cargo locate-project --workspace`), else `root/target`.
193pub 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
210/// [`target_dir`] from what it found: the variable's value and the
211/// workspace's manifest, if any.
212fn 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
225/// Builds and packages the web build, returning a one-line summary on
226/// success. `game` is what the project adds to `NOTICES.txt` (its own
227/// license texts, [`notices::game_texts`]); the crates compiled in and the
228/// shell's files are found here. A crate with no license text found is
229/// named in a warning.
230///
231/// A release build has its paths remapped, and its binary is refused if it
232/// still holds one of the build machine's ([`paths`]).
233pub 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
272/// The root of the workspace the project at `root` builds in: the folder
273/// of `cargo locate-project --workspace`'s manifest, else `root`.
274pub 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
286/// Makes the package in a staging folder and replaces what is in the dist
287/// folder with it. Split out from [`run`] so tests can exercise it (with a
288/// fake [`WasmOpt`]) without needing a real `cargo build`.
289pub(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    // Nothing is left to keep: on success its files have moved.
309    let _ = fs::remove_dir_all(&staging);
310    Ok(format!("{} ({} bytes)", dist.display(), made?))
311}
312
313/// `path` absolute with its `.` and `..` worked out, without reading the
314/// disk: what it names need not exist yet.
315fn 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
330/// Whether `path` is `folder` or is under it.
331pub(crate) fn is_under(path: &Path, folder: &Path) -> bool {
332    resolved(path).starts_with(resolved(folder))
333}
334
335/// Refuses a dist folder that replacing would destroy something in: the
336/// project, a source of the build, or a folder that is a repository or a
337/// package rather than a build's output.
338fn 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
363/// Replaces everything in `dist` with what is in `staging`. The folder
364/// itself stays, so a server running in it keeps working.
365fn 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            // A link to a folder is a folder to Windows, and a file elsewhere.
378            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
389/// Copies the wasm artifact and writes the web shell and `NOTICES.txt`
390/// (with `works`) into the empty folder `dist`, optimizing the wasm binary
391/// with `opt` first if `options.release`. Returns the binary's size.
392fn 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
434/// Whether `path` is a music track the web build ships.
435fn is_track(path: &Path) -> bool {
436    path.is_file() && path.extension().is_some_and(|ext| ext == "ogg")
437}
438
439/// Whether `path` is a voice file the web build ships.
440fn is_voice_file(path: &Path) -> bool {
441    path.extension()
442        .is_some_and(|ext| ext == "ogg" || ext == "ron")
443}
444
445/// The files a build of the project at `root` makes, by their paths in the
446/// package with `/` between folders: the shell, the binary, the notices,
447/// and the music and voice files in their folders now.
448pub(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
456/// The files of [`current_files`] that are the game's own: the music and
457/// voice files in their folders now.
458pub(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        // A missing folder is an empty one, as `copy_music` has it.
462        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
479/// The voice files under `dir`, whose package path is `prefix`.
480fn 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
496/// A folder of `shuyi.ron` as a path in the package: `./a\b/` is `a/b`.
497pub(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
506/// Makes `dst` a folder holding every `.ogg` file in `src` (none if `src`
507/// is missing), so the web build fetches tracks from it.
508fn 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
525/// Makes `dst` a copy of the voice folder `src`: its manifests (`.ron`)
526/// and clips (`.ogg`), in the same folders, so the web build fetches them
527/// from it. With no `src` there is no `dst` either: a build without voices.
528fn 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
535/// Copies the files under `src` that `keep` accepts to the same places
536/// under `dst` (made if missing), folders included. Returns how many files
537/// were copied.
538pub 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
557/// Runs `wasm-opt -Oz` on a wasm binary, abstracted so tests can substitute a
558/// fake instead of depending on a real `wasm-opt` install (which is optional
559/// and often absent — see the "skip silently" rule on [`WasmOpt::optimize`]).
560pub(crate) trait WasmOpt {
561    /// Optimizes `input` into `output` (mirroring `wasm-opt -Oz -o <output>
562    /// <input>`). `Ok(true)`/`Ok(false)` is a completed process's exit
563    /// status; `Err` means it couldn't be spawned at all (e.g. not on
564    /// `PATH`), which the caller treats the same as "skip silently".
565    fn optimize(&self, input: &Path, output: &Path) -> Result<bool, String>;
566}
567
568/// The real `wasm-opt` binary on `PATH`.
569pub(crate) struct RealWasmOpt;
570
571impl WasmOpt for RealWasmOpt {
572    /// Not covered by mutation testing (`.cargo/mutants.toml` leaves it
573    /// out): its only job is spawning an optional external tool that isn't
574    /// guaranteed to be installed anywhere tests run, so there's no way to
575    /// exercise it without one; [`package`]'s tests cover every branch of
576    /// the logic that consumes its result via a fake [`WasmOpt`] instead.
577    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
588/// Runs `opt` on `wasm_path` in place, replacing it with the optimized
589/// output on success. Does nothing (silently) if `opt` reports the tool
590/// isn't available. Whatever a failed run left behind isn't shipped.
591fn 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(_) => {} // wasm-opt not on PATH: skip silently.
601    }
602    let _ = fs::remove_file(&optimized);
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    /// The Rust side of `web/gamepad.js`: the engine's web pads.
610    const PADS_WEB: &str = include_str!("../../app/src/pads/web.rs");
611    /// The Rust side of `web/sound.js`: the engine's web sound output.
612    const SOUND_WEB: &str = include_str!("../../sound-out/src/sys.rs");
613
614    /// The Rust side of `web/download.js`: the engine's web downloads.
615    const SESSIONS_WEB: &str = include_str!("../../app/src/sessions/web.rs");
616
617    /// The Rust side of `web/page-files.js`: the files from the page.
618    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    /// A game's web build: two features, a music and a voice folder.
629    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        // The shim must wrap `AudioContext` before the bundle creates one,
667        // and retry on every key press.
668        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    /// A fresh scratch project root under `env::temp_dir()`.
680    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        // This crate's folder is one of the workspace's: its build folder
792        // is the workspace's (unless the variable moves it).
793        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        // Outside any workspace: the project's own.
801        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        // No works given: the heading alone, and no text.
852        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        // The vendored scripts are the web files of `notices::FILES`, which
902        // a test keeps equal to THIRD_PARTY_ASSETS.md.
903        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(&notices::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        // No music folder: an empty one is still made.
966        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    /// The voice folder goes along whole, manifests and clips only;
987    /// without one the package has none.
988    #[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        // A stale copy from an earlier build goes when the source is gone.
995        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        // A file named `voice` is not a voice folder.
1017        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    /// Every file under `dir`, with `/` between folders, sorted.
1091    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        // A crashed build's staging folder goes too.
1106        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        // No binary to copy.
1128        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        // A source folder under it.
1172        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        // A repository, or a package, is nobody's build output.
1180        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        // An empty directory has no Cargo.toml, so `cargo build` fails fast
1234        // (no compilation), without needing a real broken build to test the
1235        // failure path.
1236        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    /// The version `quad_storage_crate_version()` reports for the
1243    /// `quad-storage-sys` version pinned in `Cargo.lock`:
1244    /// `(major << 24) + (minor << 16) + patch`.
1245    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        // The strict-mode bundle assigns to `register_plugin`, which throws
1276        // unless the global already exists.
1277        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    /// Every name in `text` that starts with `prefix` (which isn't the
1291    /// tail of a longer identifier), sorted, once each.
1292    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    /// The number after `before` on the line of `text` that starts with it.
1320    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        // A function missing on either side only shows up in the browser,
1331        // as a warning and a pad that does nothing.
1332        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        // The loader logs a version-mismatch error unless the JS plugin's
1351        // `version` equals what `shuyi_gamepad_crate_version()` returns.
1352        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        // A function missing on either side only shows up in the browser, as a
1365        // warning and a game with no sound.
1366        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        // And what it calls of Rust's is there.
1382        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        // The plugin registers itself with the bundle's
1414        // `miniquad_add_plugin`, and must have done so before `load(...)`.
1415        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        // Beside the function, what the loader asks for its version.
1431        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        // A function missing on either side only shows up in the browser,
1466        // as a warning and a game that never sees the page's files.
1467        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        // Its strings are `sapp_jsutils.js`'s objects.
1503        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        // The loader logs a version-mismatch error unless the JS plugin's
1517        // `version` equals `quad_storage_crate_version()`.
1518        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}