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