Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-build-thing/src/web_app.rs

//! **A browser web app, built without trunk** — `[[build.web_app]]`.
//!
//! # What this replaces
//!
//! gunnar-ui's console was built by `trunk build --release` on the HOST
//! (`gunnar-ui/xtask/src/build.rs`): trunk ran the host's cargo, a wasm-bindgen
//! CLI it downloads into its own cache, `wasm-opt`, and its `index.html` asset
//! pipeline. The host's rustc, target set and bindgen binary decided the bundle.
//!
//! This step builds the same bundle from the declaration:
//!
//! 1. **the cargo build runs in the declared toolchain container**
//!    ([`run_cargo_in_container`](crate::cargo_container::run_cargo_in_container),
//!    `--locked --offline`, the same road every cargo variant takes);
//! 2. **wasm-bindgen runs as a LIBRARY** ([`WebAppBindgen`]; the live impl is
//!    `LibraryBindgen`, feature `web-app`, over `wasm-bindgen-cli-support` at
//!    one pinned version). No CLI binary, nothing downloaded. The app's
//!    `Cargo.lock` must pin the same `wasm-bindgen` version, and a mismatch is
//!    refused BY NAME before anything runs — a bindgen that does not match the
//!    macro that wrote the module produces the silent blank page;
//! 3. **the page is assembled in Rust** ([`assemble_dist`]): the
//!    `<link data-trunk …>` tags trunk reads are read the same way, so a page
//!    that builds with trunk today builds here unchanged.
//!
//! Steps 2 and 3 are pure functions of bytes (the module, the page, the assets)
//! and run in the forge process; no host tool, library or compiler takes part.
//!
//! # Not done, and said so
//!
//! `data-wasm-opt` on the rust tag asks for binaryen's `wasm-opt`, which is C++
//! with no pure-Rust equivalent. It is NOT run; the build log carries a note
//! naming it, and the `_bg.wasm` is larger than trunk's by what `-Oz` removes.
//! Function is unchanged — `wasm-opt` rewrites code, not the import/export
//! surface the glue binds to.

use std::path::{Path, PathBuf};

use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};

/// **`[[build.web_app]]`** — claimed by the `kind = "wasm"` variant of the SAME
/// NAME (the iso/installer/kernel/package/wasm rule).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebAppBuild {
    /// The handle — the same name as the variant.
    pub name: String,
    /// Repo-relative directory the app lands in. Rebuilt on every run; every
    /// file in it is an artifact.
    pub out_dir: String,
    /// The page, repo-relative (`crates/<pkg>/index.html`). Asset `href`s
    /// resolve against its directory, as trunk resolves them.
    pub index: String,
}

impl WebAppBuild {
    /// The absolute output directory.
    pub fn out_dir(&self, repo_root: &Path) -> PathBuf {
        repo_root.join(&self.out_dir)
    }

    /// The absolute page path.
    pub fn index_path(&self, repo_root: &Path) -> PathBuf {
        repo_root.join(&self.index)
    }

    /// Shape-only validation.
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            bail!("web app: empty name");
        }
        for (field, v) in [("out_dir", &self.out_dir), ("index", &self.index)] {
            if v.trim().is_empty() {
                bail!("web app '{}': `{field}` is REQUIRED", self.name);
            }
            if Path::new(v).is_absolute() {
                bail!(
                    "web app '{}': `{field}` {v:?} is absolute — it must be repo-relative, so \
                     the same declaration means the same file in every checkout",
                    self.name
                );
            }
        }
        Ok(())
    }

    /// The content id of this declaration.
    pub fn content_id(&self) -> String {
        nornir_hash::sha256_hex(format!("web_app#name:{}#out:{}#index:{}", self.name, self.out_dir, self.index).as_bytes())
    }
}

// ─────────────────────────────────────────────────────────────────────────────
//  THE BINDGEN SEAM
// ─────────────────────────────────────────────────────────────────────────────

/// **wasm-bindgen, as a seam** — so the orchestration and every red arm are
/// tested with no bindgen linked.
pub trait WebAppBindgen {
    /// One line for the build log.
    fn describe(&self) -> String;
    /// The wasm-bindgen version this implementation IS. The app's lock must pin
    /// the same one.
    fn version(&self) -> &str;
    /// Write `<out_name>.js` and `<out_name>_bg.wasm` (`--target web`, no
    /// typings) into `out_dir` from the cargo module `input`.
    fn run(&self, input: &Path, out_dir: &Path, out_name: &str) -> Result<()>;
}

/// **The live bindgen** — `wasm-bindgen-cli-support`, linked, at
/// [`LibraryBindgen::VERSION`]. The same library the `wasm-bindgen` CLI is a
/// thin `main` over.
#[cfg(feature = "web-app")]
pub struct LibraryBindgen;

#[cfg(feature = "web-app")]
impl LibraryBindgen {
    /// The version pinned in this crate's manifest (`=0.2.128`). Stated here as
    /// well because the library does not export its own version; the test
    /// `the_linked_bindgen_is_the_version_it_claims` holds the two equal
    /// against `Cargo.lock`.
    pub const VERSION: &'static str = "0.2.128";
}

#[cfg(feature = "web-app")]
impl WebAppBindgen for LibraryBindgen {
    fn describe(&self) -> String {
        format!("wasm-bindgen-cli-support {} (linked library, --target web, no typings, no name/producers sections)", Self::VERSION)
    }
    fn version(&self) -> &str {
        Self::VERSION
    }
    fn run(&self, input: &Path, out_dir: &Path, out_name: &str) -> Result<()> {
        let mut b = wasm_bindgen_cli_support::Bindgen::new();
        // No `name` and no `producers` custom section in the bound module: they
        // carry no semantics, and trunk never shipped them (its `wasm-opt` pass
        // strips both). MEASURED on gunnar-ui-web 2026-09-25: bound WITH them
        // the module was 12 108 640 B; stripping the two (host `wasm-opt
        // --strip-debug --strip-producers`, analysis only) left 2 494 555 B,
        // against trunk's `-Oz` 2 340 569 B. What is lost is function names in a
        // browser's stack trace, which trunk's release bundle did not have either.
        b.input_path(input).out_name(out_name).typescript(false).remove_name_section(true).remove_producers_section(true);
        b.web(true).map_err(|e| anyhow!("wasm-bindgen: --target web: {e:#}"))?;
        b.generate(out_dir).map_err(|e| anyhow!("wasm-bindgen over {}: {e:#}", input.display()))
    }
}

/// **The `wasm-bindgen` version a `Cargo.lock` pins**, or `None` when it pins
/// none. More than one is refused: a lock resolving two copies of the macro
/// crate has no single bindgen that matches it.
pub fn locked_bindgen_version(lock_text: &str) -> Result<Option<String>> {
    let doc: toml::Value = toml::from_str(lock_text).map_err(|e| anyhow!("Cargo.lock is not TOML: {e}"))?;
    let mut found: Vec<String> = Vec::new();
    for p in doc.get("package").and_then(|p| p.as_array()).into_iter().flatten() {
        if p.get("name").and_then(|n| n.as_str()) == Some("wasm-bindgen") {
            if let Some(v) = p.get("version").and_then(|v| v.as_str()) {
                if !found.iter().any(|f| f == v) {
                    found.push(v.to_string());
                }
            }
        }
    }
    match found.len() {
        0 => Ok(None),
        1 => Ok(found.pop()),
        _ => bail!("Cargo.lock resolves wasm-bindgen at {} versions ({}); one bindgen cannot match both", found.len(), found.join(", ")),
    }
}

// ─────────────────────────────────────────────────────────────────────────────
//  THE PAGE
// ─────────────────────────────────────────────────────────────────────────────

/// What [`assemble_dist`] produced.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Assembled {
    /// Every file in the output directory, sorted.
    pub files: Vec<PathBuf>,
    /// Things the page asked for that this step does not do, one line each.
    pub notes: Vec<String>,
}

/// One `<link …>` tag's attributes, in order.
fn parse_attrs(tag: &str) -> Vec<(String, Option<String>)> {
    // `tag` is the text between `<link` and the closing `>`.
    let b = tag.as_bytes();
    let mut i = 0;
    let mut out = Vec::new();
    while i < b.len() {
        while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b'/') {
            i += 1;
        }
        let start = i;
        while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'=' && b[i] != b'/' {
            i += 1;
        }
        if start == i {
            break;
        }
        let name = tag[start..i].to_ascii_lowercase();
        if i < b.len() && b[i] == b'=' {
            i += 1;
            let quote = b.get(i).copied();
            let value = if quote == Some(b'"') || quote == Some(b'\'') {
                let q = quote.unwrap();
                i += 1;
                let vs = i;
                while i < b.len() && b[i] != q {
                    i += 1;
                }
                let v = tag[vs..i].to_string();
                i += 1;
                v
            } else {
                let vs = i;
                while i < b.len() && !b[i].is_ascii_whitespace() {
                    i += 1;
                }
                tag[vs..i].to_string()
            };
            out.push((name, Some(value)));
        } else {
            out.push((name, None));
        }
    }
    out
}

fn attr<'a>(attrs: &'a [(String, Option<String>)], name: &str) -> Option<&'a str> {
    attrs.iter().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref())
}

fn short_hash(bytes: &[u8]) -> String {
    nornir_hash::sha256_hex(bytes)[..16].to_string()
}

fn integrity(bytes: &[u8]) -> String {
    use base64::Engine as _;
    use sha2::{Digest as _, Sha384};
    format!("sha384-{}", base64::engine::general_purpose::STANDARD.encode(Sha384::digest(bytes)))
}

/// `console.css` → `console-<hash>.css`.
fn hashed_name(file: &Path, bytes: &[u8]) -> String {
    let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
    match file.extension() {
        Some(ext) => format!("{stem}-{}.{}", short_hash(bytes), ext.to_string_lossy()),
        None => format!("{stem}-{}", short_hash(bytes)),
    }
}

/// **Assemble the page and its assets into `dist`.**
///
/// `js` and `wasm` are the bindgen outputs, already inside `dist`. The page at
/// `index` is rewritten tag by tag: every `<link data-trunk …>` is replaced in
/// place (see [`WebAppBuild`]'s table on the variant docs), HTML comments are
/// left untouched, every other byte is kept.
pub fn assemble_dist(index: &Path, js: &Path, wasm: &Path, dist: &Path) -> Result<Assembled> {
    let page = std::fs::read_to_string(index).with_context(|| format!("read the page {}", index.display()))?;
    let base = index.parent().unwrap_or(Path::new("."));
    let name_of = |p: &Path| p.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
    let (js_name, wasm_name) = (name_of(js), name_of(wasm));
    let js_bytes = std::fs::read(js).with_context(|| format!("read the glue {}", js.display()))?;
    let wasm_bytes = std::fs::read(wasm).with_context(|| format!("read the module {}", wasm.display()))?;

    let mut out = String::with_capacity(page.len() + 1024);
    let mut notes = Vec::new();
    let mut rust_tags = 0usize;
    let mut rest = page.as_str();
    loop {
        let next_comment = rest.find("<!--");
        let next_link = rest.find("<link");
        match (next_comment, next_link) {
            (Some(c), l) if l.is_none_or(|l| c < l) => {
                let end = rest[c..].find("-->").map(|e| c + e + 3).unwrap_or(rest.len());
                out.push_str(&rest[..end]);
                rest = &rest[end..];
            }
            (_, Some(l)) => {
                out.push_str(&rest[..l]);
                let close = rest[l..].find('>').ok_or_else(|| anyhow!("{}: an unterminated <link", index.display()))? + l;
                let tag = &rest[l..=close];
                let attrs = parse_attrs(&tag[5..tag.len() - 1]);
                rest = &rest[close + 1..];
                if !attrs.iter().any(|(n, _)| n == "data-trunk") {
                    out.push_str(tag);
                    continue;
                }
                let rel = attr(&attrs, "rel").unwrap_or("");
                let allowed: &[&str] = match rel {
                    "rust" => &["data-trunk", "rel", "data-wasm-opt"],
                    "css" | "icon" | "copy-file" => &["data-trunk", "rel", "href"],
                    other => bail!(
                        "{}: `<link data-trunk rel=\"{other}\">` is not a tag this step reads. It \
                         reads rust, css, icon and copy-file — the four this constellation's pages \
                         use; a tag it cannot honour is refused rather than dropped from the page",
                        index.display()
                    ),
                };
                if let Some((bad, _)) = attrs.iter().find(|(n, _)| !allowed.contains(&n.as_str())) {
                    bail!(
                        "{}: `{bad}` on `<link data-trunk rel=\"{rel}\">` is a trunk option this step \
                         does not implement; refused rather than ignored",
                        index.display()
                    );
                }
                match rel {
                    "rust" => {
                        rust_tags += 1;
                        if let Some(level) = attr(&attrs, "data-wasm-opt") {
                            notes.push(format!(
                                "data-wasm-opt=\"{level}\" asks for binaryen's wasm-opt (C++, no pure-Rust \
                                 equivalent) — NOT run; {wasm_name} is unoptimised ({} bytes)",
                                wasm_bytes.len()
                            ));
                        }
                        out.push_str(&format!(
                            "\n<script type=\"module\">\nimport init, * as bindings from '/{js_name}';\n\
                             const wasm = await init({{ module_or_path: '/{wasm_name}' }});\n\n\n\
                             window.wasmBindings = bindings;\n\n\n\
                             dispatchEvent(new CustomEvent(\"TrunkApplicationStarted\", {{detail: {{wasm}}}}));\n\n\
                             </script>"
                        ));
                    }
                    _ => {
                        let href = attr(&attrs, "href").ok_or_else(|| anyhow!("{}: `<link data-trunk rel=\"{rel}\">` has no href", index.display()))?;
                        let src = base.join(href);
                        let bytes = std::fs::read(&src).with_context(|| format!("{}: rel=\"{rel}\" href=\"{href}\"", index.display()))?;
                        let name = if rel == "copy-file" { name_of(&src) } else { hashed_name(&src, &bytes) };
                        let dst = dist.join(&name);
                        if dst.exists() {
                            bail!("{}: two assets land at {} — one would silently replace the other", index.display(), dst.display());
                        }
                        std::fs::write(&dst, &bytes).with_context(|| format!("write {}", dst.display()))?;
                        match rel {
                            "css" => out.push_str(&format!("<link rel=\"stylesheet\" href=\"/{name}\" integrity=\"{}\"/>", integrity(&bytes))),
                            "icon" => out.push_str(&format!("<link rel=\"icon\" href=\"/{name}\" integrity=\"{}\"/>", integrity(&bytes))),
                            _ => {}
                        }
                    }
                }
            }
            (_, None) => {
                out.push_str(rest);
                break;
            }
        }
    }
    if rust_tags != 1 {
        bail!(
            "{}: {rust_tags} `<link data-trunk rel=\"rust\">` tag(s) — the page must name exactly one, \
             it is where the module is started",
            index.display()
        );
    }
    let preload = format!(
        "<link rel=\"modulepreload\" href=\"/{js_name}\" crossorigin=\"anonymous\" integrity=\"{}\">\
         <link rel=\"preload\" href=\"/{wasm_name}\" crossorigin=\"anonymous\" integrity=\"{}\" as=\"fetch\" type=\"application/wasm\">",
        integrity(&js_bytes),
        integrity(&wasm_bytes)
    );
    let head_end = out.rfind("</head>").ok_or_else(|| anyhow!("{}: no </head>", index.display()))?;
    out.insert_str(head_end, &preload);
    let page_out = dist.join("index.html");
    std::fs::write(&page_out, out).with_context(|| format!("write {}", page_out.display()))?;

    let mut files: Vec<PathBuf> = std::fs::read_dir(dist)
        .with_context(|| format!("list {}", dist.display()))?
        .filter_map(|e| e.ok().map(|e| e.path()))
        .filter(|p| p.is_file())
        .collect();
    files.sort();
    Ok(Assembled { files, notes })
}

// ─────────────────────────────────────────────────────────────────────────────
//  THE STEP
// ─────────────────────────────────────────────────────────────────────────────

/// What [`build_web_app_in_container`] hands back.
#[derive(Debug)]
pub struct WebAppOutcome {
    /// The build outcome: every file in the output directory is an artifact.
    pub outcome: crate::provenance::BuildOutcome,
    /// Lines for the build log (what the page asked for that was not done).
    pub notes: Vec<String>,
    /// The bindgen that ran, in one line.
    pub bindgen: String,
}

/// **Build a web app**: the variant's cargo build in the declared toolchain
/// container, then bindgen (the library), then the page.
///
/// Refused by name, before the container starts: no `[build.toolchain]`, a web
/// app that does not validate, a lock pinning a different wasm-bindgen than
/// `bindgen` is.
#[allow(clippy::too_many_arguments)]
pub fn build_web_app_in_container(
    set: &crate::variant::BuildVariantSet,
    name: &str,
    repo_root: &Path,
    target_dir: &Path,
    registry: Option<&Path>,
    tc: &crate::cargo_container::CargoToolchain,
    runner: &dyn crate::cargo_container::CargoContainerRunner,
    bindgen: &dyn WebAppBindgen,
) -> Result<WebAppOutcome> {
    use crate::cargo_container::{run_cargo_in_container, CargoInvocation, RunEnv};
    use crate::provenance::{BuildOutcome, BuildProvenance, HostInfo, Outcome, Toolchain};

    tc.validate()?;
    let variant = set.require(name)?;
    set.validate(variant)?;
    let app = set.web_app(name).ok_or_else(|| anyhow!("variant '{name}' declares no [[build.web_app]]"))?;
    set.validate_web_app(app)?;
    tc.check_target(name, variant.target.as_deref())?;
    let index = app.index_path(repo_root);
    if !index.is_file() {
        bail!("web app '{name}': the page {} is not a file", index.display());
    }

    let lock = repo_root.join("Cargo.lock");
    let lock_text = std::fs::read_to_string(&lock).with_context(|| format!("web app '{name}': read {}", lock.display()))?;
    match locked_bindgen_version(&lock_text)? {
        Some(v) if v == bindgen.version() => {}
        Some(v) => bail!(
            "web app '{name}': {} pins wasm-bindgen {v}, and this forge's bindgen is {} ({}). The \
             two must be the same version — a module written by one macro and bound by another \
             bindgen is the page that loads and paints nothing. Move the app's lock to {}, or \
             build a forge linking {v}",
            lock.display(),
            bindgen.version(),
            bindgen.describe(),
            bindgen.version()
        ),
        None => bail!("web app '{name}': {} resolves no wasm-bindgen — there is nothing to bind", lock.display()),
    }

    // The repo's `rust-toolchain.toml` names the HOST's toolchain; inside the
    // image the declaration's `rust_version` is the toolchain, and rustup
    // would otherwise try to download the file's channel with no network.
    let mut env = RunEnv::new();
    if !tc.rust_version.is_empty() {
        env.insert("RUSTUP_TOOLCHAIN".to_string(), tc.rust_version.clone());
    }
    let run = run_cargo_in_container(
        tc,
        &CargoInvocation {
            label: name.to_string(),
            repo_root: repo_root.to_path_buf(),
            target_dir: target_dir.to_path_buf(),
            registry: registry.map(Path::to_path_buf),
            cargo_args: variant.cargo_args(),
            cfg: variant.cfg.clone(),
            env,
            extra_binds: Vec::new(),
        },
        runner,
    )?;
    if !run.success() {
        bail!(
            "web app '{name}': the cargo build in {} exited {:?}{}",
            tc.image_ref(),
            run.outcome.exit_code,
            run.parsed.errors.first().map(|e| format!(" — {}", e.trim())).unwrap_or_default()
        );
    }
    // A bin target is reported by cargo's own stream (its `executable` is the
    // `.wasm`); a cdylib is not, and is found where cargo writes it.
    let raw = run
        .parsed
        .artifacts
        .iter()
        .find(|p| p.extension().is_some_and(|e| e == "wasm"))
        .cloned()
        .unwrap_or_else(|| crate::wasm::cargo_wasm_path(variant, target_dir));
    let raw_bytes = std::fs::read(&raw).with_context(|| {
        format!("web app '{name}': the cargo build exited 0 but its module {} is not readable", raw.display())
    })?;

    let dist = app.out_dir(repo_root);
    if dist.exists() {
        std::fs::remove_dir_all(&dist).with_context(|| format!("clear {}", dist.display()))?;
    }
    std::fs::create_dir_all(&dist).with_context(|| format!("create {}", dist.display()))?;
    let package = variant.package.as_deref().unwrap_or(&variant.name);
    let out_name = format!("{package}-{}", short_hash(&raw_bytes));
    bindgen.run(&raw, &dist, &out_name)?;
    let js = dist.join(format!("{out_name}.js"));
    let wasm = dist.join(format!("{out_name}_bg.wasm"));
    for f in [&js, &wasm] {
        match std::fs::metadata(f) {
            Ok(m) if m.len() > 0 => {}
            _ => bail!("web app '{name}': bindgen returned Ok and {} is missing or empty", f.display()),
        }
    }
    let assembled = assemble_dist(&index, &js, &wasm, &dist)?;

    let record = BuildProvenance::capture(
        variant,
        &repo_root.file_name().and_then(|s| s.to_str()).unwrap_or("repo").to_string(),
        repo_root,
        Toolchain { rustc: run.parsed.rustc.clone(), host: run.parsed.host.clone(), cargo: run.parsed.cargo.clone(), ..Default::default() }
            .with_container_image(Some(tc.image.clone()), (!tc.image_sha256.is_empty()).then(|| tc.image_sha256.clone())),
        HostInfo::detect(),
        Outcome::Success,
        &assembled.files,
    )?;
    Ok(WebAppOutcome {
        outcome: BuildOutcome { variant: name.to_string(), artifacts: assembled.files, attestation: record.attest()?, success: true },
        notes: assembled.notes,
        bindgen: bindgen.describe(),
    })
}

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

    fn scratch(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("nbt-webapp-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    const PAGE: &str = r#"<!DOCTYPE html>
<html><head>
<!-- a comment naming <link data-trunk rel="sass" href="x"> is left alone -->
<link data-trunk rel="rust" data-wasm-opt="z" />
<link data-trunk rel="css" href="styles/app.css" />
<link data-trunk rel="icon" href="styles/fav.svg" />
<link data-trunk rel="copy-file" href="assets/pic.webp" />
<link rel="author" href="/humans.txt">
</head><body></body></html>
"#;

    fn app(dir: &Path, page: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
        let src = dir.join("src");
        std::fs::create_dir_all(src.join("styles")).unwrap();
        std::fs::create_dir_all(src.join("assets")).unwrap();
        std::fs::write(src.join("index.html"), page).unwrap();
        std::fs::write(src.join("styles/app.css"), b"body{}").unwrap();
        std::fs::write(src.join("styles/fav.svg"), b"<svg/>").unwrap();
        std::fs::write(src.join("assets/pic.webp"), b"RIFF").unwrap();
        let dist = dir.join("dist");
        std::fs::create_dir_all(&dist).unwrap();
        let js = dist.join("app-0123456789abcdef.js");
        let wasm = dist.join("app-0123456789abcdef_bg.wasm");
        std::fs::write(&js, b"export default 1;").unwrap();
        std::fs::write(&wasm, b"\0asm\x01\0\0\0").unwrap();
        (src.join("index.html"), js, wasm, dist)
    }

    #[test]
    fn the_page_is_rewritten_the_way_trunk_rewrites_it() {
        let d = scratch("page");
        let (index, js, wasm, dist) = app(&d, PAGE);
        let a = assemble_dist(&index, &js, &wasm, &dist).expect("assembles");
        let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
        assert!(!html.contains("<link data-trunk rel=\"rust\""), "{html}");
        assert!(html.contains("import init, * as bindings from '/app-0123456789abcdef.js';"), "{html}");
        assert!(html.contains("module_or_path: '/app-0123456789abcdef_bg.wasm'"), "{html}");
        assert!(html.contains("TrunkApplicationStarted"), "the page's no-wasm fallback listens for it: {html}");
        let css = format!("app-{}.css", &nornir_hash::sha256_hex(b"body{}")[..16]);
        assert!(html.contains(&format!("<link rel=\"stylesheet\" href=\"/{css}\" integrity=\"sha384-")), "{html}");
        assert!(html.contains("<link rel=\"icon\" href=\"/fav-"), "{html}");
        assert!(html.contains("<link rel=\"author\" href=\"/humans.txt\">"), "a plain link is kept: {html}");
        assert!(html.contains("<link data-trunk rel=\"sass\" href=\"x\">"), "a comment is kept verbatim: {html}");
        let preload = html.find("rel=\"modulepreload\"").expect("modulepreload");
        assert!(preload < html.find("</head>").unwrap());
        let names: Vec<String> = a.files.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
        assert!(names.contains(&"pic.webp".to_string()) && names.contains(&css) && names.contains(&"index.html".to_string()), "{names:?}");
        assert_eq!(a.notes.len(), 1, "the unrun wasm-opt is named: {:?}", a.notes);
        assert!(a.notes[0].contains("NOT run"));
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn a_trunk_tag_this_step_cannot_honour_is_refused_not_dropped() {
        let d = scratch("sass");
        let (index, js, wasm, dist) = app(&d, &PAGE.replace("rel=\"css\"", "rel=\"sass\""));
        let e = format!("{:#}", assemble_dist(&index, &js, &wasm, &dist).unwrap_err());
        assert!(e.contains("rel=\"sass\"") && e.contains("refused"), "{e}");
        let d = scratch("opt");
        let (index, js, wasm, dist) = app(&d, &PAGE.replace("data-wasm-opt=\"z\"", "data-cargo-features=\"x\""));
        let e = format!("{:#}", assemble_dist(&index, &js, &wasm, &dist).unwrap_err());
        assert!(e.contains("data-cargo-features"), "{e}");
    }

    #[test]
    fn a_page_must_start_exactly_one_module() {
        let d = scratch("norust");
        let (index, js, wasm, dist) = app(&d, &PAGE.replace("<link data-trunk rel=\"rust\" data-wasm-opt=\"z\" />", ""));
        let e = format!("{:#}", assemble_dist(&index, &js, &wasm, &dist).unwrap_err());
        assert!(e.contains("0 `<link data-trunk rel=\"rust\">`"), "{e}");
    }

    #[test]
    fn the_locked_bindgen_is_read_and_two_are_refused() {
        let one = "version = 3\n[[package]]\nname = \"wasm-bindgen\"\nversion = \"0.2.128\"\n[[package]]\nname = \"serde\"\nversion = \"1\"\n";
        assert_eq!(locked_bindgen_version(one).unwrap().as_deref(), Some("0.2.128"));
        assert_eq!(locked_bindgen_version("version = 3\n").unwrap(), None);
        let two = format!("{one}[[package]]\nname = \"wasm-bindgen\"\nversion = \"0.2.100\"\n");
        assert!(locked_bindgen_version(&two).is_err());
    }

    /// **The whole step over fakes**: the container "builds" (writes the
    /// module where cargo would), the bindgen "binds", the page is assembled,
    /// and every file in the output directory is an artifact. Then the two
    /// refusals that must come BEFORE anything runs: a lock pinning another
    /// wasm-bindgen, and no toolchain image pin.
    #[test]
    fn the_step_runs_container_then_bindgen_then_page_and_refuses_a_foreign_bindgen() {
        use crate::cargo_container::{CargoContainerRunner, CargoRunOutcome, CargoRunRequest, CargoToolchain, CARGO_SENTINEL, HOST_SENTINEL, RUSTC_SENTINEL};
        use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet, Profile};
        use std::cell::RefCell;

        struct Ctr {
            module: PathBuf,
            seen: RefCell<Vec<String>>,
        }
        impl CargoContainerRunner for Ctr {
            fn run(&self, req: &CargoRunRequest) -> Result<CargoRunOutcome> {
                self.seen.borrow_mut().push(req.script.clone());
                std::fs::create_dir_all(self.module.parent().unwrap())?;
                std::fs::write(&self.module, b"\0asm\x01\0\0\0cargo's module")?;
                Ok(CargoRunOutcome {
                    exit_code: Some(0),
                    stdout: vec![format!("{RUSTC_SENTINEL}rustc 1.97.1 (x)"), format!("{CARGO_SENTINEL}cargo 1.97.1"), format!("{HOST_SENTINEL}x86_64-unknown-linux-gnu")],
                    stderr: vec![],
                })
            }
        }
        struct Bg(&'static str);
        impl WebAppBindgen for Bg {
            fn describe(&self) -> String {
                "fake".into()
            }
            fn version(&self) -> &str {
                self.0
            }
            fn run(&self, input: &Path, out_dir: &Path, out_name: &str) -> Result<()> {
                assert!(input.is_file());
                std::fs::write(out_dir.join(format!("{out_name}.js")), b"export default 1;")?;
                std::fs::write(out_dir.join(format!("{out_name}_bg.wasm")), b"\0asm bound")?;
                Ok(())
            }
        }

        let root = scratch("step");
        let target = root.join("target");
        let web = root.join("crates/console");
        let (index, ..) = app(&web, PAGE);
        std::fs::rename(&index, web.join("index.html")).unwrap();
        for d in ["styles", "assets"] {
            std::fs::rename(web.join("src").join(d), web.join(d)).unwrap();
        }
        std::fs::write(root.join("Cargo.lock"), "version = 3\n[[package]]\nname = \"wasm-bindgen\"\nversion = \"0.2.128\"\n").unwrap();
        std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
        let set = BuildVariantSet {
            variants: vec![BuildVariant {
                name: "console".into(),
                package: Some("console".into()),
                bin: Some("console".into()),
                kind: ArtifactKind::Wasm,
                target: Some("wasm32-unknown-unknown".into()),
                profile: Profile::Release,
                ..BuildVariant::default()
            }],
            web_apps: vec![WebAppBuild { name: "console".into(), out_dir: "crates/console/dist".into(), index: "crates/console/index.html".into() }],
            ..BuildVariantSet::default()
        };
        let tc = CargoToolchain {
            image_sha256: "3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143".into(),
            rust_version: "1.97.1".into(),
            targets: vec!["wasm32-unknown-unknown".into()],
            ..CargoToolchain::default()
        };
        let ctr = Ctr { module: target.join("wasm32-unknown-unknown/release/console.wasm"), seen: RefCell::new(vec![]) };
        let w = build_web_app_in_container(&set, "console", &root, &target, None, &tc, &ctr, &Bg("0.2.128")).expect("the step runs");
        let script = &ctr.seen.borrow()[0];
        assert!(script.contains("export RUSTUP_TOOLCHAIN='1.97.1'"), "the image's toolchain, not rust-toolchain.toml's: {script}");
        assert!(script.contains("'--offline'") && script.contains("'--locked'"), "{script}");
        let names: Vec<String> = w.outcome.artifacts.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
        assert!(names.contains(&"index.html".to_string()) && names.iter().any(|n| n.starts_with("console-") && n.ends_with("_bg.wasm")), "{names:?}");
        assert!(w.outcome.success && w.notes.iter().any(|n| n.contains("wasm-opt")));

        let e = format!("{:#}", build_web_app_in_container(&set, "console", &root, &target, None, &tc, &ctr, &Bg("0.2.100")).unwrap_err());
        assert!(e.contains("pins wasm-bindgen 0.2.128") && e.contains("0.2.100"), "{e}");
        let unpinned = CargoToolchain { image_sha256: String::new(), ..tc.clone() };
        assert!(build_web_app_in_container(&set, "console", &root, &target, None, &unpinned, &ctr, &Bg("0.2.128")).is_err());
        assert_eq!(ctr.seen.borrow().len(), 1, "a refused step started a container");
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn a_bin_module_keeps_its_name_and_a_cdylib_is_renamed() {
        use crate::variant::{ArtifactKind, BuildVariant, Profile};
        let v = BuildVariant {
            name: "w".into(),
            package: Some("gunnar-ui-web".into()),
            bin: Some("gunnar-ui-web".into()),
            kind: ArtifactKind::Wasm,
            target: Some("wasm32-unknown-unknown".into()),
            profile: Profile::Release,
            ..BuildVariant::default()
        };
        let t = Path::new("/t");
        assert_eq!(crate::wasm::cargo_wasm_path(&v, t), PathBuf::from("/t/wasm32-unknown-unknown/release/gunnar-ui-web.wasm"));
        let lib = BuildVariant { bin: None, profile: Profile::Dev, package: Some("my-guest".into()), ..v };
        assert_eq!(crate::wasm::cargo_wasm_path(&lib, t), PathBuf::from("/t/wasm32-unknown-unknown/debug/my_guest.wasm"));
    }

    #[test]
    fn a_declaration_must_be_repo_relative() {
        let ok = WebAppBuild { name: "w".into(), out_dir: "crates/w/dist".into(), index: "crates/w/index.html".into() };
        ok.validate().unwrap();
        for bad in [
            WebAppBuild { out_dir: "/abs".into(), ..ok.clone() },
            WebAppBuild { index: "".into(), ..ok.clone() },
            WebAppBuild { name: " ".into(), ..ok.clone() },
        ] {
            assert!(bad.validate().is_err(), "{bad:?}");
        }
        assert_ne!(ok.content_id(), WebAppBuild { index: "x/index.html".into(), ..ok.clone() }.content_id());
    }

    /// The two schema-5 keys survive the ONE emitter and the ONE loader, ask for
    /// schema 5, and a web app claimed by the wrong kind is refused by name.
    #[test]
    fn a_signed_web_app_round_trips_and_asks_for_schema_5() {
        use crate::sign::SignSpec;
        use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet, Profile};
        let mut set = BuildVariantSet {
            variants: vec![BuildVariant {
                name: "console".into(),
                package: Some("console".into()),
                kind: ArtifactKind::Wasm,
                target: Some("wasm32-unknown-unknown".into()),
                profile: Profile::Release,
                sign: Some(SignSpec::default()),
                ..BuildVariant::default()
            }],
            web_apps: vec![WebAppBuild { name: "console".into(), out_dir: "crates/console/dist".into(), index: "crates/console/index.html".into() }],
            ..BuildVariantSet::default()
        };
        let (need, why) = crate::schema::required_by(&set);
        assert_eq!(need, 5, "{why:?}");
        assert!(why.iter().any(|w| w.contains("sign")) && why.iter().any(|w| w.contains("[[build.web_app]]")), "{why:?}");
        let text = crate::emit::recipe_toml("# test\n", &set).unwrap();
        assert!(text.contains("schema = 5") && text.contains("[[build.web_app]]") && text.contains("purpose = \"plugin\""), "{text}");
        let back = BuildVariantSet::from_toml_str(&text).unwrap();
        assert_eq!(back.web_apps, set.web_apps);
        assert_eq!(back.variants[0].sign, set.variants[0].sign);
        set.validate_all().unwrap();

        set.variants[0].kind = ArtifactKind::Exe;
        let e = format!("{:#}", set.validate_web_app(&set.web_apps[0]).unwrap_err());
        assert!(e.contains("not 'wasm'"), "{e}");
    }

    #[cfg(feature = "web-app")]
    #[test]
    fn the_linked_bindgen_is_the_version_it_claims() {
        let lock = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.lock")).expect("edda's Cargo.lock");
        let doc: toml::Value = toml::from_str(&lock).unwrap();
        let v: Vec<&str> = doc["package"]
            .as_array()
            .unwrap()
            .iter()
            .filter(|p| p["name"].as_str() == Some("wasm-bindgen-cli-support"))
            .filter_map(|p| p["version"].as_str())
            .collect();
        assert_eq!(v, vec![LibraryBindgen::VERSION], "LibraryBindgen::VERSION does not name the linked library");
    }
}