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

nordisk/monetize

uitest/src/main.rs

//! `monetize-uitest` — the console's functional matrix, in a real browser.
//!
//! See `Cargo.toml` for what it is. Two modes in one binary:
//!
//! * the HARNESS (default): brings a rig up — the mock monetize, the real
//!   `monetize-ui-server`, a headless Chrome — and walks `UI.md`'s four
//!   screens as rows;
//! * `--mock-serve`: BE the mock monetize, on a port, until killed. The
//!   harness spawns itself this way, so there is one binary to build.

mod cdp;
mod fixture;
// A copy of gunnar-ui's `uitest/src/http.rs`, kept line for line
// (`tests/copied_from_gunnar_ui.rs`), so its one lint is allowed here rather
// than fixed in the copy alone.
#[allow(clippy::collapsible_if)]
mod http;
mod json;
mod matrix;
mod mock;
mod rig;
mod ws;

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

const USAGE: &str = "\
monetize-uitest — one functional walk of the monetize console, in a browser

usage:
  monetize-uitest [options]

options:
  --ui <path>        monetize-ui-server   (default ../target/release/monetize-ui-server)
  --dist <path>      the built console    (default ../crates/monetize-ui-web/dist)
  --chrome <path>    the browser          (default google-chrome)
  --work <dir>       scratch              (default uitest/uitest-work)
  --only <substr>    run only rows whose name contains this
  --probe            bring the rig up, print every screen endpoint's real answer, stop
  --estate <origin>  NO rig: a REAL console (loopback) in front of a REAL estate's monetize; enrol a
                     passkey with --admin-token-file, then walk every page against the console's API
  --live <origin>    NO rig: walk the screens against an OPEN console on loopback that
                     has this mock behind it (refuses any origin that is not loopback)
  --shots <dir>      also take a picture of every page at 1440 px and 390 px into <dir>, red when a page
                     scrolls sideways; with --live, ONLY the pictures (no fixture rows)
  --keep             leave the rig's data behind
  --no-browser       bring the rig up and check the API only (no Chrome)
  --no-passkey       skip the second rig (the passkey ceremony)
  --mock-serve <bind> serve the mock monetize on <bind> (needs --token); used by the rig
";

fn arg(args: &[String], name: &str) -> Option<String> {
    args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.iter().any(|a| a == "-h" || a == "--help") {
        print!("{USAGE}");
        return;
    }
    if let Some(bind) = arg(&args, "--mock-serve") {
        let token = arg(&args, "--token").unwrap_or_default();
        if token.is_empty() {
            eprintln!("--mock-serve needs --token");
            std::process::exit(2);
        }
        mock_serve(&bind, &token);
        return;
    }
    let chrome = arg(&args, "--chrome").unwrap_or_else(|| "google-chrome".to_owned());
    let only = arg(&args, "--only");
    if let Some(origin) = arg(&args, "--estate") {
        // A REAL console in front of a REAL estate's monetize (no mock): the
        // passkey ceremony with its break-glass token, then every page against
        // what the console's own API says monetize holds.
        let origin = origin.trim_end_matches('/').to_owned();
        // A console off loopback (the monetize-admin box at its public name):
        // only with its name MAPPED to where it is reached and its own
        // certificate PINNED — the walk still enrols a passkey with the
        // break-glass token, so it never runs against a name it cannot pin.
        let (map, spki) = (arg(&args, "--map"), arg(&args, "--spki"));
        // `--no-sandbox`: a box whose AppArmor refuses Chrome's user-namespace
        // sandbox (Ubuntu 24.04+, MEASURED on dwarf 2026-09-29: "No usable
        // sandbox") — said on the command line, never a default.
        let mut extra: Vec<String> = if args.iter().any(|a| a == "--no-sandbox") { vec!["--no-sandbox".to_string()] } else { Vec::new() };
        let pinned = if let (Some(m), Some(pin)) = (&map, &spki) {
            let Some((host, ip)) = m.split_once('=') else {
                eprintln!("--map HOST=IP");
                std::process::exit(2);
            };
            extra.push(format!("--host-resolver-rules=MAP {host} {ip}"));
            extra.push(format!("--ignore-certificate-errors-spki-list={pin}"));
            true
        } else {
            false
        };
        let _ = cdp::EXTRA_ARGS.set(extra);
        if !pinned && !rig::is_loopback_origin(&origin) {
            eprintln!("--estate {origin}: refused. The walk enrols a passkey with a break-glass token; it runs against a console on loopback only.");
            std::process::exit(2);
        }
        let token_file = arg(&args, "--admin-token-file").unwrap_or_default();
        let token = match std::fs::read_to_string(&token_file) {
            Ok(t) if !t.trim().is_empty() => t.trim().to_owned(),
            _ => {
                eprintln!("--estate needs --admin-token-file <the console's break-glass token> (read, never printed)");
                std::process::exit(2);
            }
        };
        let work = PathBuf::from(arg(&args, "--work").unwrap_or_else(|| concat!(env!("CARGO_MANIFEST_DIR"), "/uitest-work/estate").to_owned()));
        eprintln!("estate walk against {origin}; page texts kept in {}", work.display());
        let rows = matrix::estate(&chrome, &origin, &token, &work, only.as_deref());
        finish(rows, None);
        return;
    }
    if let Some(origin) = arg(&args, "--live") {
        let origin = origin.trim_end_matches('/').to_owned();
        if !rig::is_loopback_origin(&origin) {
            eprintln!("--live {origin}: refused. The screen rows assert the FIXTURE's numbers and this is not a loopback origin; a real deployment (monetize.vetra.se) is never a target of this harness.");
            std::process::exit(2);
        }
        if let Some(dir) = arg(&args, "--shots") {
            eprintln!("pictures of every page of {origin} into {dir}");
            let rows = matrix::shots(&chrome, &origin, Path::new(&dir), only.as_deref());
            finish(rows, None);
            return;
        }
        eprintln!("live walk of the screens against {origin} (an open console with the mock behind it)");
        let rows = matrix::live(&chrome, &origin, only.as_deref());
        finish(rows, None);
        return;
    }
    let here = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let ui = arg(&args, "--ui").unwrap_or_else(|| here.join("../target/release/monetize-ui-server").display().to_string());
    let dist = arg(&args, "--dist").unwrap_or_else(|| here.join("../crates/monetize-ui-web/dist").display().to_string());
    // Under this crate, not under the caller's cwd: a run from the repo root
    // must not leave a work directory in the repo. `uitest/.gitignore` covers it.
    let work = PathBuf::from(arg(&args, "--work").unwrap_or_else(|| concat!(env!("CARGO_MANIFEST_DIR"), "/uitest-work").to_owned()));
    let keep = args.iter().any(|a| a == "--keep");

    // Absolute, before anything is spawned: every child runs with its current
    // directory set to the work directory, and a relative binary path then
    // stops resolving with no hint that the path was the problem.
    let ui = match std::fs::canonicalize(&ui) {
        Ok(p) => p.display().to_string(),
        Err(e) => {
            eprintln!("--ui {ui}: {e}\n(build it: cargo build --release -p monetize-ui-server)");
            std::process::exit(2);
        }
    };
    let dist = match std::fs::canonicalize(&dist) {
        Ok(p) if p.join("index.html").is_file() => p,
        Ok(p) => {
            eprintln!("--dist {}: no index.html there\n(build it: cd crates/monetize-ui-web && trunk build --release)", p.display());
            std::process::exit(2);
        }
        Err(e) => {
            eprintln!("--dist {dist}: {e}\n(build it: cd crates/monetize-ui-web && trunk build --release)");
            std::process::exit(2);
        }
    };

    eprintln!("bringing the OPEN rig up in {} …", work.display());
    let r = match rig::Rig::up(&ui, &dist, &work, keep, rig::Posture::Open) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("rig: {e}");
            std::process::exit(2);
        }
    };
    eprintln!("rig up ({:?}): mock {} console {}", r.posture, r.mock_addr, r.origin());
    if args.iter().any(|a| a == "--probe") {
        probe(&r);
        drop(r);
        return;
    }
    let want_browser = !args.iter().any(|a| a == "--no-browser");
    let mut rows = matrix::run(&r, if want_browser { Some(chrome.as_str()) } else { None }, only.as_deref());
    if let (true, Some(dir)) = (want_browser, arg(&args, "--shots")) {
        rows.extend(matrix::shots(&chrome, &r.origin(), Path::new(&dir), only.as_deref()));
    }
    // **Drop the rig BEFORE exiting.** `std::process::exit` runs no
    // destructors; a red run that exited here would orphan a mock and a
    // console per run (gunnar-ui measured 17 orphans holding 9 500 threads).
    drop(r);

    if want_browser && !args.iter().any(|a| a == "--no-passkey") {
        let work2 = work.join("passkey");
        eprintln!("bringing the PASSKEY rig up in {} …", work2.display());
        match rig::Rig::up(&ui, &dist, &work2, keep, rig::Posture::Passkey) {
            Ok(r2) => {
                rows.extend(matrix::passkey(&r2, &chrome, only.as_deref()));
                drop(r2);
            }
            Err(e) => rows.push(matrix::Row { name: "passkey-rig-starts", ok: false, ms: 0, detail: e }),
        }
    }
    finish(rows, Some(&work));
}

fn finish(rows: Vec<matrix::Row>, work: Option<&Path>) {
    let failed = rows.iter().filter(|x| !x.ok).count();
    let ms: u128 = rows.iter().map(|x| x.ms).sum();
    println!("\n{} situations, {} PASS, {} FAIL, {} ms", rows.len(), rows.len() - failed, failed, ms);
    if failed > 0 {
        for x in rows.iter().filter(|x| !x.ok) {
            println!("  FAIL {}: {}", x.name, x.detail);
        }
        if let Some(w) = work {
            println!("  (the console's log: {})", w.join("ui.err").display());
        }
        std::process::exit(1);
    }
}

/// `--mock-serve`: bind, announce the port on stdout (the rig reads it), serve
/// until killed.
fn mock_serve(bind: &str, token: &str) {
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("a tokio runtime");
    rt.block_on(async {
        let mock = mock::Mock::new();
        match mock::serve(mock, bind, token).await {
            Ok(addr) => {
                use std::io::Write as _;
                println!("listening {addr}");
                let _ = std::io::stdout().flush();
            }
            Err(e) => {
                eprintln!("mock: cannot bind {bind}: {e}");
                std::process::exit(2);
            }
        }
        std::future::pending::<()>().await;
    });
}

/// Ask every screen endpoint for its real answer and print it, so rows are
/// written against what the console ACTUALLY sends (measure, never assert, applied before the
/// first green rather than after it).
fn probe(r: &rig::Rig) {
    for path in [
        "/health",
        "/auth/status",
        "/api/health",
        "/api/products",
        "/api/products/gunnar/tenants",
        "/api/products/idun/tenants",
        "/api/tenants/gunnar/alice",
        "/api/tenants/gunnar/bjorn",
        "/api/ledger?product=gunnar",
        "/api/quote?product=gunnar&tenant=alice&caps=pack_bytes%3D107374182400",
        "/api/purchase/gunnar/stuck/2026-09-04",
    ] {
        match http::req(&r.ui_addr(), "GET", path, &[], None) {
            Ok(resp) => println!("GET  {path}\n  {} {}", resp.status, http::cut(&resp.body, 900)),
            Err(e) => println!("GET  {path}\n  ERR {e}"),
        }
    }
    println!("### console log tail:");
    if let Ok(s) = std::fs::read_to_string(r.work.join("ui.err")) {
        for l in s.lines().rev().take(12).collect::<Vec<_>>().into_iter().rev() {
            println!("  {}", http::cut(l, 200));
        }
    }
}

/// **The fixture's own honesty**, proved on the host with no browser and no
/// server. Every `Shown` pair must be what the ONE formatter renders; every
/// number must differ from every other number of the same tenant; the fleet
/// must be the sum of the readings.
#[cfg(test)]
mod fixture_tests {
    use super::fixture::*;
    use monetize_product::bytes;

    #[test]
    fn every_shown_pair_is_what_the_one_formatter_renders() {
        let mut wrong = Vec::new();
        for s in [
            ALICE_PACK, ALICE_CACHE, ALICE_LFS, ALICE_TOMBSTONED, ALICE_RAM,
            BOB_PACK, BOB_CACHE, BOB_LFS, BOB_TOMBSTONED, BOB_RAM,
            ANNA_PACK, TEAM_SUB_PACK, ZZ_PACK, BJORN_PACK, EDGE_PACK,
            CAP_10, CAP_1, CAP_CACHE_20, GUNNAR_CAPACITY,
            MIRA_STORE, NILS_STORE, SKADE_CAP_8, SKADE_CAPACITY, SKADE_OVERSOLD, OLA_ARCHIVE, IDUN_CAP_5,
        ] {
            let got = bytes(s.raw);
            if got != s.text {
                wrong.push(format!("raw {} renders as {got:?}, not {:?}", s.raw, s.text));
            }
        }
        for (raw, text, what) in [
            (gunnar_pack_used(), GUNNAR_PACK_USED_TEXT, "the big number on screens 1 and 2"),
            (GUNNAR_FLEET_PER_WEEK.round() as u64, GUNNAR_FLEET_PER_WEEK_TEXT, "gunnar's rate"),
            (ALICE_PER_WEEK.round() as u64, ALICE_PER_WEEK_TEXT, "alice's rate"),
            (SKADE_FLEET_PER_WEEK.round() as u64, SKADE_FLEET_PER_WEEK_TEXT, "skade's rate"),
        ] {
            let got = bytes(raw);
            if got != text {
                wrong.push(format!("{what}: {raw} renders as {got:?}, not {text:?}"));
            }
        }
        assert!(wrong.is_empty(), "{}", wrong.join("\n"));
    }

    /// A fixture in which two meters of one tenant carried the same value
    /// could not tell a screen that wired the wrong meter from one that wired
    /// the right one — and a zero could not tell a wired meter from a dead one.
    #[test]
    fn every_meter_of_every_tenant_is_distinct_and_non_zero() {
        for product in PRODUCT_IDS {
            for u in usages(product) {
                let mut seen = std::collections::BTreeSet::new();
                for m in &u.meters {
                    assert!(m.value > 0, "{product}/{}: {} is zero", u.tenant, m.name);
                    assert!(seen.insert(m.value), "{product}/{}: {} = {} repeats another meter's value", u.tenant, m.name, m.value);
                }
            }
        }
    }

    /// `BASE-MODEL.md` rule 3: nothing on screen the rows cannot rebuild. The
    /// canned fleet's `used` must be the sum of the canned readings.
    #[test]
    fn the_fleet_is_the_sum_of_the_readings() {
        for product in PRODUCT_IDS {
            let f = fleet(product);
            for m in &f.meters {
                let sum: u64 = usages(product).iter().flat_map(|u| u.meters.iter()).filter(|x| x.name == m.meter).map(|x| x.value).sum();
                assert_eq!(m.used, sum, "{product}/{}: fleet says {} but the readings sum to {sum}", m.meter, m.used);
                assert_eq!(m.tenants as usize, usages(product).len(), "{product}/{}: tenant count", m.meter);
            }
        }
        assert_eq!(GUNNAR_ALLOWANCE, facts("gunnar").iter().filter_map(|f| f.caps.get("pack_bytes")).sum::<u64>(), "the allowance is the sum of the caps the facts name");
        assert_eq!(SKADE_OVERSOLD.raw, 2 * SKADE_CAP_8.raw - SKADE_CAPACITY.raw, "oversold by exactly what was promised beyond the box");
    }

    /// The order the screen must produce is NOT the order the server emits.
    #[test]
    fn the_closest_first_order_differs_from_the_alphabetical_one() {
        let mut sorted = GUNNAR_CLOSEST_FIRST.to_vec();
        sorted.sort();
        assert_eq!(sorted, GUNNAR_TENANTS_ALPHABETICAL, "the two lists must name the same tenants");
        assert_ne!(GUNNAR_CLOSEST_FIRST, GUNNAR_TENANTS_ALPHABETICAL, "a console that forgot to sort must be red");
        let names: Vec<String> = usages("gunnar").iter().map(|u| u.tenant.clone()).collect();
        assert_eq!(names, GUNNAR_TENANTS_ALPHABETICAL, "the readings are emitted alphabetically, as the BFF's join does");
    }

    #[test]
    fn the_sealed_sentences_end_with_the_clause_a_truncation_takes_first() {
        assert!(SEALED_DETAIL.ends_with("not another disk"));
        assert!(SEALED_REFUSAL.ends_with("not another disk"));
        assert!(SEALED_REFUSAL.contains("BILLED AND INVISIBLE"));
    }
}