uitest/src/matrix.rs
//! The situations — `UI.md`'s four screens, as rows.
//!
//! ## Written from the SPEC, asserted on the DOM
//!
//! Every row names a sentence in `UI.md` or `BASE-MODEL.md` and checks what
//! the page actually holds for it: `innerText` (what a person reads), a
//! `role="meter"` (what a screen reader is told), a `title` (where the jargon
//! is allowed to live). Never the page's own claim about itself, and never a
//! class name the implementation happens to use — a row that keys on markup
//! tests the markup, and the markup is what is landing commit by commit. A row
//! for a screen that has not landed yet is RED, which is correct.
//!
//! ## Every number is the fixture's
//!
//! `crate::fixture` is the one source: `10.2 GiB` on alice's page is
//! `ALICE_PACK.text`, proved by a host test to be what the one byte
//! formatter renders for `ALICE_PACK.raw`, which is what the mock served. A
//! row that finds the string has found the wire.
use std::path::Path;
use std::time::Instant;
use crate::cdp::{Cdp, Chrome};
use crate::fixture as fx;
use crate::http;
use crate::json::{esc, Json};
use crate::rig::Rig;
pub struct Row {
pub name: &'static str,
pub ok: bool,
pub ms: u128,
pub detail: String,
}
fn go(rows: &mut Vec<Row>, only: Option<&str>, name: &'static str, f: impl FnOnce() -> Result<String, String>) -> bool {
if let Some(o) = only {
if !name.contains(o) {
return true;
}
}
let t = Instant::now();
let (ok, detail) = match f() {
Ok(d) => (true, d),
Err(e) => (false, e),
};
let ms = t.elapsed().as_millis();
println!("{:<4} {:<58} {:>6} ms {}", if ok { "PASS" } else { "FAIL" }, name, ms, http::cut(&detail, 140));
rows.push(Row { name, ok, ms, detail });
ok
}
fn want(cond: bool, msg: impl FnOnce() -> String) -> Result<(), String> {
if cond {
Ok(())
} else {
Err(msg())
}
}
// ── the API, on the real binary ──────────────────────────────────────────
struct Api<'a> {
r: &'a Rig,
}
impl Api<'_> {
fn get(&self, path: &str) -> Result<(u16, Json, String), String> {
let resp = http::req(&self.r.ui_addr(), "GET", path, &[], None)?;
Ok((resp.status, resp.json().unwrap_or(Json::Null), resp.body))
}
fn post(&self, path: &str, body: &str) -> Result<(u16, Json, String), String> {
let h = [("Content-Type", "application/json".to_owned()), ("Origin", self.r.origin())];
let resp = http::req(&self.r.ui_addr(), "POST", path, &h, Some(body))?;
Ok((resp.status, resp.json().unwrap_or(Json::Null), resp.body))
}
}
pub fn run(r: &Rig, chrome_bin: Option<&str>, only: Option<&str>) -> Vec<Row> {
let mut rows: Vec<Row> = Vec::new();
let a = Api { r };
go(&mut rows, only, "the-console-presents-the-bearer-and-monetize-names-four-products", || {
// The mock REFUSES a call without the suite bearer, so a serving
// answer with the four ids is the proof the token travelled.
let (status, j, body) = a.get("/api/health")?;
want(status == 200, || format!("/api/health {status}: {}", http::cut(&body, 200)))?;
want(j.path("serving").and_then(|v| v.as_bool()) == Some(true), || format!("not serving: {}", http::cut(&body, 200)))?;
let ids: Vec<String> = j.path("products").map(|p| p.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect()).unwrap_or_default();
want(ids == fx::PRODUCT_IDS, || format!("products {ids:?}"))?;
Ok(format!("serving {}", ids.join(", ")))
});
go(&mut rows, only, "every-screen-endpoint-answers-the-open-console", || {
let mut seen = Vec::new();
for path in ["/api/products", "/api/products/gunnar/tenants", "/api/tenants/gunnar/alice", "/api/ledger?product=gunnar"] {
let (status, _, body) = a.get(path)?;
want(status == 200, || format!("{path} answered {status}: {}", http::cut(&body, 200)))?;
seen.push(format!("{path} {}B", body.len()));
}
Ok(seen.join(" · "))
});
go(&mut rows, only, "the-sealed-refusal-reaches-the-console-whole", || {
// The ORDER: alice to 100 GiB. Refused by the product, in its words.
let body = format!(r#"{{"product":"gunnar","tenant":"alice","caps":{{"pack_bytes":{}}},"vendor":"mockpay"}}"#, 100u64 << 30);
let (status, j, body) = a.post("/api/purchase", &body)?;
want(status == 400, || format!("expected 400, got {status}: {}", http::cut(&body, 200)))?;
let msg = j.text("message");
want(msg == fx::SEALED_REFUSAL, || format!("the sentence was changed on the way: {}", http::cut(&msg, 300)))?;
want(msg.ends_with("not another disk"), || "the last clause is missing".into())?;
// And the QUOTE before it says the same, as a quote — with the price
// of what was refused beside it, and nothing moved.
let (status, q, body) = a.get(&format!("/api/quote?product=gunnar&tenant=alice&caps=pack_bytes%3D{}", 100u64 << 30))?;
want(status == 200, || format!("the quote answered {status}: {}", http::cut(&body, 200)))?;
want(q.path("can_serve").and_then(|v| v.as_bool()) == Some(false), || format!("the quote must say no: {}", http::cut(&body, 200)))?;
want(q.text("reason") == fx::SEALED_REFUSAL, || "the quote's reason is not the sentence".into())?;
want(q.path("price_minor").and_then(|v| v.as_f64()) == Some(fx::ALICE_QUOTE_90_GIB_MINOR as f64), || format!("the price of the 90 GiB refused: {}", http::cut(&body, 200)))?;
Ok(format!("{} chars, ends with \"not another disk\"; quoted at {} first", msg.len(), fx::ALICE_QUOTE_90_GIB_TEXT))
});
if let Some(chrome_bin) = chrome_bin {
if let Some(mut b) = Browser::start(chrome_bin, &r.work.join("chrome"), &mut rows, only) {
screens(&mut b, &r.origin(), &mut rows, only);
}
}
rows
}
pub fn live(chrome_bin: &str, origin: &str, only: Option<&str>) -> Vec<Row> {
let mut rows = Vec::new();
let profile = std::env::temp_dir().join(format!("monetize-uitest-live-{}", std::process::id()));
if let Some(mut b) = Browser::start(chrome_bin, &profile, &mut rows, only) {
screens(&mut b, origin, &mut rows, only);
}
let _ = std::fs::remove_dir_all(&profile);
rows
}
// ── the browser ──────────────────────────────────────────────────────────
struct Browser {
_chrome: Chrome,
c: Cdp,
sess: String,
}
impl Browser {
fn start(chrome_bin: &str, profile: &Path, rows: &mut Vec<Row>, only: Option<&str>) -> Option<Browser> {
let port = match std::net::TcpListener::bind("127.0.0.1:0").and_then(|l| l.local_addr()) {
Ok(a) => a.port(),
Err(e) => {
go(rows, None, "chrome-starts-headless", || Err(e.to_string()));
return None;
}
};
let chrome = match Chrome::launch(chrome_bin, profile, port) {
Ok(c) => {
go(rows, None, "chrome-starts-headless", || Ok(format!("debugging on {port}")));
c
}
Err(e) => {
go(rows, None, "chrome-starts-headless", || Err(format!("{e} — install Google Chrome or pass --chrome <path>; a matrix that cannot open a browser is red, not skipped")));
return None;
}
};
let mut c = match Cdp::attach(port) {
Ok(c) => c,
Err(e) => {
go(rows, None, "devtools-protocol-attaches", || Err(e));
return None;
}
};
let sess = match c.page() {
Ok(s) => {
go(rows, None, "devtools-protocol-attaches", || Ok("page attached".into()));
s
}
Err(e) => {
go(rows, None, "devtools-protocol-attaches", || Err(e));
return None;
}
};
let _ = only;
Some(Browser { _chrome: chrome, c, sess })
}
fn navigate(&mut self, url: &str) -> Result<(), String> {
self.c.navigate(&self.sess, url)
}
/// The page as a person reads it. Closed `<details>` are NOT in it — which
/// is exactly what a "behind a badge" assertion needs.
fn text(&mut self) -> Result<String, String> {
self.c.eval_str(&self.sess, "document.body ? document.body.innerText : ''")
}
fn wait_text(&mut self, needle: &str) -> Result<(), String> {
self.c.wait_for(&self.sess, &format!("document.body && document.body.innerText.includes({})", esc(needle)), 20_000).map_err(|e| {
let held = self.text().unwrap_or_default();
let at = self.c.eval_str(&self.sess, "location.href").unwrap_or_default();
// What the page said while it held nothing: a wasm panic, a
// refused script, a request that never answered.
let errs = self.errors();
format!("{e}; at {at} the page held: {}; page errors: {}", http::cut(&held, 300), http::cut(&errs.join(" | "), 400))
})
}
/// Open every disclosure on the page: `<details>`, and anything that says
/// it is collapsed. What was behind a badge is on the surface afterwards.
fn disclose_all(&mut self) -> Result<u64, String> {
let n = self.c.eval(
&self.sess,
"(() => { let n = 0;
for (const d of document.querySelectorAll('details')) { if (!d.open) { d.open = true; n++; } }
for (const b of document.querySelectorAll('[aria-expanded=\"false\"]')) { b.click(); n++; }
return n; })()",
)?;
Ok(n.as_f64().unwrap_or(0.0) as u64)
}
fn count(&mut self, selector: &str) -> Result<u64, String> {
Ok(self.c.eval(&self.sess, &format!("document.querySelectorAll({}).length", esc(selector)))?.as_f64().unwrap_or(0.0) as u64)
}
/// Every `title` attribute on the page, joined — where `UI.md` rule 3 lets
/// the precise name live.
fn titles(&mut self) -> Result<String, String> {
self.c.eval_str(&self.sess, "[...document.querySelectorAll('[title]')].map(e => e.getAttribute('title')).join('\\n')")
}
fn errors(&mut self) -> Vec<String> {
self.c.drain_page_errors()
}
/// **The row that names `needle`**, as one line: the element whose own
/// text is exactly `needle` (a tenant id, a product id), walked up to the
/// nearest thing that is a row — a link, a table row, a list item, an
/// ARIA row — and its text with the whitespace collapsed. `UI.md`: a
/// product is a row, a tenant is a row, a sale is a row; each leads
/// somewhere or sits in a list.
fn row_of(&mut self, needle: &str) -> Result<String, String> {
self.row_in(None, needle, true)
}
/// A row that merely CONTAINS `needle` (`Sold bob …`), after the
/// heading `after` when one is given — so a sale row for a tenant is not
/// confused with the tenant's own row further up the page.
fn row_in(&mut self, after: Option<&str>, needle: &str, exact: bool) -> Result<String, String> {
let r = self.c.eval_str(
&self.sess,
&format!(
"(() => {{ const N = {}; const H = {}; const EXACT = {};
const all = [...document.body.querySelectorAll('*')];
let from = null;
if (H) {{
from = all.find(h => /^(H[1-6]|SUMMARY|HEADER|CAPTION|LEGEND)$/.test(h.tagName) && h.textContent.trim().toLowerCase().startsWith(H));
if (!from) return '';
}}
const after = e => !from || (from.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING);
const own = e => [...e.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const cands = all.filter(after);
let leaf = cands.find(e => own(e).trim() === N);
if (!leaf && !EXACT) leaf = cands.find(e => own(e).includes(N));
if (!leaf) return '';
const row = leaf.closest('a, tr, li, [role=\"row\"], [class*=\"row\"]') || leaf.parentElement;
return row.innerText.replace(/\\s+/g, ' ').trim(); }})()",
esc(needle),
after.map(|h| esc(&h.to_lowercase())).unwrap_or_else(|| "null".into()),
if exact { "true" } else { "false" }
),
)?;
if r.is_empty() {
return Err(format!("no row names {needle:?}{}; the page held: {}", after.map(|h| format!(" under {h:?}")).unwrap_or_default(), http::cut(&self.text()?, 300)));
}
Ok(r)
}
/// Wait until the page holds a ROW for `needle` — an element whose own
/// text is exactly that. Not merely the word anywhere: the topbar names
/// every product before the list has loaded.
fn wait_row(&mut self, needle: &str) -> Result<String, String> {
let deadline = Instant::now() + std::time::Duration::from_millis(20_000);
loop {
if let Ok(r) = self.row_of(needle) {
return Ok(r);
}
if Instant::now() > deadline {
return Err(format!("no row for {needle:?} appeared within 20 s; the page held: {}", http::cut(&self.text()?, 300)));
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
fn click_button(&mut self, text: &str) -> Result<(), String> {
let hit = self.c.eval_str(
&self.sess,
&format!(
"(() => {{ const b = [...document.querySelectorAll('button,a')].find(e => e.textContent.trim() === {}); if (!b) return 'none'; b.click(); return 'clicked'; }})()",
esc(text)
),
)?;
want(hit == "clicked", || format!("no button reads {text:?}"))
}
}
/// The text of one section: from the line that reads `heading` (case-insensitively)
/// up to the next line that reads any of `until`, or the end.
fn section(text: &str, heading: &str, until: &[&str]) -> Result<String, String> {
let lines: Vec<&str> = text.lines().collect();
let start = lines.iter().position(|l| l.trim().eq_ignore_ascii_case(heading) || l.trim().to_lowercase().starts_with(&heading.to_lowercase())).ok_or_else(|| {
let heads: Vec<&str> = lines.iter().filter(|l| l.len() < 40 && !l.trim().is_empty()).take(30).copied().collect();
format!("no {heading:?} heading; short lines seen: {heads:?}")
})?;
let end = lines[start + 1..].iter().position(|l| until.iter().any(|u| l.trim().to_lowercase().starts_with(&u.to_lowercase()))).map(|i| start + 1 + i).unwrap_or(lines.len());
Ok(lines[start + 1..end].join("\n"))
}
/// Positions of `names` in `text`, in the order they appear, or which are missing.
fn order_of(text: &str, names: &[&str]) -> Result<Vec<String>, String> {
let mut found: Vec<(usize, &str)> = Vec::new();
let mut missing = Vec::new();
for n in names {
match text.find(n) {
Some(i) => found.push((i, n)),
None => missing.push(*n),
}
}
if !missing.is_empty() {
return Err(format!("missing {missing:?} in: {}", http::cut(text, 300)));
}
found.sort();
Ok(found.into_iter().map(|(_, n)| n.to_owned()).collect())
}
/// **The estate every screen below is one estate's** (owner 2026-10-02: the
/// ESTATE is the top level, every page of it under `/e/<estate>/…`). The rig's
/// console administers one, named by `/api/overview`; `local` is what a console
/// with a plain `--control` calls its estate.
fn estate_base(origin: &str) -> String {
let hostport = origin.trim_start_matches("http://");
let name = http::req(hostport, "GET", "/api/overview", &[], None)
.ok()
.and_then(|r| crate::json::parse(&r.body).ok())
.and_then(|j| j.path("estates").and_then(|e| e.arr().first().map(|x| x.text("name"))))
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "local".to_owned());
format!("{origin}/e/{name}")
}
fn tenant_url(base: &str, tenant: &str) -> String {
format!("{base}/customer?product=gunnar&tenant={}", tenant.replace('/', "%2F"))
}
// ── the four screens ─────────────────────────────────────────────────────
fn screens(b: &mut Browser, origin: &str, rows: &mut Vec<Row>, only: Option<&str>) {
let base = estate_base(origin);
// Screen 1's product lines are the estate's MAP now; its spend lines, MONEY.
let map = format!("{base}/map");
let money = format!("{base}/money");
// ── screen 1 ──
go(rows, only, "the-console-loads-under-the-served-csp-and-runs-its-script", || {
// The blank-page regression (serve.rs, 2026-09-04): a CSP hash that
// does not match the served bootstrap blocks the module script, nothing
// mounts, and every route still answers 200. So the assertion is a DOM
// node the WASM created — index.html ships an EMPTY body — under the
// header the server really sent.
let hostport = origin.trim_start_matches("http://");
let resp = http::req(hostport, "GET", "/", &[("Accept", "text/html".to_owned())], None)?;
let csp = resp.header("content-security-policy").unwrap_or("").to_owned();
want(csp.contains("script-src") && csp.contains("'sha256-"), || format!("the page was served without a hashed script-src: {:?}", http::cut(&csp, 200)))?;
let _ = b.errors();
b.navigate(origin)?;
b.c.wait_for(&b.sess, "document.body && document.body.children.length > 0", 20_000).map_err(|e| format!("the console's script did not create anything in the empty body ({e}); the CSP was: {}", http::cut(&csp, 200)))?;
b.wait_text("monetize")?;
let errs = b.errors();
want(errs.is_empty(), || format!("{} page error(s): {}", errs.len(), http::cut(&errs.join(" | "), 300)))?;
Ok(format!("body has {} children; script-src hashed", b.count("body > *")?))
});
go(rows, only, "screen1-one-row-per-product-and-gunnar-is-one-of-them", || {
b.navigate(&map)?;
b.c.wait_for(&b.sess, "document.querySelectorAll('a.product-row').length >= 4", 20_000)?;
b.wait_row("gunnar")?;
// A product row LEADS to its screen (`→`); the rows are the links.
let hrefs = b.c.eval(&b.sess, "[...new Set([...document.querySelectorAll('a.product-row')].map(a => a.getAttribute('href')))]")?;
let hrefs: Vec<String> = hrefs.arr().iter().map(|h| h.as_str().unwrap_or("").to_owned()).collect();
want(hrefs.len() == fx::PRODUCT_IDS.len(), || format!("{} product rows, expected {}: {hrefs:?}", hrefs.len(), fx::PRODUCT_IDS.len()))?;
let text = b.text()?;
for id in fx::PRODUCT_IDS {
want(text.contains(id), || format!("no row names {id}: {}", http::cut(&text, 300)))?;
}
// And the "huge text mass" is gone: no meter name, no prose paragraph
// about plugins on the estate screen.
want(!text.contains("pack_bytes"), || "the products screen still lists raw meters".into())?;
Ok(format!("{} rows: {}", hrefs.len(), hrefs.join(" ")))
});
go(rows, only, "screen1-a-down-product-shows-the-down-dot-and-not-connected", || {
b.navigate(&map)?;
b.c.wait_for(&b.sess, "document.querySelectorAll('a.product-row').length >= 4", 20_000)?;
let line = b.wait_row("holger")?;
want(line.to_lowercase().contains("not connected"), || format!("holger's row does not say `not connected`: {line:?}"))?;
// The dot carries the state: an element on that row whose class or
// title says `down`, and the plugin's own reason reachable as a title.
let dot = b.c.eval(
&b.sess,
"(() => { const row = [...document.querySelectorAll('a.product-row')].find(a => a.textContent.includes('holger')); if (!row) return 'no row';
const el = [...row.querySelectorAll('*')].find(e => /down|not connected/i.test((e.className||'') + ' ' + (e.getAttribute('title')||'')));
return el ? ((el.className||'') + ' | ' + (el.getAttribute('title')||'')) : 'no dot'; })()",
)?;
let dot = dot.as_str().unwrap_or("").to_owned();
want(dot != "no row" && dot != "no dot", || format!("holger's row has no element marked down: {dot}"))?;
want(dot.contains(fx::HOLGER_DOWN_DETAIL) || b.titles()?.contains(fx::HOLGER_DOWN_DETAIL), || format!("the plugin's own reason is not reachable: {dot}"))?;
Ok(format!("{} · {}", http::cut(&line, 60), http::cut(&dot, 80)))
});
go(rows, only, "screen1-the-fleet-figure-is-the-fixtures-used-and-capacity", || {
b.navigate(&map)?;
b.c.wait_for(&b.sess, "document.querySelectorAll('a.product-row').length >= 4", 20_000)?;
let line = b.wait_row("gunnar")?;
want(line.contains(fx::GUNNAR_PACK_USED_TEXT), || format!("gunnar's row does not show {} used: {line:?}", fx::GUNNAR_PACK_USED_TEXT))?;
want(line.contains(fx::GUNNAR_CAPACITY.text), || format!("gunnar's row does not show the capacity {}: {line:?}", fx::GUNNAR_CAPACITY.text))?;
want(line.contains("7 tenants"), || format!("gunnar's row does not count its seven tenants: {line:?}"))?;
Ok(http::cut(&line, 120))
});
go(rows, only, "screen1-a-rate-is-one-phrase-and-an-unknown-rate-is-nothing", || {
b.navigate(&map)?;
b.c.wait_for(&b.sess, "document.querySelectorAll('a.product-row').length >= 4", 20_000)?;
b.wait_row("idun")?;
let gunnar = b.row_of("gunnar")?;
want(gunnar.contains(&format!("{}/wk", fx::GUNNAR_FLEET_PER_WEEK_TEXT)), || format!("gunnar's rate is not `{}/wk`: {gunnar:?}", fx::GUNNAR_FLEET_PER_WEEK_TEXT))?;
// idun: `per_week` is null (not enough history). NO rate, and no zero.
let idun = b.row_of("idun")?;
want(!idun.contains("/wk"), || format!("idun has no rate yet but the row shows one: {idun:?}"))?;
// …and not as a zero either. The check is on the RATE: `0 B in the
// cloud` further along the row is an honest zero (idun owns nothing).
want(!idun.contains("B/wk") && !idun.contains("0/wk"), || format!("an unknown rate rendered as zero: {idun:?}"))?;
// skade: known but bursty — the rate stands, marked `~`.
let skade = b.row_of("skade")?;
want(skade.contains(&format!("~{}/wk", fx::SKADE_FLEET_PER_WEEK_TEXT)), || format!("skade's bursty rate should carry its `~`: {skade:?}"))?;
Ok(format!("gunnar {} · idun (none) · skade ~", fx::GUNNAR_FLEET_PER_WEEK_TEXT))
});
go(rows, only, "screen1-unmeasured-capacity-is-neither-a-full-bar-nor-an-empty-one", || {
b.navigate(&map)?;
b.c.wait_for(&b.sess, "document.querySelectorAll('a.product-row').length >= 4", 20_000)?;
let idun = b.wait_row("idun")?;
want(idun.contains(fx::OLA_ARCHIVE.text), || format!("idun's used figure {} is missing: {idun:?}", fx::OLA_ARCHIVE.text))?;
want(idun.to_lowercase().contains("unmeasured"), || format!("idun's capacity was never measured and the row does not say so: {idun:?}"))?;
// No meter role on a row with no denominator: a bar there would be a
// number nobody measured, read out to a screen reader as a percentage.
let meters_on_idun = b.c.eval(&b.sess, "(() => { const row = [...document.querySelectorAll('a.product-row')].find(a => a.textContent.includes('idun')); return row ? row.querySelectorAll('[role=\"meter\"]').length : -1; })()")?;
want(meters_on_idun.as_f64() == Some(0.0), || format!("idun's row carries {} meter(s) with no denominator", meters_on_idun.dump()))?;
Ok(http::cut(&idun, 120))
});
go(rows, only, "screen1-the-spend-line-shows-the-lifetime-cap", || {
// `UI.md` screen 1: `This month €0.00 spent · €10.00 cap` — "the
// number that stops everything". A money figure with its cap, on the
// estate screen.
b.navigate(&money)?;
b.wait_text("spent")?;
let text = b.text()?;
let line = text.lines().find(|l| l.to_lowercase().contains("spent") && l.to_lowercase().contains("cap")).ok_or_else(|| format!("no spend line (`… spent · … cap`) on the estate screen: {}", http::cut(&text, 300)))?;
want(line.contains(fx::SPEND_SPENT_TEXT), || format!("the spend {} is not on the line: {line:?}", fx::SPEND_SPENT_TEXT))?;
want(line.contains(fx::SPEND_CEILING_TEXT), || format!("the cap {} is not on the line: {line:?}", fx::SPEND_CEILING_TEXT))?;
want(line.contains("EUR"), || format!("an amount without its currency: {line:?}"))?;
Ok(http::cut(line, 120))
});
// ── screen 2 ──
let gunnar = format!("{base}/customers?product=gunnar");
go(rows, only, "screen2-one-number-is-big-and-the-trend-is-one-line", || {
b.navigate(&gunnar)?;
b.wait_text(fx::GUNNAR_PACK_USED_TEXT)?;
let text = b.text()?;
want(text.contains(fx::GUNNAR_CAPACITY.text), || format!("the capacity {} is not on the screen", fx::GUNNAR_CAPACITY.text))?;
let trend = text.lines().find(|l| l.contains("/wk")).ok_or_else(|| format!("no trend line: {}", http::cut(&text, 300)))?;
want(trend.contains("Full in ~"), || format!("an approximate rate with a capacity must project `Full in ~N weeks`: {trend:?}"))?;
want(trend.contains(&format!("{}/wk", fx::GUNNAR_FLEET_PER_WEEK_TEXT)), || format!("the rate on the line is not the fixture's: {trend:?}"))?;
want(trend.contains("last 30 d"), || format!("the window is not stated: {trend:?}"))?;
// ONE line: no chart to interpret.
want(b.count("canvas, svg.chart, .chart")? == 0, || "a chart is on the screen; the trend is one line".into())?;
Ok(http::cut(trend, 120))
});
go(rows, only, "screen2-tenants-are-sorted-closest-to-cap-first", || {
b.navigate(&gunnar)?;
b.wait_text("alice")?;
let text = b.text()?;
let tenants = section(&text, "tenants", &["recently bought", "all activity"])?;
// What is visible before the fold: the first five, at-cap first.
let visible = order_of(&tenants, &fx::GUNNAR_CLOSEST_FIRST[..5])?;
let top_two: std::collections::BTreeSet<&str> = visible.iter().take(2).map(String::as_str).collect();
want(top_two == ["edge", "alice"].into_iter().collect(), || format!("the two tenants AT their cap are not the first two rows: {visible:?}"))?;
want(visible[2..] == ["team/sub", "anna", "zz-visibility"], || format!("after the at-cap rows the order must be by percentage (97, 89, 50): {visible:?}"))?;
// Alphabetical would put anna first: say so if that is what happened.
want(visible[0] != "anna", || "the list is alphabetical, not by attention".into())?;
Ok(visible.join(" > "))
});
go(rows, only, "screen2-the-rest-are-folded-and-the-uncapped-tenant-is-last", || {
b.navigate(&gunnar)?;
b.wait_text("alice")?;
let text = b.text()?;
let tenants = section(&text, "tenants", &["recently bought", "all activity"])?;
want(!tenants.contains("bob") && !tenants.contains("bjorn"), || format!("the sixth and seventh tenants are on the surface before the fold is opened: {}", http::cut(&tenants, 200)))?;
want(tenants.contains("2 more"), || format!("no `+ 2 more` fold: {}", http::cut(&tenants, 200)))?;
b.disclose_all()?;
let text = b.text()?;
let tenants = section(&text, "tenants", &["recently bought", "all activity"])?;
let all = order_of(&tenants, fx::GUNNAR_CLOSEST_FIRST)?;
want(all[5..] == ["bob", "bjorn"], || format!("opened, the fold must continue the order (bob 31 %, then the uncapped bjorn last): {all:?}"))?;
Ok(all.join(" > "))
});
go(rows, only, "screen2-at-cap-is-a-word-and-the-percentages-are-numbers", || {
b.navigate(&gunnar)?;
let alice = b.wait_row("alice")?;
want(alice.contains("AT CAP"), || format!("alice is over cap and the row does not say AT CAP: {alice:?}"))?;
want(alice.contains(fx::ALICE_PACK.text) && alice.contains(fx::CAP_10.text), || format!("alice's row must read {} of {}: {alice:?}", fx::ALICE_PACK.text, fx::CAP_10.text))?;
let anna = b.row_of("anna")?;
want(anna.contains("89 %") || anna.contains("89%"), || format!("anna is at 89 %: {anna:?}"))?;
want(!anna.contains("AT CAP"), || format!("anna is not at cap: {anna:?}"))?;
Ok(format!("{} · {}", http::cut(&alice, 60), http::cut(&anna, 60)))
});
go(rows, only, "screen2-cannot-grow-is-a-badge-and-its-paragraph-is-behind-it", || {
b.navigate(&gunnar)?;
b.wait_text("cannot grow")?;
let text = b.text()?;
want(!text.contains("not another disk"), || "the sealed paragraph is on the page, not behind the badge (UI.md rule 2)".into())?;
let opened = b.disclose_all()?;
want(opened > 0, || "nothing on the page can be disclosed".into())?;
let text = b.text()?;
want(text.contains(fx::SEALED_DETAIL), || format!("opened, the badge must hold the product's paragraph verbatim; the page held: {}", http::cut(&text, 400)))?;
want(text.contains("fresh install"), || "the remedy (a fresh install onto a larger set) is not on the page".into())?;
Ok(format!("badge, {} disclosure(s), paragraph ends with \"not another disk\"", opened))
});
go(rows, only, "screen2-a-bursty-rate-renders-the-rate-but-no-date", || {
b.navigate(&format!("{base}/customers?product=skade"))?;
b.wait_text("mira")?;
let text = b.text()?;
let trend = text.lines().find(|l| l.contains("/wk")).ok_or_else(|| format!("no rate line for skade: {}", http::cut(&text, 300)))?;
want(trend.contains(&format!("~{}/wk", fx::SKADE_FLEET_PER_WEEK_TEXT)), || format!("the bursty rate must stand, with its `~`: {trend:?}"))?;
want(!text.contains("Full in"), || format!("a bursty series must not become a date: {trend:?}"))?;
want(!text.contains("cannot grow"), || "skade is operator_only, not sealed; it must not wear the badge".into())?;
Ok(http::cut(trend, 120))
});
go(rows, only, "screen2-oversold-shows-the-number", || {
b.navigate(&format!("{base}/customers?product=skade"))?;
b.wait_text("mira")?;
let text = b.text()?;
want(text.to_lowercase().contains("oversold"), || format!("two tenants were promised 16 GiB on a 12 GiB box and the screen does not say oversold: {}", http::cut(&text, 300)))?;
let lower = text.to_lowercase();
let line = lower.lines().find(|l| l.contains("oversold")).ok_or_else(|| format!("no line says oversold: {}", http::cut(&lower, 300)))?;
want(line.contains(&fx::SKADE_OVERSOLD.text.to_lowercase()), || format!("the oversell number {} is not on the line: {line:?}", fx::SKADE_OVERSOLD.text))?;
Ok(http::cut(line, 120))
});
go(rows, only, "screen2-unmeasured-is-its-own-state-never-fits-or-zero", || {
b.navigate(&format!("{base}/customers?product=idun"))?;
b.wait_text("ola")?;
let text = b.text()?;
want(text.to_lowercase().contains("unmeasured"), || format!("idun's box never answered and the screen does not say so: {}", http::cut(&text, 300)))?;
want(!text.to_lowercase().contains("fits"), || "an unmeasured fleet rendered as `fits`".into())?;
want(!text.contains("0 B of") && !text.contains(" of 0 B"), || "an unmeasured capacity rendered as zero".into())?;
want(!text.contains("Full in"), || "no capacity, so no projection".into())?;
want(b.count(".big [role=\"meter\"], section[aria-label] [role=\"meter\"]")? == 0 || !text.contains("0 %"), || "an invented denominator drew a bar".into())?;
Ok(format!("{} · capacity unmeasured", fx::OLA_ARCHIVE.text))
});
go(rows, only, "screen2-recently-bought-reads-outcomes-not-steps", || {
b.navigate(&gunnar)?;
b.wait_text("bob")?;
let text = b.text()?;
let bought = section(&text, "recently bought", &[])?;
want(bought.contains("bob") && bought.contains("zz-visibility"), || format!("the outcomes are not under RECENTLY BOUGHT: {}", http::cut(&bought, 200)))?;
let bob = b.row_in(Some("recently bought"), "bob", false)?;
want(bob.contains("220.00"), || format!("bob's sale must carry its 220.00 SEK: {bob:?}"))?;
want(bob.contains(fx::CAP_10.text), || format!("`10 GiB for bob`: {bob:?}"))?;
let zz = b.row_in(Some("recently bought"), "zz-visibility", false)?;
want(zz.to_lowercase().contains("refund"), || format!("zz-visibility was refunded and the line does not say so: {zz:?}"))?;
want(!zz.contains("Sold"), || format!("a refund must not read as a sale: {zz:?}"))?;
want(zz.contains("1.00"), || format!("its 1.00 SEK: {zz:?}"))?;
// Steps are NOT on this surface.
want(!bought.contains("Reserved") && !bought.contains("Captured"), || "ledger steps are on the product screen; they belong behind a disclosure".into())?;
Ok(format!("{} · {}", http::cut(&bob, 50), http::cut(&zz, 50)))
});
// ── screen 3 ──
let alice = tenant_url(&base, "alice");
go(rows, only, "screen3-every-meter-shows-its-own-bytes", || {
b.navigate(&alice)?;
b.wait_text(fx::ALICE_PACK.text)?;
let text = b.text()?;
for (what, s) in [("repository data", fx::ALICE_PACK), ("cache", fx::ALICE_CACHE), ("large files", fx::ALICE_LFS), ("deleted, not purged", fx::ALICE_TOMBSTONED), ("RAM", fx::ALICE_RAM)] {
want(text.contains(s.text), || format!("{what} = {} is not on the page: {}", s.text, http::cut(&text, 300)))?;
}
let repos = b.row_in(None, "Repositories", false)?;
want(repos.split(|c: char| !c.is_ascii_digit()).any(|n| n == fx::ALICE_REPOS.to_string()), || format!("the repository count {} is not on its row: {repos:?}", fx::ALICE_REPOS))?;
want(text.contains(fx::CAP_10.text), || format!("the cap {} is not on the page", fx::CAP_10.text))?;
Ok(format!("{} {} {} {} {} · {} repos", fx::ALICE_PACK.text, fx::ALICE_CACHE.text, fx::ALICE_LFS.text, fx::ALICE_TOMBSTONED.text, fx::ALICE_RAM.text, fx::ALICE_REPOS))
});
go(rows, only, "screen3-plain-words-on-the-surface-and-the-raw-name-in-the-title", || {
b.navigate(&alice)?;
b.wait_text(fx::ALICE_PACK.text)?;
let text = b.text()?;
for word in ["Repository data", "Cache", "Deleted, not purged", "Repositories"] {
want(text.contains(word), || format!("{word:?} is not on the surface: {}", http::cut(&text, 300)))?;
}
for raw in fx::RAW_METER_NAMES {
want(!text.contains(raw), || format!("{raw} is on the surface; it belongs in the detail"))?;
}
for word in ["wall", "not billed", "still costs"] {
want(text.contains(word), || format!("the word {word:?} (UI.md rule 3) is not beside its meter: {}", http::cut(&text, 300)))?;
}
let titles = b.titles()?;
want(titles.contains("pack_bytes"), || format!("the precise name must be reachable as a title; titles held: {}", http::cut(&titles, 200)))?;
Ok("Repository data · Cache · Deleted, not purged · Repositories; pack_bytes in a title".into())
});
go(rows, only, "screen3-at-cap-appears-exactly-when-value-reaches-cap", || {
let mut seen = Vec::new();
for (tenant, at_cap, needle) in [("alice", true, fx::ALICE_PACK.text), ("edge", true, fx::EDGE_PACK.text), ("bob", false, fx::BOB_PACK.text), ("team/sub", false, fx::TEAM_SUB_PACK.text)] {
b.navigate(&tenant_url(&base, tenant))?;
b.wait_text(needle)?;
let text = b.text()?;
let has = text.contains("AT CAP");
want(has == at_cap, || format!("{tenant}: AT CAP {} (value {needle}, cap {})", if has { "shown" } else { "missing" }, fx::CAP_10.text))?;
if at_cap {
let full = b.c.eval(&b.sess, "[...document.querySelectorAll('[role=\"meter\"]')].some(m => m.getAttribute('aria-valuenow') === '100')")?;
want(full.as_bool() == Some(true), || format!("{tenant}: no meter announces 100 %"))?;
}
seen.push(format!("{tenant}:{}", if has { "AT CAP" } else { "below" }));
}
Ok(seen.join(" "))
});
go(rows, only, "screen3-uncapped-is-its-own-state-with-no-meter-role", || {
b.navigate(&tenant_url(&base, "bjorn"))?;
b.wait_text(fx::BJORN_PACK.text)?;
let text = b.text()?;
let meters = b.count("[role=\"meter\"]")?;
want(meters == 0, || format!("bjorn names no cap and the page still announces {meters} meter(s) — an empty bar is `0 %` to a screen reader"))?;
want(text.to_lowercase().contains("no cap") || text.to_lowercase().contains("uncapped"), || format!("the uncapped state is not said: {}", http::cut(&text, 300)))?;
want(!text.contains("AT CAP"), || "an uncapped tenant cannot be at a cap".into())?;
Ok(format!("{} · no cap, 0 meter roles", fx::BJORN_PACK.text))
});
go(rows, only, "screen3-the-trend-is-one-line-with-its-rate", || {
b.navigate(&alice)?;
b.wait_text(fx::ALICE_PACK.text)?;
let text = b.text()?;
let line = text.lines().find(|l| l.contains("/wk")).ok_or_else(|| format!("no rate line on alice's screen: {}", http::cut(&text, 300)))?;
want(line.contains(&format!("{}/wk", fx::ALICE_PER_WEEK_TEXT)), || format!("alice's own rate is {}/wk: {line:?}", fx::ALICE_PER_WEEK_TEXT))?;
Ok(http::cut(line, 120))
});
// ── the order: quoted before any money moves ──
go(rows, only, "screen3-how-much-quotes-price-cost-and-can-serve-before-confirming", || {
b.navigate(&alice)?;
b.wait_text(fx::ALICE_PACK.text)?;
b.click_button("How much?")?;
b.c.wait_for(&b.sess, "!!document.getElementById('order-pack_bytes')", 10_000).map_err(|e| format!("the order form did not open: {e}; page: {}", http::cut(&b.text().unwrap_or_default(), 300)))?;
let prefilled = b.c.eval_str(&b.sess, "document.getElementById('order-pack_bytes').value")?;
want(prefilled == "10", || format!("the form must start at what the tenant holds (10 GiB), not at zero: {prefilled:?}"))?;
// 100 GiB: the sealed refusal, priced.
b.c.eval(
&b.sess,
"(() => { const el = document.getElementById('order-pack_bytes'); const proto = Object.getPrototypeOf(el); Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, '100'); el.dispatchEvent(new Event('input', {bubbles:true})); return 'set'; })()",
)?;
b.click_button("Quote")?;
b.wait_text(fx::ALICE_QUOTE_90_GIB_TEXT)?;
let text = b.text()?;
want(text.contains("90.0 GiB"), || format!("the quote must name the DELTA (90 GiB), not the hundred: {}", http::cut(&text, 300)))?;
want(text.contains(fx::ALICE_QUOTE_90_GIB_TEXT), || format!("the price is not on the page: {}", http::cut(&text, 300)))?;
let cost = format!("{}.{:02} EUR", fx::ALICE_QUOTE_90_GIB_COST_MINOR / 100, fx::ALICE_QUOTE_90_GIB_COST_MINOR % 100);
want(text.contains(&cost), || format!("the provider's cost {cost} is not beside the price: {}", http::cut(&text, 300)))?;
want(text.ends_with("not another disk") || text.contains("not another disk"), || format!("can serve: no must carry the sentence to its last clause: {}", http::cut(&text, 300)))?;
want(!text.contains("take the money") && !text.contains("place the order"), || "a refused quote must not offer a confirm button".into())?;
// 20 GiB: served, and the confirm button appears — nothing is pressed.
b.c.eval(
&b.sess,
"(() => { const el = document.getElementById('order-pack_bytes'); const proto = Object.getPrototypeOf(el); Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, '20'); el.dispatchEvent(new Event('input', {bubbles:true})); return 'set'; })()",
)?;
b.click_button("Quote")?;
b.c.wait_for(&b.sess, "document.body.innerText.includes('take the money')", 10_000).map_err(|e| format!("a servable quote did not offer a confirm: {e}; page: {}", http::cut(&b.text().unwrap_or_default(), 300)))?;
let text = b.text()?;
let ten = format!("{}.{:02} SEK", 10 * fx::GUNNAR_DISK_GIB_MONTH_MINOR / 100, 10 * fx::GUNNAR_DISK_GIB_MONTH_MINOR % 100);
want(text.contains(&ten), || format!("10 GiB more at 22.00 is {ten}: {}", http::cut(&text, 300)))?;
Ok(format!("100 GiB: {} · refused whole · 20 GiB: {ten} · confirm offered, not pressed", fx::ALICE_QUOTE_90_GIB_TEXT))
});
go(rows, only, "screen3-backed-by-names-the-disk-under-the-quota", || {
b.navigate(&alice)?;
b.wait_text(fx::ALICE_PACK.text)?;
let text = b.text()?;
let backed = section(&text, "backed by", &["history", "activity"]).or_else(|_| section(&text, "resources", &["ledger", "history"]))?;
want(backed.contains("10 GiB") && backed.contains("disk"), || format!("the 10 GiB disk is not named: {}", http::cut(&backed, 200)))?;
want(backed.contains(fx::ALICE_DISK_SERVER), || format!("the server it sits on is not named: {}", http::cut(&backed, 200)))?;
Ok(http::cut(&backed, 120))
});
// ── screen 4 ──
let activity = format!("{base}/money?product=gunnar");
go(rows, only, "screen4-open-attempts-float-to-needs-you-first", || {
b.navigate(&activity)?;
b.wait_text("bob")?;
let text = b.text()?;
want(text.to_lowercase().contains("needs you"), || format!("no NEEDS YOU on the activity screen: {}", http::cut(&text, 300)))?;
let order = order_of(&text, &["stuck", "bob"])?;
want(order == ["stuck", "bob"], || format!("the open attempt (stuck) must come before history (bob): {order:?}"))?;
let stuck = b.row_in(None, "stuck", false)?;
want(stuck.contains("never delivered") || stuck.contains(fx::STUCK_REASON), || format!("the task row must say what is wrong: {stuck:?}"))?;
Ok(http::cut(&stuck, 120))
});
go(rows, only, "screen4-a-released-attempt-reads-refunded-not-sold", || {
b.navigate(&activity)?;
b.wait_text("zz-visibility")?;
let zz = b.row_in(None, "zz-visibility", false)?;
want(zz.to_lowercase().contains("refund"), || format!("{zz:?}"))?;
want(!zz.contains("Sold"), || format!("a refund reads as a sale: {zz:?}"))?;
let bob = b.row_in(None, "bob", false)?;
want(bob.contains("Sold") && bob.contains("220.00"), || format!("`Sold bob 10 GiB · 220.00 SEK`: {bob:?}"))?;
// Declined is NOT a refund: nothing was locked.
let anna = b.row_in(None, "anna", false)?;
want(anna.to_lowercase().contains("declined") && !anna.to_lowercase().contains("refund"), || format!("a decline must not wear the refund word: {anna:?}"))?;
Ok(format!("{} · {}", http::cut(&zz, 50), http::cut(&bob, 50)))
});
go(rows, only, "screen4-the-steps-are-hidden-until-disclosed", || {
b.navigate(&activity)?;
b.wait_text("bob")?;
let text = b.text()?;
want(!text.contains("Captured") && !text.contains("EntitlementPushed") && !text.contains("charged"), || format!("ledger steps are on the surface (UI.md rule 6): {}", http::cut(&text, 300)))?;
let opened = b.disclose_all()?;
want(opened > 0, || "nothing on the page can be disclosed, so the steps are unreachable".into())?;
b.c.wait_for(&b.sess, "/Captured|charged|Reserved|reserved/.test(document.body.innerText)", 10_000).map_err(|e| format!("opened, the steps still do not appear: {e}"))?;
let text = b.text()?;
want(text.contains("Captured") || text.contains("charged"), || format!("the disclosed steps do not include the capture: {}", http::cut(&text, 300)))?;
Ok(format!("{opened} disclosure(s); steps visible after"))
});
// ── what must NEVER appear, on every screen ──
//
// Money words (`BASE-MODEL.md`: there is no balance) and raw meter names
// on EVERY screen — `UI.md`'s four surfaces, and there is no fifth: the
// Plans page went with the catalogue, and the order form names a meter in
// plain words with the wire name in the label's title.
go(rows, only, "no-screen-carries-money-vocabulary-or-a-raw-meter-name", || {
let mut swept = 0;
let e = base.trim_start_matches(origin).to_owned();
for path in [
"/".to_owned(),
format!("{e}/map"),
format!("{e}/customers?product=gunnar"),
format!("{e}/customers?product=idun"),
format!("{e}/customers?product=skade"),
format!("{e}/customers?product=holger"),
tenant_url(&e, "alice"),
tenant_url(&e, "bjorn"),
format!("{e}/money?product=gunnar"),
format!("{e}/events"),
format!("{e}/offer"),
format!("{e}/settings"),
] {
let path = path.as_str();
b.navigate(&format!("{origin}{path}"))?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.length > 40", 20_000)?;
let is_screen = true;
// Twice: once as shown, once with every disclosure open.
for _ in 0..2 {
let text = b.text()?;
let lower = text.to_lowercase();
let titles = b.titles()?.to_lowercase();
for w in fx::FORBIDDEN_WORDS {
want(!lower.contains(w) && !titles.contains(w), || format!("{path}: {w:?} on the surface (BASE-MODEL.md: there is no balance) near: {}", http::cut(&excerpt(&lower, w), 120)))?;
}
for raw in fx::RAW_METER_NAMES.iter().filter(|_| is_screen) {
want(!text.contains(raw), || format!("{path}: raw meter name {raw} on the surface near: {}", http::cut(&excerpt(&text, raw), 120)))?;
}
b.disclose_all()?;
}
swept += 1;
}
Ok(format!("{swept} screens, both folded and unfolded: no money word anywhere, no raw meter name on any surface"))
});
}
fn excerpt(text: &str, needle: &str) -> String {
match text.find(needle) {
Some(i) => text[i.saturating_sub(40)..(i + needle.len() + 40).min(text.len())].to_owned(),
None => String::new(),
}
}
// ── the passkey rig ──────────────────────────────────────────────────────
const SETTLED: &str = "fetch('/auth/status').then(r => r.json()).then(j => j.authenticated === true || !!document.querySelector('.failure'))";
pub fn passkey(r: &Rig, chrome_bin: &str, only: Option<&str>) -> Vec<Row> {
let mut rows = Vec::new();
let Some(mut b) = Browser::start(chrome_bin, &r.work.join("chrome"), &mut rows, only) else { return rows };
let origin = r.origin();
let auth = std::cell::RefCell::new(String::new());
if !go(&mut rows, only, "a-virtual-authenticator-is-installed", || {
let id = b.c.add_authenticator(&b.sess)?;
want(!id.is_empty(), || "no authenticatorId".into())?;
*auth.borrow_mut() = id.clone();
Ok(format!("authenticator {id}"))
}) {
return rows;
}
let auth = auth.into_inner();
go(&mut rows, only, "a-stranger-is-sent-to-the-login-page", || {
b.navigate(&origin)?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.length > 20", 20_000)?;
let path = b.c.eval_str(&b.sess, "location.pathname")?;
let text = b.text()?;
want(path == "/login" || text.to_lowercase().contains("passkey"), || format!("an unauthenticated visitor landed on {path} showing {}", http::cut(&text, 150)))?;
let s = b.c.eval(&b.sess, "fetch('/api/products').then(r => r.status)")?;
want(s.as_f64() == Some(401.0), || format!("/api/products answered {} to a stranger", s.dump()))?;
Ok(format!("at {path}, api closed"))
});
let registered = go(&mut rows, only, "the-first-admin-enrols-with-the-break-glass-token", || {
b.navigate(&format!("{origin}/login"))?;
b.c.wait_for(&b.sess, "!!document.getElementById('tok')", 20_000).map_err(|e| format!("no admin-token field on the login page ({e}); is bootstrap on? page: {}", http::cut(&b.text().unwrap_or_default(), 200)))?;
b.c.eval(
&b.sess,
&format!(
"(() => {{ const set = (id, v) => {{ const el = document.getElementById(id); const proto = Object.getPrototypeOf(el); Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, v); el.dispatchEvent(new Event('input', {{bubbles:true}})); }};
set('tok', {}); set('user', 'operator'); set('label', 'virtual'); return 'set'; }})()",
esc(&r.admin_token)
),
)?;
b.click_button("Enrol as admin")?;
b.c.wait_for(&b.sess, "!!document.querySelector('.notice') || !!document.querySelector('.failure')", 30_000)?;
let text = b.text()?;
want(text.contains("enrolled") || text.contains("Enrolled"), || format!("the page said: {}", http::cut(&text, 300)))?;
// The page's word is not the evidence. The authenticator's is.
let creds = b.c.credentials(&b.sess, &auth)?;
want(creds.len() == 1, || format!("the authenticator holds {} credentials", creds.len()))?;
want(creds[0].text("rpId") == "localhost", || format!("rpId {:?}", creds[0].text("rpId")))?;
Ok(format!("1 credential for rp {}", creds[0].text("rpId")))
});
if registered {
go(&mut rows, only, "the-admin-token-retires-once-an-admin-passkey-exists", || {
let h = [("Authorization", format!("Bearer {}", r.admin_token))];
let resp = http::req(&r.ui_addr(), "GET", "/api/products", &h, None)?;
want(resp.status == 401, || format!("the token still opens /api/products: {} {}", resp.status, http::cut(&resp.body, 120)))?;
let st = http::req(&r.ui_addr(), "GET", "/auth/status", &[], None)?.json()?;
want(st.path("bootstrap").and_then(|v| v.as_bool()) == Some(false), || format!("bootstrap still on: {}", st.dump()))?;
Ok("401 — the break-glass token is spent".into())
});
}
go(&mut rows, only, "the-passkey-logs-in-as-admin", || {
b.navigate(&format!("{origin}/login"))?;
b.c.wait_for(&b.sess, "[...document.querySelectorAll('button')].some(b => b.textContent.includes('passkey'))", 20_000)?;
b.click_button("Log in with a passkey")?;
b.c.wait_for(&b.sess, SETTLED, 30_000)?;
let st = b.c.eval(&b.sess, "fetch('/auth/status').then(r => r.json())")?;
want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || format!("not authenticated: {}", http::cut(&st.dump(), 200)))?;
want(st.text("user") == "operator", || format!("logged in as {:?}", st.text("user")))?;
want(st.text("role") == "admin", || format!("role {:?}", st.text("role")))?;
Ok(format!("{} as {}", st.text("user"), st.text("role")))
});
// ★ **A stranger's login offers nobody's passkey** (owner 2026-10-06):
// the operator's key is enrolled, and a request with no session asks
// login/start. Anonymous: no credential id at all (the discoverable
// ceremony the row above just logged in with). Named: that account's keys
// among made-up ones, eight in all, and as many for a name nobody has.
go(&mut rows, only, "a-strangers-login-offers-nobodys-passkey", || {
let count = |q: &str| -> Result<usize, String> {
let j = http::req(&r.ui_addr(), "GET", &format!("/auth/passkey/login/start{q}"), &[], None)?.json()?;
j.path("publicKey.allowCredentials").map(|a| a.arr().len()).ok_or_else(|| format!("login/start{q} answered {}", j.dump()))
};
let (anonymous, named, unknown) = (count("")?, count("?user=operator")?, count("?user=nobody-here")?);
want(anonymous == 0, || format!("an anonymous login/start offers {anonymous} credential ids"))?;
want(named == 8 && unknown == 8, || format!("the named offers are {named} (operator) and {unknown} (nobody): not the same eight"))?;
Ok(format!("anonymous {anonymous}; operator {named}; a name nobody has {unknown}"))
});
go(&mut rows, only, "the-passkey-session-renders-the-estate-under-the-served-csp", || {
// The production posture of the blank-page regression: a logged-in
// operator, the real header, the real bundle. The 401s the stranger
// row deliberately collected are drained first; only THIS page's
// errors count.
let _ = b.errors();
// The Overview: every estate, each answering for itself, its map as a
// thumbnail — under the cookie session, the real header, the real bundle.
b.navigate(&origin)?;
b.c.wait_for(&b.sess, "document.querySelectorAll('.estate-row img').length >= 1 && /serving|down/.test(document.body.innerText)", 20_000).map_err(|e| format!("{e}; the page held: {}", http::cut(&b.text().unwrap_or_default(), 300)))?;
let errs = b.errors();
want(errs.is_empty(), || format!("{} page error(s): {}", errs.len(), http::cut(&errs.join(" | "), 300)))?;
// Who is logged in is under You ▾ (owner 2026-10-02): open it.
b.disclose_all()?;
let text = b.text()?;
want(text.contains("operator"), || "the shell does not name who is logged in".into())?;
Ok("the Overview's estate rows on a cookie session".into())
});
rows
}
// ── a REAL estate's monetize (no mock) ───────────────────────────────────
/// **The console against a REAL monetize** — an estate's, through its
/// `monetize.<apex>` (owner 2026-09-28: every page PASS/FAIL against the
/// orders the estate's functional test made). No fixture: every assertion
/// compares what the PAGE shows with what the console's own API says monetize
/// holds, and each page's text is kept under `work/` as the evidence.
///
/// `origin` is the console, loopback only (its passkey RP is `localhost`);
/// `admin_token` is its break-glass bearer, spent on the first passkey.
pub fn estate(chrome_bin: &str, origin: &str, admin_token: &str, work: &Path, only: Option<&str>) -> Vec<Row> {
let mut rows = Vec::new();
let _ = std::fs::create_dir_all(work);
let profile = work.join("chrome");
let _ = std::fs::remove_dir_all(&profile);
let Some(mut b) = Browser::start(chrome_bin, &profile, &mut rows, only) else { return rows };
let keep = |name: &str, text: &str| {
let _ = std::fs::write(work.join(format!("{name}.txt")), text);
};
let fetch_json = |b: &mut Browser, path: &str| -> Result<Json, String> {
let v = b.c.eval(&b.sess, &format!("fetch({}).then(async r => ({{status: r.status, body: await r.text()}}))", esc(path)))?;
let st = v.path("status").and_then(|s| s.as_f64()).unwrap_or(0.0) as u16;
let body = v.text("body");
want(st == 200, || format!("{path} answered {st}: {}", http::cut(&body, 200)))?;
crate::json::parse(&body)
};
let no_failure = |b: &mut Browser| -> Result<(), String> {
let n = b.count(".failure")?;
want(n == 0, || format!("{n} failure panel(s): {}", http::cut(&b.text().unwrap_or_default(), 300)))
};
let auth = std::cell::RefCell::new(String::new());
if !go(&mut rows, only, "a-virtual-authenticator-is-installed", || {
let id = b.c.add_authenticator(&b.sess)?;
*auth.borrow_mut() = id.clone();
Ok(format!("authenticator {id}"))
}) {
return rows;
}
let auth = auth.into_inner();
go(&mut rows, only, "login: a stranger is sent to the login page and the API is closed", || {
b.navigate(origin)?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.length > 20", 30_000)?;
let path = b.c.eval_str(&b.sess, "location.pathname")?;
let s = b.c.eval(&b.sess, "fetch('/api/products').then(r => r.status)")?;
want(s.as_f64() == Some(401.0), || format!("/api/products answered {} to a stranger", s.dump()))?;
Ok(format!("at {path}, /api/products 401"))
});
let enrolled = go(&mut rows, only, "login: the break-glass token enrols the first admin passkey", || {
b.navigate(&format!("{origin}/login"))?;
b.c.wait_for(&b.sess, "!!document.getElementById('tok')", 30_000).map_err(|e| format!("no admin-token field ({e}); page: {}", http::cut(&b.text().unwrap_or_default(), 200)))?;
b.c.eval(
&b.sess,
&format!(
"(() => {{ const set = (id, v) => {{ const el = document.getElementById(id); const proto = Object.getPrototypeOf(el); Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, v); el.dispatchEvent(new Event('input', {{bubbles:true}})); }};
set('tok', {}); set('user', 'operator'); set('label', 'virtual'); return 'set'; }})()",
esc(admin_token)
),
)?;
b.click_button("Enrol as admin")?;
b.c.wait_for(&b.sess, "!!document.querySelector('.notice') || !!document.querySelector('.failure')", 30_000)?;
let text = b.text()?;
want(text.to_lowercase().contains("enrolled"), || format!("the page said: {}", http::cut(&text, 300)))?;
let creds = b.c.credentials(&b.sess, &auth)?;
want(creds.len() == 1, || format!("the authenticator holds {} credentials", creds.len()))?;
Ok(format!("1 credential for rp {}", creds[0].text("rpId")))
});
if enrolled {
go(&mut rows, only, "login: the break-glass token retires once an admin passkey exists", || {
let st = fetch_json(&mut b, "/auth/status")?;
want(st.path("bootstrap").and_then(|v| v.as_bool()) == Some(false), || format!("bootstrap still on: {}", st.dump()))?;
Ok("bootstrap off".into())
});
}
let logged_in = go(&mut rows, only, "login: the passkey logs in as admin", || {
b.c.eval(&b.sess, "fetch('/auth/logout', {method: 'POST', headers: {'Origin': location.origin}}).then(r => r.status)")?;
b.navigate(&format!("{origin}/login"))?;
b.c.wait_for(&b.sess, "[...document.querySelectorAll('button')].some(b => b.textContent.includes('passkey'))", 30_000)?;
b.click_button("Log in with a passkey")?;
b.c.wait_for(&b.sess, SETTLED, 30_000)?;
let st = fetch_json(&mut b, "/auth/status")?;
want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || format!("not authenticated: {}", st.dump()))?;
want(st.text("role") == "admin", || format!("role {:?}", st.text("role")))?;
Ok(format!("{} as {}", st.text("user"), st.text("role")))
});
if !logged_in {
return rows;
}
// ── every estate (one console administers each estate's monetize) ──
// Owner 2026-10-02: the ESTATE is the top level. Overview is the one page
// that spans them; every other page is one estate's, under /e/<estate>/…
let overview = fetch_json(&mut b, "/api/overview").ok();
let names: Vec<String> = overview.as_ref().and_then(|e| e.path("estates")).map(|e| e.arr().iter().map(|x| x.text("name")).filter(|n| !n.is_empty()).collect()).unwrap_or_default();
let base = format!("{origin}/e/{}", names.first().cloned().unwrap_or_else(|| "local".into()));
go(&mut rows, only, "estates: the Overview is one row per estate, each with its map or a named placeholder", || {
b.navigate(origin)?;
let all = format!("document.querySelectorAll('.estate-row').length === {}", names.len().max(1));
b.c.wait_for(&b.sess, &all, 30_000).map_err(|e| format!("{} estate row(s) expected ({e}): {}", names.len(), http::cut(&b.text().unwrap_or_default(), 300)))?;
// Every row settles: no row still asking after the console's patience.
let _ = b.c.wait_for(&b.sess, "!document.body.innerText.includes('asking …')", 30_000);
let text = b.text()?;
keep("overview", &text);
let mut said = Vec::new();
for n in &names {
let s = fetch_json(&mut b, &format!("/api/estates/{n}/summary"))?;
let reached = s.path("reached").and_then(|v| v.as_bool()) == Some(true);
let row = b.c.eval_str(&b.sess, &format!("(document.querySelector('.estate-row[data-estate=\"' + {} + '\"]') || {{innerText: ''}}).innerText", esc(n)))?;
want(!row.is_empty(), || format!("no row for {n}"))?;
let named = row.contains("not reached") || row.contains("not fed this console's key");
want(named != reached, || format!("{n}: its monetize says reached={reached}, the row says {row:?}"))?;
let thumbs = b.c.eval(&b.sess, &format!("document.querySelectorAll('.estate-row[data-estate=\"' + {} + '\"] .thumb').length", esc(n)))?;
want(thumbs.as_f64() == Some(1.0), || format!("{n}: {} thumbnail(s); every estate has its map or a named placeholder", thumbs.dump()))?;
said.push(format!("{n}: {}", if reached { "answered" } else { "named as not answering" }));
}
want(!text.to_lowercase().contains("production") && !text.to_lowercase().contains("staging"), || "an estate wears a role".into())?;
no_failure(&mut b)?;
Ok(said.join(" · "))
});
if names.len() > 1 {
go(&mut rows, only, "estates: the picker groups every estate under its product, and choosing one opens that estate", || {
let api = fetch_json(&mut b, "/api/estates")?;
let serving: Vec<Json> = api.path("estates").map(|e| e.arr().iter().filter(|x| x.path("serving").and_then(|v| v.as_bool()) == Some(true)).cloned().collect()).unwrap_or_default();
b.navigate(&format!("{base}/map"))?;
b.c.wait_for(&b.sess, "document.querySelectorAll('select[aria-label=Estate] optgroup').length > 0", 30_000).map_err(|e| format!("no grouped estate picker ({e})"))?;
let mut options: Vec<String> = b.c.eval_str(&b.sess, "[...document.querySelectorAll('select[aria-label=Estate] option')].map(o => o.value).filter(v => v).join(',')")?.split(',').map(str::to_owned).collect();
options.sort();
let mut all = names.clone();
all.sort();
want(options == all, || format!("the picker offers {options:?}, the console administers {all:?}"))?;
let groups = b.c.eval_str(&b.sess, "[...document.querySelectorAll('select[aria-label=Estate] optgroup')].map(g => g.label).join(',')")?;
let first = names[0].clone();
let Some(other) = serving.iter().find(|e| e.text("name") != first).cloned() else { return Err("no second estate is serving: nothing to switch to".into()) };
let target = other.text("name");
let want_ids: Vec<String> = other.path("products").map(|p| p.arr().iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default();
b.c.eval(&b.sess, &format!("(() => {{ const s = document.querySelector('select[aria-label=Estate]'); s.value = {}; s.dispatchEvent(new Event('change', {{bubbles: true}})); return 1; }})()", esc(&target)))?;
b.c.wait_for(&b.sess, &format!("location.pathname.startsWith('/e/' + {} + '/')", esc(&target)), 30_000)?;
b.c.wait_for(&b.sess, SETTLED, 30_000)?;
for id in &want_ids {
b.wait_row(id)?;
}
keep("estate-switched", &b.text()?);
b.c.eval(&b.sess, &format!("(() => {{ const s = document.querySelector('select[aria-label=Estate]'); s.value = {}; s.dispatchEvent(new Event('change', {{bubbles: true}})); return 1; }})()", esc(&first)))?;
b.c.wait_for(&b.sess, &format!("location.pathname.startsWith('/e/' + {} + '/')", esc(&first)), 30_000)?;
Ok(format!("groups {groups}; {target} shows {} and back to {first}", want_ids.join(", ")))
});
} else {
go(&mut rows, only, "estates: a console of one estate names it in the picker", || {
b.navigate(&format!("{base}/map"))?;
b.c.wait_for(&b.sess, SETTLED, 30_000)?;
let n = names.first().cloned().unwrap_or_default();
b.c.wait_for(&b.sess, &format!("[...document.querySelectorAll('select[aria-label=Estate] option')].some(o => o.value === {} && o.selected)", esc(&n)), 30_000)?;
Ok(format!("{n}, selected"))
});
}
// ── products ──
let products = std::cell::RefCell::new(Vec::<String>::new());
go(&mut rows, only, "products: every product monetize serves is a row", || {
let api = fetch_json(&mut b, "/api/products")?;
let ids: Vec<String> = api.path("products").map(|p| p.arr().iter().map(|x| x.text("id")).collect()).unwrap_or_default();
want(!ids.is_empty(), || format!("monetize serves no product: {}", http::cut(&api.dump(), 200)))?;
let _ = b.errors();
b.navigate(&format!("{base}/map"))?;
let mut seen = Vec::new();
for id in &ids {
seen.push(b.wait_row(id)?);
}
let text = b.text()?;
keep("products", &text);
let errs = b.errors();
want(errs.is_empty(), || format!("page errors: {}", http::cut(&errs.join(" | "), 300)))?;
no_failure(&mut b)?;
*products.borrow_mut() = ids.clone();
Ok(format!("{} product(s): {}", ids.len(), seen.join(" / ")))
});
let products = products.into_inner();
// ── tenants, per product ──
let tenants = std::cell::RefCell::new(Vec::<(String, String)>::new());
go(&mut rows, only, "tenants: each product's tenants are rows", || {
let mut said = Vec::new();
for p in &products {
let api = fetch_json(&mut b, &format!("/api/products/{p}/tenants"))?;
let ids: Vec<String> = api.path("tenants").map(|t| t.arr().iter().map(|x| x.text("tenant")).filter(|t| !t.is_empty()).collect()).unwrap_or_default();
b.navigate(&format!("{base}/customers?product={p}"))?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.length > 40", 30_000)?;
// The page folds the tail of a long list: unfold it first.
std::thread::sleep(std::time::Duration::from_millis(500));
b.disclose_all()?;
for t in &ids {
tenants.borrow_mut().push((p.clone(), t.clone()));
}
let all = format!("[{}].every(t => document.body.innerText.includes(t))", ids.iter().map(|t| esc(t)).collect::<Vec<_>>().join(","));
let seen = b.c.wait_for(&b.sess, &all, 20_000);
b.disclose_all()?;
let text = b.text()?;
keep(&format!("tenants-{p}"), &text);
let missing: Vec<&String> = ids.iter().filter(|t| !text.contains(t.as_str())).collect();
want(seen.is_ok() || missing.is_empty(), || format!("{p}: monetize lists {} tenant(s); the page shows none of {missing:?}", ids.len()))?;
no_failure(&mut b)?;
said.push(format!("{p}: {} tenant(s) {}", ids.len(), ids.iter().take(8).cloned().collect::<Vec<_>>().join(", ")));
}
Ok(said.join(" · "))
});
let tenants = tenants.into_inner();
go(&mut rows, only, "tenant: the tenant view names its product and its tenant", || {
let mut said = Vec::new();
for (p, t) in tenants.iter() {
b.navigate(&format!("{base}/customer?product={p}&tenant={}", t.replace('/', "%2F")))?;
b.wait_text(t)?;
// Loaded = nothing on the page still says it is loading or checking.
let settled = b.c.wait_for(&b.sess, "document.body && document.body.innerText.length > 80 && !/Loading …|checking …|loading the/.test(document.body.innerText)", 30_000);
let text = b.text()?;
keep(&format!("tenant-{p}-{}", t.replace('/', "_")), &text);
settled.map_err(|e| format!("{p}/{t} is still loading after 30 s ({e}): {}", http::cut(&text, 300)))?;
keep(&format!("tenant-{p}-{}", t.replace('/', "_")), &text);
want(text.contains(p.as_str()), || format!("the tenant page for {t} does not name {p}: {}", http::cut(&text, 300)))?;
no_failure(&mut b)?;
said.push(format!("{p}/{t}"));
}
want(!said.is_empty(), || "no tenant to open".into())?;
Ok(said.join(", "))
});
// ── the ledger: orders, their states, amounts ──
go(&mut rows, only, "orders: every recent order in monetize's ledger is on the ledger page, with its state", || {
let api = fetch_json(&mut b, "/api/ledger")?;
let mut refs: Vec<String> = Vec::new();
let mut states: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for r in api.path("recent").map(|r| r.arr().to_vec()).unwrap_or_default() {
let reference = r.text("reference");
if !refs.contains(&reference) {
refs.push(reference.clone());
}
states.entry(reference).or_default().push(r.text("kind"));
}
want(!refs.is_empty(), || format!("monetize's ledger holds no order: {}", http::cut(&api.dump(), 200)))?;
b.navigate(&format!("{base}/money"))?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.includes('History')", 30_000)?;
b.disclose_all()?;
let text = b.text()?;
let titles = b.titles()?;
keep("ledger", &text);
keep("ledger-titles", &titles);
let missing: Vec<&String> = refs.iter().filter(|r| {
let tenant = r.split('/').nth(1).unwrap_or(r);
!text.contains(r.as_str()) && !titles.contains(r.as_str()) && !text.contains(tenant)
}).collect();
want(missing.is_empty(), || format!("orders in the ledger but not on the page: {missing:?}"))?;
no_failure(&mut b)?;
Ok(states.iter().map(|(r, k)| format!("{r}: {}", k.join("→"))).collect::<Vec<_>>().join(" | "))
});
// Each panel draws NOTHING when its read is empty (by design: the absence
// is the good state). So: what the API holds decides what must be seen.
for (name, path, list, heading) in [
("signups: the paid signups panel shows every paid signup", "/api/signups", "signups", "Paid signups"),
("refusals: the refused vendor deliveries panel shows every refusal", "/api/callback-refusals", "refusals", "refus"),
("paid at vendor: the panel of payments monetize did not fulfil", "/api/paid-at-vendor", "cases", "paid at the vendor"),
] {
go(&mut rows, only, name, || {
let api = fetch_json(&mut b, path)?;
let n = api.path(list).map(|l| l.arr().len()).unwrap_or(0);
b.navigate(&format!("{base}/money"))?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.includes('History')", 30_000)?;
if n == 0 {
std::thread::sleep(std::time::Duration::from_secs(3));
let text = b.text()?;
want(!text.to_lowercase().contains(&heading.to_lowercase()), || format!("{path} holds nothing and the page still shows {heading:?}"))?;
no_failure(&mut b)?;
return Ok(format!("{path}: 0 rows, and the panel is absent (the good state)"));
}
let seen = b.c.wait_for(&b.sess, &format!("document.body.innerText.toLowerCase().includes({})", esc(&heading.to_lowercase())), 20_000);
let text = b.text()?;
keep(&format!("ledger-{list}"), &text);
seen.map_err(|e| format!("{path} holds {n} row(s) and the page never showed {heading:?} within 20 s ({e}): {}", http::cut(&text, 300)))?;
no_failure(&mut b)?;
Ok(format!("{n} row(s): {}", around(&text, heading)))
});
}
go(&mut rows, only, "tax: the tax page loads", || {
b.navigate(&format!("{base}/tax"))?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.length > 60", 30_000)?;
std::thread::sleep(std::time::Duration::from_secs(2));
let text = b.text()?;
keep("tax", &text);
no_failure(&mut b)?;
Ok(http::cut(&text, 160))
});
// Owner 2026-09-28: the estate's [tax] links its jurisdiction (DK, 25 %),
// so the page is no longer "no jurisdiction is linked" with every box "—".
go(&mut rows, only, "tax: a jurisdiction is linked, not the loud default", || {
let api = fetch_json(&mut b, "/api/tax/periods?year=2026")?;
let j = api.text("jurisdiction");
want(!j.is_empty() && j != "—", || format!("/api/tax names no jurisdiction: {}", http::cut(&api.dump(), 300)))?;
let text = std::fs::read_to_string(work.join("tax.txt")).unwrap_or_default();
want(!text.contains("no jurisdiction is linked"), || format!("the page still says no jurisdiction is linked: {}", http::cut(&text, 300)))?;
Ok(format!("jurisdiction {j}"))
});
// Owner 2026-09-28: the VAT per order — every sale the API lists that
// charged anything carries its tax, and the page shows it.
go(&mut rows, only, "ledger: every sale carries its VAT", || {
let l = fetch_json(&mut b, "/api/ledger")?;
let sales: Vec<Json> = l.path("sales").map(|s| s.arr().to_vec()).unwrap_or_default();
let charged: Vec<&Json> = sales.iter().filter(|s| s.text("outcome") != "declined").collect();
let untaxed: Vec<String> = charged.iter().filter(|s| s.path("tax_minor").and_then(|t| t.as_f64()).is_none()).map(|s| s.text("reference")).collect();
want(untaxed.is_empty(), || format!("sales with no tax on the wire: {untaxed:?}"))?;
if charged.is_empty() {
return Ok("no sale charged anything yet: nothing to show".into());
}
b.navigate(&format!("{base}/money"))?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.includes('VAT ')", 30_000)?;
let text = b.text()?;
Ok(format!("{} sale(s) with their VAT: {}", charged.len(), around(&text, "VAT ")))
});
// Owner 2026-09-28: a MACHINE principal is no tenant (gunnar says so at
// the source, Namespace.owner_kind).
go(&mut rows, only, "tenants: no machine principal is listed as a tenant", || {
let machines = ["front-certify", "gunnar-guest", "gunnar-twin", "gunnar-ui", "installer-seed", "monetize", "twin", "dwarves-oden", "monetize-ui", "monetize-ui-bench"];
let listed: Vec<String> = tenants.iter().map(|(_, t)| t.clone()).filter(|t| machines.contains(&t.as_str())).collect();
want(listed.is_empty(), || format!("machine principals listed as tenants: {listed:?}"))?;
Ok(format!("{} tenant(s), none a machine", tenants.len()))
});
// ── live: an order that changes state reaches an open page without a reload ──
// The live probe needs a tenant the product can serve NOW (a bundle with
// `can_serve`) and with no attempt already open (that one would be handed
// back, which is no NEW order). Tenants that have paid before come first:
// the others may be machine principals no product sells room to.
let pick: Option<(String, String, String)> = (|| {
let l = fetch_json(&mut b, "/api/ledger").ok()?;
let open: Vec<String> = l.path("unfinished").map(|u| u.arr().iter().filter_map(|x| x.text("reference").split('/').nth(1).map(str::to_owned)).collect()).unwrap_or_default();
let paid: Vec<String> = l.path("sales").map(|s| s.arr().iter().map(|x| x.text("tenant")).collect()).unwrap_or_default();
let mut order: Vec<(String, String)> = tenants.iter().filter(|(_, t)| paid.contains(t)).cloned().collect();
order.extend(tenants.iter().filter(|(_, t)| !paid.contains(t)).cloned());
for (p, t) in order.into_iter().filter(|(_, t)| !open.contains(t)) {
let bundles = fetch_json(&mut b, &format!("/api/bundles?product={p}&tenant={t}")).ok()?;
if let Some(id) = bundles.path("bundles").and_then(|x| x.arr().iter().find(|b| b.path("can_serve").and_then(|v| v.as_bool()) == Some(true)).map(|b| b.text("id"))) {
return Some((p, t, id));
}
}
None
})();
if let Some((p, t, bundle)) = pick {
go(&mut rows, only, "sse: a new order reaches the open ledger page live (no reload)", || {
b.navigate(&format!("{base}/money"))?;
b.c.wait_for(&b.sess, "document.body && document.body.innerText.includes('History')", 30_000)?;
std::thread::sleep(std::time::Duration::from_secs(2));
let rows_before = b.count(".sale-row")?;
b.c.eval(&b.sess, "(() => { window.__feed = 0; const es = new EventSource('/api/events'); es.addEventListener('feed', () => window.__feed++); window.__es = es; return 1; })()")?;
let started = b.c.eval(
&b.sess,
&format!(
"fetch('/api/purchase', {{method: 'POST', headers: {{'content-type': 'application/json', 'Origin': location.origin}}, body: JSON.stringify({{product: {}, tenant: {}, caps: {{}}, bundle: {}}})}}).then(async r => ({{status: r.status, body: await r.text()}}))",
esc(&p), esc(&t), esc(&bundle)
),
)?;
let body = started.text("body");
want(started.path("status").and_then(|s| s.as_f64()) == Some(200.0), || format!("the order was not started: {}", http::cut(&started.dump(), 300)))?;
let reference = crate::json::parse(&body).map(|v| v.text("reference")).unwrap_or_default();
want(!reference.is_empty(), || format!("no reference in {}", http::cut(&body, 200)))?;
b.c.wait_for(&b.sess, "window.__feed > 0", 30_000).map_err(|e| format!("no feed event within 30 s: {e}"))?;
// The new attempt is a NEW ROW naming the tenant ("Needs you": money
// reserved, not yet captured) — the page does not print the reference.
let on_page = format!("document.querySelectorAll('.sale-row').length > {rows_before} && [...document.querySelectorAll('.sale-row')].some(e => e.innerText.includes({}))", esc(&t));
let live = b.c.wait_for(&b.sess, &on_page, 30_000);
let n = b.c.eval(&b.sess, "window.__feed")?.as_f64().unwrap_or(0.0);
let held = b.text()?;
keep("ledger-live", &held);
// Whether a RELOAD shows it tells a dead live path from a page that never draws it.
let after_reload = if live.is_err() {
b.navigate(&format!("{base}/money"))?;
let r = b.c.wait_for(&b.sess, &on_page, 20_000).is_ok();
keep("ledger-reloaded", &b.text()?);
Some(r)
} else {
None
};
// Leave nothing open: the reservation is given back.
let _ = b.c.eval(&b.sess, &format!("fetch('/api/purchase/abandon', {{method: 'POST', headers: {{'content-type': 'application/json', 'Origin': location.origin}}, body: JSON.stringify({{reference: {}, reason: 'uitest: the live-feed probe gives its reservation back'}})}}).then(r => r.status)", esc(&reference)));
match after_reload {
None => Ok(format!("{reference} ({bundle}) on the page live; {n} feed event(s)")),
Some(true) => Err(format!("{reference}: {n} feed event(s) reached the page but it did not redraw; a RELOAD shows the order — the live path is broken")),
Some(false) => Err(format!("{reference}: {n} feed event(s); not on the page live NOR after a reload: {}", http::cut(&held, 300))),
}
});
} else {
go(&mut rows, only, "sse: a new order reaches the open ledger page live (no reload)", || Err("no tenant the product can serve now without an attempt already open — nothing to order".into()));
}
rows
}
/// The line of `text` that mentions `needle` (case-insensitively), whole.
fn around(text: &str, needle: &str) -> String {
let n = needle.to_lowercase();
text.lines().find(|l| l.to_lowercase().contains(&n)).map(|l| http::cut(l, 160)).unwrap_or_default()
}
// ── pictures: every page at desktop and at phone width ───────────────────
/// **The console as it LOOKS**, at 1440 px and at 390 px (owner 2026-10-02:
/// a visual check at desktop and phone width, no horizontal page scroll).
///
/// The Overview, every estate's Map, and every section of the first estate,
/// each saved as `<dir>/<desktop|phone>-<page>.png`. One row per picture,
/// and the row is RED when the page scrolls sideways — the document, or any
/// `.page` that scrolls inside it — since a phone reader would have to pan.
/// Fixture-free: it asserts layout only, so it runs against the rig, a
/// `--live` console, or a demo of several estates alike.
pub fn shots(chrome_bin: &str, origin: &str, dir: &Path, only: Option<&str>) -> Vec<Row> {
let mut rows = Vec::new();
let _ = std::fs::create_dir_all(dir);
let profile = std::env::temp_dir().join(format!("monetize-uitest-shots-{}", std::process::id()));
let Some(mut b) = Browser::start(chrome_bin, &profile, &mut rows, only) else { return rows };
let hostport = origin.trim_start_matches("http://");
let names: Vec<String> = http::req(hostport, "GET", "/api/overview", &[], None)
.ok()
.and_then(|r| crate::json::parse(&r.body).ok())
.and_then(|j| j.path("estates").map(|e| e.arr().iter().map(|x| x.text("name")).filter(|n| !n.is_empty()).collect()))
.unwrap_or_default();
let mut pages: Vec<(String, String)> = vec![("overview".into(), "/".into())];
for n in &names {
pages.push((format!("{n}-map"), format!("/e/{n}/map")));
}
if let Some(n) = names.first() {
for s in ["customers", "money", "tax", "offer", "events", "settings"] {
pages.push((format!("{n}-{s}"), format!("/e/{n}/{s}")));
}
pages.push((format!("{n}-customer"), format!("/e/{n}/customer?product=gunnar&tenant=alice")));
}
for (name, path) in pages {
for (w, h, tag) in [(1440u32, 1000u32, "desktop"), (390, 844, "phone")] {
let row: &'static str = Box::leak(format!("picture-{tag}-{name}").into_boxed_str());
go(&mut rows, only, row, || {
b.c.viewport(&b.sess, w, h)?;
b.navigate(&format!("{origin}{path}"))?;
// Settled: the page has drawn its words and no row is still asking.
let _ = b.c.wait_for(&b.sess, "document.body && document.body.innerText.length > 60 && !/asking …|Loading …|checking …/.test(document.body.innerText)", 15_000);
std::thread::sleep(std::time::Duration::from_millis(700));
let over = b.c.eval(
&b.sess,
"(() => { const d = document.documentElement.scrollWidth - window.innerWidth;
const inner = [...document.querySelectorAll('.page')].map(p => p.scrollWidth - p.clientWidth);
return Math.max(d, ...inner, 0); })()",
)?;
let px = over.as_f64().unwrap_or(0.0);
let file = dir.join(format!("{tag}-{name}.png"));
let bytes = b.c.screenshot(&b.sess, &file)?;
want(px <= 1.0, || {
let wide = b
.c
.eval_str(
&b.sess,
"[...document.querySelectorAll('.page *')].filter(e => e.getBoundingClientRect().right > window.innerWidth + 1).slice(0, 4).map(e => e.tagName + '.' + (e.className || '')).join(' ')",
)
.unwrap_or_default();
format!("{path} at {w} px scrolls sideways by {px} px ({}); widest: {wide}", file.display())
})?;
Ok(format!("{} ({} kB)", file.display(), bytes / 1024))
});
}
}
let _ = std::fs::remove_dir_all(&profile);
rows
}