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");
}
}