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

nordisk/edda

crates/nornir-pages/src/lib.rs

//! # nornir-pages — the parameterized gh-pages demo/deploy flow
//!
//! Four repos (nornir, holger, dwarves, facett) grew a near-byte-identical
//! `cargo xtask demo-wasm` / `deploy-pages` pair: compile a demo crate to release
//! wasm, run `wasm-bindgen` (+ optional `wasm-opt -Oz`) into `dist/`, write a
//! cache-busted static `index.html` and the static pages host's `.domains`/`.nojekyll`,
//! then publish `dist/` to an orphan `pages` branch by force-push. This crate holds
//! that FLOW once (LAW #5); the 100% of per-repo divergence is the eleven
//! [`PagesConfig`] knobs.
//!
//! ## The public verbs
//! - [`demo_wasm`] — build the caller's DECLARED wasm row (its recipe's `set` +
//!   row name) in the pinned `[build.toolchain]` image, bindgen stage included
//!   ([`nornir_build_thing::container_road`]), then stage the site shell. No host
//!   cargo, no host wasm-bindgen, no host wasm-opt.
//! - [`publish_dist`] — publish an ALREADY-BUILT dist (e.g. a build thing row built
//!   in the pinned container): [`stage_dist`] refuses a dist without its bundle and
//!   writes the site shell ([`write_site_shell`]), then the same squashed orphan
//!   commit + force-push as [`deploy_pages`]. Builds nothing.
//! - [`deploy_pages`] — [`demo_wasm`], then squash-publish `dist/` to the `pages`
//!   branch: the orphan commit is built **in-process via gix**
//!   ([`nornir_git::gitops::commit_dir_as_orphan_branch`]) and only the network
//!   `push --force` shells `git`.
//!
//! ## Where the line sits (L5a)
//! The generic, domain-free plumbing (`which`/`run`/`copy_dir`/`target_dir`/the
//! wasm bootstrap) lives one crate down in [`nornir_xtask_support`]; this crate is
//! only the *flow* + its config. The commit half of the publish goes through the
//! `nornir-git` leaf's pure-gix orphan-branch primitive (LAW: git ops route through
//! nornir-git, never the CLI). The ONE remaining `git` subprocess is the
//! over-the-network force-push — gix 0.84 has no send-pack (the `gix-no-send-pack`
//! law), the sanctioned exception.
//!
//! ## Error style
//! Re-uses [`nornir_xtask_support::Result`], a boxed `Send + Sync` error, so
//! `anyhow` / `Box<dyn Error>` / `Result<_, String>` callers all `?`/`From`-adapt.

use nornir_xtask_support as xs;
use std::path::{Path, PathBuf};
use std::process::Command;
use nornir_build_thing::container_road;
use nornir_build_thing::variant::{BuildVariant, BuildVariantSet};
use xs::{run, short_rev, Result};

pub mod selftest;

/// Per-repo knobs — the entirety of the divergence between the four xtasks. Every
/// field is a `&'static str` (or slice) except [`workspace_root`](Self::workspace_root),
/// which the caller computes from its own `env!("CARGO_MANIFEST_DIR")`.
#[derive(Debug, Clone)]
pub struct PagesConfig {
    /// The cargo crate that owns the wasm binary (e.g. `"nornir-demo"`). Its
    /// default-bin artifact keeps the hyphen; wasm-bindgen emits `<crate>.js` +
    /// `<crate>_bg.wasm` off that stem.
    pub demo_crate: &'static str,
    /// The eframe canvas id the wasm entry and `index.html` agree on
    /// (e.g. `"demo_canvas"`; facett uses `"facett_demo"`).
    pub canvas_id: &'static str,
    /// The named JS state-hook export lifted onto `window`
    /// (e.g. `"__nornir_state"`).
    pub state_export: &'static str,
    /// The `<title>` and the source of the loading label (its first word).
    pub title: &'static str,
    /// The `<meta name="description">` content.
    pub description: &'static str,
    /// The live URL printed in the deploy banner (e.g.
    /// `"https://pages.example/nornir"`; the caller names its own host).
    pub pages_url: &'static str,
    /// The host pinned in `.domains`: the caller's own (e.g. `"pages.example"`).
    pub pages_host: &'static str,
    /// The branch the pages host serves from (default `"pages"`).
    pub pages_branch: &'static str,
    /// The env var naming the push remote (e.g. `"NORNIR_PAGES_REMOTE"`); unset →
    /// `origin`.
    pub remote_env: &'static str,
    /// The human label in the deploy commit message
    /// (`"deploy: <commit_label> (<rev>)"`, e.g. `"nornir live web demo"`).
    pub commit_label: &'static str,
    /// READ BY NOTHING since 2026-09-30: it fed the host `cargo build` that
    /// [`demo_wasm`] no longer runs — a row's features are its recipe's. Kept only so
    /// callers built against the old shape still compile; delete it once every
    /// caller (facett, nornir, dwarves) has dropped it.
    pub extra_build_args: &'static [&'static str],
    /// The workspace root — the caller supplies
    /// `nornir_xtask_support::workspace_root_of(env!("CARGO_MANIFEST_DIR").as_ref())`.
    pub workspace_root: PathBuf,
}

impl PagesConfig {
    /// The repo display label for the loading spinner = the title's first word
    /// (`"nornir — live web demo"` → `"nornir"`).
    fn display_name(&self) -> &str {
        self.title.split_whitespace().next().unwrap_or(self.title)
    }
}

// ── command: demo-wasm ────────────────────────────────────────────────────────

/// **The caller's declared demo row and the dist its `[[build.wasm]]` cell writes** —
/// refused BY NAME when the declaration lacks the row, the row declares no cell, or
/// the cell does not produce the two files the page imports ([`bundle_files`]): a
/// page whose `import` 404s is the silent-green deploy. Pure — no engine, no I/O.
pub fn demo_row<'a>(cfg: &PagesConfig, set: &'a BuildVariantSet, variant: &str) -> Result<(&'a BuildVariant, PathBuf)> {
    let row = set.require(variant).map_err(|e| format!("{e:#}"))?;
    let cell = set
        .wasm(&row.name)
        .ok_or_else(|| format!("'{}' declares no [[build.wasm]] cell — no bindgen, no page", row.name))?;
    let produced = cell.outputs(row);
    for f in bundle_files(cfg) {
        let want = format!("{}/{f}", cell.out_dir);
        if !produced.iter().any(|p| *p == want) {
            return Err(format!("the page imports `{want}`, and '{}' produces {produced:?}", row.name).into());
        }
    }
    Ok((row, cell.out_dir(&cfg.workspace_root)))
}

/// **Build the caller's declared demo ROW in the pinned image and stage the site
/// shell.** Returns the dist dir (the row's `[[build.wasm]]` cell `out_dir`).
///
/// THE LAW: *a build thing builds in a container or a KVM, never on the host.* Until
/// 2026-09-30 this function spelled its own `cargo build --release -p <crate>
/// --target wasm32-…` on the HOST and ran the host's `wasm-bindgen` (and `wasm-opt`
/// when one happened to be on PATH) — a second writer of the demo's build, decided
/// by whatever the box had installed. Now the build is the caller's DECLARED row
/// (`variant` in `set`, the repo's recipe), materialized by
/// [`nornir_build_thing::container_road`]: the forge's argv inside
/// `[build.toolchain]`'s pinned image, then the row's bindgen stage in the same
/// image. A caller without such a row is refused by name; there is no host fallback.
pub fn demo_wasm(cfg: &PagesConfig, set: &BuildVariantSet, variant: &str) -> Result<PathBuf> {
    let root = &cfg.workspace_root;
    let (row, dist) = demo_row(cfg, set, variant)?;
    let road = container_road::Road::enter(set, root).map_err(|e| format!("{e:#}"))?;
    container_road::build_row(set, row, root, &road).map_err(|e| format!("{e:#}"))?;
    let files = stage_dist(cfg, &dist)?;
    for f in &files {
        let n = std::fs::metadata(f).map(|m| m.len()).unwrap_or(0);
        println!("xtask: {} ({n} bytes)", f.display());
    }
    println!("xtask: ✔ demo bundle + site shell in {}", dist.display());
    Ok(dist)
}

/// Render the static `index.html` string — the byte-shape of the reference
/// `write_index_html`: a full-viewport canvas + spinner + a cache-busted module
/// import that lifts the `state_export` hook onto `window`. Pure (no I/O) so the
/// golden test can pin the whole template. `rev` is the cache-bust short-rev.
pub(crate) fn render_index_html(cfg: &PagesConfig, rev: &str) -> String {
    let js = format!("{}.js?v={rev}", cfg.demo_crate);
    let wasm = format!("{}_bg.wasm?v={rev}", cfg.demo_crate);
    // A raw template with `@@…@@` sentinels (no `format!` brace-escaping of the CSS
    // /JS), replaced verbatim → the exact reference bytes.
    const TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
  <!-- Never cache the document itself: the ?v=<sha> on the JS/wasm only helps if the
       browser re-reads THIS file. Without this, a cached index.html keeps the old
       ?v= and you keep seeing the old build ("the bug is still there"). -->
  <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
  <meta http-equiv="Pragma" content="no-cache" />
  <meta http-equiv="Expires" content="0" />
  <title>@@TITLE@@</title>
  <meta name="description" content="@@DESC@@" />
  <style>
    html, body { margin: 0; height: 100%; overflow: hidden; background: #0b0d10; }
    /* The eframe canvas fills the viewport. */
    canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
    /* A minimal loading state shown until the wasm boots. */
    #loading {
      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
      color: #9aa4b2; font: 500 15px/1.4 system-ui, sans-serif; letter-spacing: .02em;
    }
    #loading .dot { animation: blink 1s infinite both; }
    @keyframes blink { 0%, 100% { opacity: .25 } 50% { opacity: 1 } }
  </style>
</head>
<body>
  <div id="loading">loading @@NAME@@<span class="dot">…</span></div>
  <canvas id="@@CANVAS@@"></canvas>
  <script type="module">
    // Import the default `init` AND the named `@@STATE@@` export (the
    // browser-readable state hook). With `--target web`, wasm-bindgen exports are
    // module members, NOT globals — so we lift `@@STATE@@` onto `window` here
    // so a headless drive / the browser console can read what the live wasm bundle
    // actually rendered + ran (`window.@@STATE@@()` → the whole `state_json`,
    // incl. the `runtrace` "what RAN" ledger). This is the verify-the-shipped-
    // artifact-without-a-human seam.
    import init, { @@STATE@@ } from './@@JS@@';
    // Expose the hook immediately (before init resolves) so a driver can poll it.
    window.@@STATE@@ = @@STATE@@;
    init('./@@WASM@@')
      .then(() => { const l = document.getElementById('loading'); if (l) l.remove(); })
      .catch((err) => {
        const l = document.getElementById('loading');
        if (l) l.textContent = 'failed to load the demo — see the browser console';
        console.error(err);
      });
  </script>
</body>
</html>
"#;
    TEMPLATE
        .replace("@@TITLE@@", cfg.title)
        .replace("@@DESC@@", cfg.description)
        .replace("@@NAME@@", cfg.display_name())
        .replace("@@CANVAS@@", cfg.canvas_id)
        .replace("@@STATE@@", cfg.state_export)
        .replace("@@JS@@", &js)
        .replace("@@WASM@@", &wasm)
}

/// The two files wasm-bindgen (`--target web`) emits off the `demo_crate` stem and
/// the loader imports: `<crate>.js` and `<crate>_bg.wasm`.
pub fn bundle_files(cfg: &PagesConfig) -> [String; 2] {
    [format!("{}.js", cfg.demo_crate), format!("{}_bg.wasm", cfg.demo_crate)]
}

/// **Write the static site shell into an already-built `dist`** — the cache-busted
/// `index.html` plus the pages host's `.domains` / `.nojekyll`. It builds nothing.
///
/// This is the half of [`demo_wasm`] that is not a build, public so a repo whose wasm
/// + bindgen are built elsewhere (a build thing's row in the pinned container) gets
/// the same page bytes without a host `cargo build`.
pub fn write_site_shell(cfg: &PagesConfig, dist: &Path) -> Result<()> {
    write_index_html(cfg, dist)?;
    write_pages_meta(cfg, dist)
}

/// **Stage an already-built `dist` for publishing**: refuse BY NAME a dist missing
/// either bundle file (a page whose import 404s is not a deploy), then write the site
/// shell. No build, no git. Returns the bundle files' paths.
pub fn stage_dist(cfg: &PagesConfig, dist: &Path) -> Result<Vec<PathBuf>> {
    let files: Vec<PathBuf> = bundle_files(cfg).iter().map(|f| dist.join(f)).collect();
    let missing: Vec<String> = files.iter().filter(|p| !p.is_file()).map(|p| p.display().to_string()).collect();
    if !missing.is_empty() {
        return Err(format!(
            "publish {}: the dist is not a built bundle — missing {} (build the wasm + bindgen first)",
            dist.display(),
            missing.join(", ")
        )
        .into());
    }
    write_site_shell(cfg, dist)?;
    Ok(files)
}

/// Write `index.html` into `dist/` (cache-busted with the source tree's short-rev).
fn write_index_html(cfg: &PagesConfig, dist: &Path) -> Result<()> {
    let rev = short_rev(&cfg.workspace_root).unwrap_or_else(|| "dev".to_string());
    let html = render_index_html(cfg, &rev);
    std::fs::write(dist.join("index.html"), html).map_err(|e| format!("write index.html: {e}"))?;
    Ok(())
}

/// The pages host's helper files in `dist/`:
/// - `.domains` pins the served host (one per line, first is canonical),
/// - `.nojekyll` signals "serve the files as-is".
fn write_pages_meta(cfg: &PagesConfig, dist: &Path) -> Result<()> {
    std::fs::write(dist.join(".domains"), format!("{}\n", cfg.pages_host))
        .map_err(|e| format!("write .domains: {e}"))?;
    std::fs::write(dist.join(".nojekyll"), "").map_err(|e| format!("write .nojekyll: {e}"))?;
    Ok(())
}

// ── command: deploy-pages ─────────────────────────────────────────────────────

/// Build the caller's declared demo row IN THE PINNED IMAGE ([`demo_wasm`]), then
/// publish its dist to the `pages` branch and force-push it. Returns the pushed sha.
///
/// The `pages` branch tip is rebuilt **in-process via gix** each deploy: a single
/// squashed ORPHAN commit whose tree is `dist/` wholesale
/// ([`nornir_git::gitops::commit_dir_as_orphan_branch`]) — no throwaway worktree,
/// no `git worktree/switch/add/commit` subprocesses, and the caller's working tree
/// and current branch are never touched. Re-runnable: the served site == the
/// latest build and the source history stays free of blobs.
///
/// The ONE step still shelled to `git` is the network **force-push**: gix 0.84 has
/// no send-pack over the wire (the nordisk `gix-no-send-pack` law), so this single
/// `git push --force` is the sanctioned exception. The local `pages` branch is
/// deleted afterwards, leaving the repo exactly as found.
pub fn deploy_pages(cfg: &PagesConfig, set: &BuildVariantSet, variant: &str) -> Result<String> {
    let dist = demo_wasm(cfg, set, variant)?;
    push_pages(cfg, &dist)
}

/// **Publish an ALREADY-BUILT `dist`** to the `pages` branch — [`stage_dist`] (refuse
/// a dist without its bundle, write the site shell), then the same squashed orphan
/// commit + force-push as [`deploy_pages`]. It never builds: the wasm and its bindgen
/// come from wherever the caller built them (a build thing row in the pinned
/// container). Returns the pages commit sha that was pushed.
pub fn publish_dist(cfg: &PagesConfig, dist: &Path) -> Result<String> {
    stage_dist(cfg, dist)?;
    push_pages(cfg, dist)
}

/// Commit `dist` as the orphan `pages` tip (gix) and force-push it; returns the sha.
fn push_pages(cfg: &PagesConfig, dist: &Path) -> Result<String> {
    let root = &cfg.workspace_root;
    let branch = cfg.pages_branch;

    // Resolve the push remote (default `origin`). SSH-only per the nordisk law.
    let remote = std::env::var(cfg.remote_env).unwrap_or_else(|_| "origin".to_string());

    // Build the squashed orphan pages commit in-process (pure gix). This creates
    // `refs/heads/<branch>` pointing at a commit whose tree is dist/, without a
    // worktree/checkout and without disturbing HEAD or the working tree.
    let msg = format!(
        "deploy: {} ({})",
        cfg.commit_label,
        short_rev(root).unwrap_or_else(|| "wip".into())
    );
    let sha = nornir_git::gitops::commit_dir_as_orphan_branch(root, dist, branch, &msg)
        .map_err(|e| format!("build pages commit (gix): {e:#}"))?;
    println!("xtask: built pages commit {sha} on `{branch}` (in-process gix)");

    // Force-push the rebuilt branch tip. THE ONE sanctioned `git` exception: gix
    // 0.84 has no over-the-network send-pack, so the push (and only the push) shells.
    println!("xtask: pushing `{branch}` → {remote} (force)…");
    let push = run(
        Command::new("git").current_dir(root).args([
            "push",
            "--force",
            &remote,
            &format!("{branch}:{branch}"),
        ]),
        "git push (pages)",
    );

    // Tidy: drop the local pages branch so the repo is left exactly as found
    // (pure gix). Done whether or not the push succeeded.
    let _ = nornir_git::gitops::delete_branch(root, branch);
    push?;

    println!(
        "\nxtask: ✔ deployed. The pages host serves it at:\n  {}\n  (first publish can take a minute to go live.)",
        cfg.pages_url
    );
    Ok(sha)
}

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

    fn nornir_cfg() -> PagesConfig {
        PagesConfig {
            demo_crate: "nornir-demo",
            canvas_id: "demo_canvas",
            state_export: "__nornir_state",
            title: "nornir — live web demo",
            description: "nornir: the release dependency-DAG in your browser.",
            pages_url: "https://pages.example/nornir",
            pages_host: "pages.example",
            pages_branch: "pages",
            remote_env: "NORNIR_PAGES_REMOTE",
            commit_label: "nornir live web demo",
            extra_build_args: &[],
            workspace_root: PathBuf::from("/ws/nornir"),
        }
    }

    /// GOLDEN, RED-when-broken: the full `index.html` for a known cfg + rev must be
    /// byte-for-byte this string. Any drift in the template (a lost cache-bust, a
    /// mis-interpolated knob, a stray whitespace change) fails here — this is the
    /// proof the served artifact is faithful across the extraction.
    #[test]
    fn index_html_is_byte_faithful_golden() {
        let html = render_index_html(&nornir_cfg(), "abc1234");
        let expected = r#"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
  <!-- Never cache the document itself: the ?v=<sha> on the JS/wasm only helps if the
       browser re-reads THIS file. Without this, a cached index.html keeps the old
       ?v= and you keep seeing the old build ("the bug is still there"). -->
  <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
  <meta http-equiv="Pragma" content="no-cache" />
  <meta http-equiv="Expires" content="0" />
  <title>nornir — live web demo</title>
  <meta name="description" content="nornir: the release dependency-DAG in your browser." />
  <style>
    html, body { margin: 0; height: 100%; overflow: hidden; background: #0b0d10; }
    /* The eframe canvas fills the viewport. */
    canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
    /* A minimal loading state shown until the wasm boots. */
    #loading {
      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
      color: #9aa4b2; font: 500 15px/1.4 system-ui, sans-serif; letter-spacing: .02em;
    }
    #loading .dot { animation: blink 1s infinite both; }
    @keyframes blink { 0%, 100% { opacity: .25 } 50% { opacity: 1 } }
  </style>
</head>
<body>
  <div id="loading">loading nornir<span class="dot">…</span></div>
  <canvas id="demo_canvas"></canvas>
  <script type="module">
    // Import the default `init` AND the named `__nornir_state` export (the
    // browser-readable state hook). With `--target web`, wasm-bindgen exports are
    // module members, NOT globals — so we lift `__nornir_state` onto `window` here
    // so a headless drive / the browser console can read what the live wasm bundle
    // actually rendered + ran (`window.__nornir_state()` → the whole `state_json`,
    // incl. the `runtrace` "what RAN" ledger). This is the verify-the-shipped-
    // artifact-without-a-human seam.
    import init, { __nornir_state } from './nornir-demo.js?v=abc1234';
    // Expose the hook immediately (before init resolves) so a driver can poll it.
    window.__nornir_state = __nornir_state;
    init('./nornir-demo_bg.wasm?v=abc1234')
      .then(() => { const l = document.getElementById('loading'); if (l) l.remove(); })
      .catch((err) => {
        const l = document.getElementById('loading');
        if (l) l.textContent = 'failed to load the demo — see the browser console';
        console.error(err);
      });
  </script>
</body>
</html>
"#;
        assert_eq!(html, expected, "index.html template drifted");
    }

    /// The cache-bust `?v=<rev>` rides on BOTH the JS import and the wasm fetch, and
    /// every knob interpolates. RED-when-broken: a missing `?v=` serves stale wasm.
    #[test]
    fn cache_bust_and_knobs_interpolate() {
        let html = render_index_html(&nornir_cfg(), "deadbee");
        assert!(html.contains("./nornir-demo.js?v=deadbee"), "js cache-bust");
        assert!(html.contains("./nornir-demo_bg.wasm?v=deadbee"), "wasm cache-bust");
        assert!(html.contains(r#"<canvas id="demo_canvas">"#), "canvas knob");
        assert!(html.contains("window.__nornir_state = __nornir_state;"), "state knob");
        assert!(html.contains("<title>nornir — live web demo</title>"), "title knob");
        // A facett-flavoured cfg swaps every knob (proves nothing is hardcoded).
        let mut fc = nornir_cfg();
        fc.demo_crate = "facett-demo";
        fc.canvas_id = "facett_demo";
        fc.state_export = "__facett_state";
        fc.title = "facett — live web demo";
        let h2 = render_index_html(&fc, "cafe123");
        assert!(h2.contains("./facett-demo_bg.wasm?v=cafe123"));
        assert!(h2.contains(r#"<canvas id="facett_demo">"#));
        assert!(h2.contains(">loading facett<"), "loading label from title");
    }

    /// A fresh scratch dir under the OS temp dir, unique per test + process.
    fn scratch(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("nornir-pages-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    /// `stage_dist` on a built dist writes exactly the page `demo_wasm` writes (the
    /// golden template, cache-busted with the root's rev — `dev` outside a repo) plus
    /// `.domains` / `.nojekyll`, and leaves the bundle bytes untouched.
    #[test]
    fn stage_dist_writes_the_site_shell_into_a_built_dist() {
        let root = scratch("stage-root");
        let dist = root.join("dist");
        std::fs::create_dir_all(&dist).unwrap();
        std::fs::write(dist.join("nornir-demo.js"), b"js").unwrap();
        std::fs::write(dist.join("nornir-demo_bg.wasm"), b"\0asm").unwrap();
        let cfg = PagesConfig { workspace_root: root.clone(), ..nornir_cfg() };
        let files = stage_dist(&cfg, &dist).unwrap();
        assert_eq!(files, vec![dist.join("nornir-demo.js"), dist.join("nornir-demo_bg.wasm")]);
        let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
        assert_eq!(html, render_index_html(&cfg, "dev"), "the same page demo_wasm writes");
        assert_eq!(std::fs::read_to_string(dist.join(".domains")).unwrap(), "pages.example\n");
        assert_eq!(std::fs::read(dist.join(".nojekyll")).unwrap(), b"");
        assert_eq!(std::fs::read(dist.join("nornir-demo_bg.wasm")).unwrap(), b"\0asm");
        let _ = std::fs::remove_dir_all(&root);
    }

    /// RED-when-broken: a dist without its bundle is refused BY NAME before any page
    /// is written (and so before any git) — a pushed page whose import 404s is the
    /// silent-green deploy.
    #[test]
    fn stage_dist_refuses_a_dist_without_its_bundle() {
        let root = scratch("stage-missing");
        let dist = root.join("dist");
        std::fs::create_dir_all(&dist).unwrap();
        std::fs::write(dist.join("nornir-demo.js"), b"js").unwrap();
        let cfg = PagesConfig { workspace_root: root.clone(), ..nornir_cfg() };
        let err = stage_dist(&cfg, &dist).unwrap_err().to_string();
        assert!(err.contains("nornir-demo_bg.wasm"), "names the missing file: {err}");
        assert!(!err.contains("nornir-demo.js,"), "does not name a present file: {err}");
        assert!(!dist.join("index.html").exists(), "no page written for a refused dist");
        // publish_dist refuses the same way, before touching git (root is no repo).
        let err = publish_dist(&cfg, &dist).unwrap_err().to_string();
        assert!(err.contains("missing"), "{err}");
        let _ = std::fs::remove_dir_all(&root);
    }

    /// A declaration with a `nornir-demo-wasm` row, optionally with its bindgen cell.
    fn set(cell: bool) -> BuildVariantSet {
        let mut t = String::from(
            "[[build.variant]]\nname = \"nornir-demo-wasm\"\npackage = \"nornir-demo\"\nbin = \"nornir-demo\"\n\
             profile = \"release\"\ntarget = \"wasm32-unknown-unknown\"\nkind = \"wasm\"\n",
        );
        if cell {
            t.push_str("\n[[build.wasm]]\nname = \"nornir-demo-wasm\"\nout_dir = \"dist\"\n\n[build.wasm.bindgen]\ntarget = \"web\"\n");
        }
        BuildVariantSet::from_toml_str(&t).expect("the fixture parses")
    }

    /// The row the page is built from is the CALLER's declared row, and the dist is
    /// its cell's `out_dir` — nothing here names a package, triple or flag.
    #[test]
    fn demo_row_is_the_declared_row_and_its_cell_dir() {
        let s = set(true);
        let (row, dist) = demo_row(&nornir_cfg(), &s, "nornir-demo-wasm").unwrap();
        assert_eq!(row.name, "nornir-demo-wasm");
        assert_eq!(dist, PathBuf::from("/ws/nornir/dist"));
    }

    /// RED-when-broken: no row, no cell, or a cell whose bundle is not what the page
    /// imports — each refused BY NAME before any engine is asked (so no host build
    /// can stand in for a missing declaration).
    #[test]
    fn demo_row_refuses_a_missing_row_a_missing_cell_and_a_mismatched_bundle() {
        let err = demo_row(&nornir_cfg(), &set(true), "no-such-row").unwrap_err().to_string();
        assert!(err.contains("no-such-row"), "{err}");
        let err = demo_row(&nornir_cfg(), &set(false), "nornir-demo-wasm").unwrap_err().to_string();
        assert!(err.contains("declares no [[build.wasm]] cell"), "{err}");
        let cfg = PagesConfig { demo_crate: "other-demo", ..nornir_cfg() };
        let err = demo_row(&cfg, &set(true), "nornir-demo-wasm").unwrap_err().to_string();
        assert!(err.contains("dist/other-demo.js"), "names the file the page imports: {err}");
        // demo_wasm refuses the same way, before entering the road.
        let err = demo_wasm(&nornir_cfg(), &set(false), "nornir-demo-wasm").unwrap_err().to_string();
        assert!(err.contains("declares no [[build.wasm]] cell"), "{err}");
    }

    /// The host road is gone: nothing in this crate spawns cargo or wasm-bindgen.
    #[test]
    fn no_host_build_is_spelled_here() {
        let src = include_str!("lib.rs");
        // Only the crate's code, not this test (which has to spell the banned words).
        let src = src.split("#[cfg(test)]").next().unwrap();
        let code: String = src.lines().filter(|l| !l.trim_start().starts_with("//")).collect::<Vec<_>>().join("\n");
        for banned in ["Command::new(\"cargo\")", "ensure_wasm_bindgen", "ensure_wasm_target", "\"wasm-opt\""] {
            assert!(!code.contains(banned), "nornir-pages spells `{banned}` — the host build road is back");
        }
    }

    /// `display_name` = the title's first word (the loading-spinner label).
    #[test]
    fn display_name_is_title_first_word() {
        assert_eq!(nornir_cfg().display_name(), "nornir");
    }
}