uitest/src/places.rs
//! **Where things live, and what a person may see** (owner, 2026-10-02) — the
//! rows for the storage information architecture, the Overview moving under
//! Administration, the events a person is shown, plain page titles, and the
//! buy button that goes straight to the vendor.
//!
//! Every row drives the console as a person does and asserts what the page
//! DREW (or where the browser LANDED), against the server's own answer where
//! there is one. `admin` is bob's session, `person` kim's — a viewer in an
//! isolated browser context with an authenticator of their own.
use std::time::{Duration, Instant};
use crate::cdp::Cdp;
use crate::http;
use crate::json::esc;
use crate::matrix::{go, want, Row};
use crate::rig::Rig;
const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";
/// The SHA-256 fingerprint of the host key the rig's SSH listener presents,
/// read with stock OpenSSH (`ssh-keyscan | ssh-keygen -lf -`) — what a user's
/// ssh client shows on first connect.
fn presented_host_key(r: &Rig) -> Result<String, String> {
let scan = std::process::Command::new("ssh-keyscan")
.args(["-t", "ed25519", "-p", &r.ssh.to_string(), "127.0.0.1"])
.output()
.map_err(|e| format!("ssh-keyscan: {e}"))?;
let lines = String::from_utf8_lossy(&scan.stdout).into_owned();
let mut keygen = std::process::Command::new("ssh-keygen")
.args(["-lf", "-"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("ssh-keygen: {e}"))?;
{
use std::io::Write as _;
keygen.stdin.take().ok_or("no stdin")?.write_all(lines.as_bytes()).map_err(|e| e.to_string())?;
}
let out = keygen.wait_with_output().map_err(|e| e.to_string())?;
let said = String::from_utf8_lossy(&out.stdout).into_owned();
said.split_whitespace()
.find(|w| w.starts_with("SHA256:"))
.map(str::to_owned)
.ok_or_else(|| format!("no fingerprint from ssh-keyscan/ssh-keygen: {said:?} (scan {lines:?})"))
}
/// The top bar's entries, as `(menu, label, href)`: menu is `main`, `You` or
/// `Administration`.
/// The viewport as a PHONE (`mobile: true`): overlay scrollbars, as a phone draws them. A
/// desktop viewport of 390 px with a classic 15 px scrollbar is a 375 px screen, so the row
/// about how a phone's bar fits measures on this one. (Here, not in `cdp.rs`, which monetize
/// keeps a checked copy of.)
fn viewport_phone(c: &mut Cdp, sess: &str, w: u32, h: u32) -> Result<(), String> {
c.call(
Some(sess),
"Emulation.setDeviceMetricsOverride",
&format!(r#"{{"width":{w},"height":{h},"deviceScaleFactor":1,"mobile":true}}"#),
)?;
Ok(())
}
fn menus(c: &mut Cdp, sess: &str) -> Result<Vec<(String, String, String)>, String> {
c.wait_for(sess, "!!document.querySelector('header.topbar a.top-link')", 30_000)?;
let j = c.eval(
sess,
"[...document.querySelectorAll('header.topbar a.top-link, header.topbar details.menu a.menu-link')].map(a => {
const d = a.closest('details');
const menu = !d ? 'main' : (d.classList.contains('admin-menu') ? 'Administration' : 'You');
return [menu, a.textContent.trim(), a.getAttribute('href')]; })",
)?;
Ok(j
.arr()
.iter()
.map(|p| {
let at = |i: usize| p.arr().get(i).and_then(|x| x.as_str()).unwrap_or("").to_owned();
(at(0), at(1), at(2))
})
.collect())
}
/// Wait until the page has drawn a page header.
fn header(c: &mut Cdp, sess: &str) -> Result<(String, String), String> {
c.wait_for(sess, "!!document.querySelector('header.page-header h1')", 30_000)?;
let h1 = c.eval_str(sess, "document.querySelector('header.page-header h1').textContent.trim()")?;
let line = c.eval_str(sess, "(document.querySelector('header.page-header p') || {textContent: ''}).textContent.trim()")?;
Ok((h1, line))
}
/// The panels drawn on the page, by heading.
fn panels(c: &mut Cdp, sess: &str) -> Result<Vec<String>, String> {
let j = c.eval(sess, "[...document.querySelectorAll('section.panel > header h2')].map(h => h.textContent.trim())")?;
Ok(j.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect())
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(T[(n >> 18) as usize & 63] as char);
out.push(T[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });
}
out
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, admin: &str, person: &str) {
// ── a person lands on Repositories, headed by the server key ───────────
go(rows, "a-person-lands-on-repositories-headed-by-the-server-key", || {
let want_fp = presented_host_key(r)?;
c.navigate(person, &r.origin())?;
c.wait_for(person, "location.pathname === '/repos'", 30_000)
.map_err(|e| format!("a person opening the console is not taken to Repositories: {e}"))?;
let (h1, _) = header(c, person)?;
want(h1 == "Repositories", || format!("the landing page is headed {h1:?}"))?;
c.wait_for(person, "(document.querySelector('header.page-header p') || {textContent:''}).textContent.includes('SHA256:')", 30_000)?;
let (_, line) = header(c, person)?;
want(line == format!("Server key {want_fp}"), || {
format!("the header's second line is {line:?}; the SSH listener presents {want_fp}")
})?;
Ok(format!("landed on /repos, line 2 {line:?} = what ssh-keyscan reads"))
});
// ── the top bar: what a person and an administrator are offered ───────
go(rows, "the-top-bar-offers-a-person-no-administration", || {
let mine = menus(c, person)?;
let main: Vec<&str> = mine.iter().filter(|(m, ..)| m == "main").map(|(_, l, _)| l.as_str()).collect();
want(main == ["Repositories"], || format!("the top bar's main entries are {main:?}"))?;
want(!mine.iter().any(|(m, ..)| m == "Administration"), || format!("a person is offered Administration: {mine:?}"))?;
let you: Vec<(&str, &str)> = mine.iter().filter(|(m, ..)| m == "You").map(|(_, l, h)| (l.as_str(), h.as_str())).collect();
want(!you.iter().any(|(l, _)| *l == "Storage") && you.contains(&("Activity", "/account/activity")) && you.contains(&("Account", "/account")), || format!("You ▾ is {you:?}"))?;
let logout = c.eval_str(person, "(document.querySelector('header.topbar details.menu .menu-logout') || {textContent:''}).textContent.trim()")?;
want(logout == "Log out", || format!("You ▾ has no Log out: {logout:?}"))?;
for gone in ["/storage", "/usage", "/events", "/overview", "/"] {
want(!mine.iter().any(|(_, _, h)| h == gone), || format!("{gone} is still on a person's top bar: {mine:?}"))?;
}
let theirs = menus(c, admin)?;
let admin_menu: Vec<&str> = theirs.iter().filter(|(m, ..)| m == "Administration").map(|(_, l, _)| l.as_str()).collect();
// Owner, 2026-10-04: Administration ▾ Overview · Storage · Identities ·
// Mirrors · Instruments — the Machine page is a card on the Overview;
// company SSO (2026-10-05) adds the Identity provider after Identities,
// and the owner's enterprise list (2026-10-05) Accounts (the totals)
// after Overview and Audit log before Instruments.
want(admin_menu == ["Overview", "Accounts", "Storage", "Identities", "Identity provider", "Mirrors", "Audit log", "Instruments"], || format!("Administration ▾ is {admin_menu:?}"))?;
Ok(format!("person: main {main:?}, You {you:?}; admin: Administration {admin_menu:?}"))
});
// ── inside a repository: the tabs ──────────────────────────────────────
go(rows, "a-repository-has-one-tab-row-readme-first", || {
c.navigate(admin, &format!("{}/repo/alice/uitest", r.origin()))?;
c.wait_for(admin, "document.querySelectorAll('nav.repo-tabs a').length >= 5", 30_000)?;
let tabs = c.eval(admin, "[...document.querySelectorAll('nav.repo-tabs a')].map(a => a.textContent.trim())")?;
let tabs: Vec<String> = tabs.arr().iter().filter_map(|x| x.as_str().map(str::to_owned)).collect();
want(tabs == ["Readme", "Code", "History", "Compare", "Search", "Blame", "Pulls", "Issues", "Activity", "Settings"], || format!("the admin's tabs are {tabs:?}"))?;
let on = c.eval_str(admin, "(document.querySelector('nav.repo-tabs a.on') || {textContent:''}).textContent.trim()")?;
want(on == "Readme", || format!("the repository's home marks {on:?}"))?;
c.eval(admin, "[...document.querySelectorAll('nav.repo-tabs a')].find(a => a.textContent.trim() === 'Activity').click()")?;
// GUN-GUEST (2026-10-30): a repository is `/{ns}/{name}`, its tabs below it.
c.wait_for(admin, "location.pathname === '/alice/uitest/activity'", 30_000)?;
c.wait_for(admin, "(document.querySelector('nav.repo-tabs a.on') || {textContent:''}).textContent.trim() === 'Activity'", 30_000)?;
c.wait_for(admin, &format!("{MAIN_TEXT}.includes('Stream')"), 30_000)?;
// A person's tabs: no Settings.
c.navigate(person, &format!("{}/repos", r.origin()))?;
Ok(format!("tabs {tabs:?}; Activity opens the repository's own stream"))
});
// ── the old addresses lead a person to their own ───────────────────────
go(rows, "the-old-storage-and-usage-addresses-lead-a-person-to-their-own", || {
let mut trail = Vec::new();
for (from, to) in [
("/storage", "/account"),
("/usage", "/account"),
("/usage/alice", "/account"),
("/admin/overview", "/repos"),
("/admin/storage", "/account"),
("/overview", "/repos"),
("/events", "/account/activity"),
("/access", "/account"),
("/machine", "/repos"),
] {
c.navigate(person, &format!("{}{from}", r.origin()))?;
c.wait_for(person, &format!("location.pathname === {}", esc(to)), 30_000)
.map_err(|e| format!("{from} did not lead a person to {to}: {e}"))?;
trail.push(format!("{from} → {to}"));
}
Ok(trail.join(", "))
});
// ── /account/storage is no page of its own (owner 2026-10-17) ─────────
// It goes on to the ONE sign-up page in the account's signed-in mode.
go(rows, "account-storage-goes-on-to-the-signup-page", || {
want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
c.navigate(person, &format!("{}/account/storage", r.origin()))?;
c.wait_for(person, "location.pathname === '/signup' && ['Buy a plan', 'Grow your account'].includes(document.getElementById('heading')?.textContent)", 30_000)
.map_err(|e| format!("/account/storage did not go on to the sign-up page's Buy a plan / Grow your account: {e}"))?;
Ok(c.eval_str(person, "location.pathname + location.search")?)
});
// ── Administration → Storage ───────────────────────────────────────────
go(rows, "the-admin-sees-the-machine-under-administration-storage", || {
c.navigate(admin, &format!("{}/admin/storage", r.origin()))?;
let deadline = Instant::now() + Duration::from_secs(30);
let mut seen = Vec::new();
while Instant::now() < deadline {
seen = panels(c, admin)?;
if seen.iter().any(|p| p == "Engine") && seen.iter().any(|p| p == "Disks") {
break;
}
std::thread::sleep(Duration::from_millis(200));
}
want(seen.iter().any(|p| p == "Engine") && seen.iter().any(|p| p == "Disks"), || format!("panels {seen:?}"))?;
let st = c.eval(admin, "fetch('/api/storage').then(r => r.json())")?;
let (name, desc, url) = (st.text("engine.name"), st.text("engine.description"), st.text("engine.repo_url"));
want(!name.is_empty(), || format!("/api/storage names no engine in parts: {}", http::cut(&st.dump(), 200)))?;
let link = c.eval(
admin,
"(() => { const dt = [...document.querySelectorAll('dt')].find(e => e.textContent.trim() === 'Engine');
const a = dt && dt.nextElementSibling && dt.nextElementSibling.querySelector('a');
return a ? [a.textContent.trim(), a.getAttribute('href'), a.getAttribute('title')] : null; })()",
)?;
let parts: Vec<String> = link.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect();
want(parts == [name.clone(), url.clone(), desc.clone()], || {
format!("the Engine row is {parts:?}; gunnar says name {name:?}, source {url:?}, description {desc:?}")
})?;
let text = c.eval_str(admin, MAIN_TEXT)?;
for na in ["N/A", "not reported", "Write amplification", "Priority transfer"] {
want(!text.contains(na), || format!("a row gunnar did not report: {na:?} in {}", http::cut(&text, 300)))?;
}
let data_sets = seen.iter().filter(|p| *p == "Data set").count();
want(data_sets <= 1, || format!("the Data set is drawn {data_sets} times"))?;
Ok(format!("panels {seen:?}; Engine = <a href={url:?} title={desc:?}>{name}</a>"))
});
// ── Administration → Overview, and the console's own identity ──────────
go(rows, "the-overview-is-the-admins-and-carries-no-console-identity", || {
let fp = presented_host_key(r)?;
c.navigate(admin, &format!("{}/admin/overview", r.origin()))?;
let (h1, _) = header(c, admin)?;
want(h1 == "Overview", || format!("headed {h1:?}"))?;
c.wait_for(admin, "(document.querySelector('header.page-header p') || {textContent:''}).textContent.includes('SHA256:')", 30_000)?;
let (_, line) = header(c, admin)?;
want(line == format!("Server key {fp}"), || format!("line 2 {line:?}, ssh presents {fp}"))?;
let seen = panels(c, admin)?;
want(!seen.iter().any(|p| p.contains("own identity")), || format!("the Overview still shows the console's identity: {seen:?}"))?;
c.navigate(admin, &format!("{}/admin", r.origin()))?;
c.wait_for(admin, &format!("{MAIN_TEXT}.includes(\"The console's own identity\")"), 30_000)
.map_err(|e| format!("the console's identity is not on Administration either: {e}"))?;
Ok(format!("Overview: {line:?}, panels {seen:?}; the identity is under Instruments"))
});
// ── the Machine card, and no Machine page (owner, 2026-10-04) ──────────
go(rows, "the-machine-page-is-gone-and-its-address-leads-to-the-overview", || {
c.navigate(admin, &format!("{}/machine", r.origin()))?;
c.wait_for(admin, "location.pathname === '/admin/overview'", 30_000)
.map_err(|e| format!("/machine did not lead the administrator to the Overview: {e}"))?;
Ok("/machine → /admin/overview".into())
});
go(rows, "the-overview-draws-the-machine-card-as-gunnar-said", || {
c.navigate(admin, &format!("{}/admin/overview", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('.machine-card .estate-picture svg') && document.querySelectorAll('.machine-card ul.glance li').length > 0", 30_000)
.map_err(|e| format!("no Machine card with an inline picture and its lines: {e}"))?;
let seen = panels(c, admin)?;
want(seen.first().map(String::as_str) == Some("Machine"), || format!("the Machine card is not the Overview's first panel: {seen:?}"))?;
// The picture: the shared renderer's live board of Admin.Topology,
// INLINE (swapped in place, never an <img> that blanks), one named
// group per box gunnar reported.
let topo = c.eval(admin, "fetch('/api/machine/topology').then(r => r.json())")?;
let boxes = topo.path("boxes").map(|b| b.arr().len()).unwrap_or(0);
let drawn = c.eval_str(admin, "String(document.querySelectorAll('.machine-card .estate-picture svg g[data-box]').length)")?;
want(drawn == boxes.to_string(), || format!("the card draws {drawn} box(es); gunnar has {boxes}"))?;
let imgs = c.eval_str(admin, "String(document.querySelectorAll('.machine-card img').length)")?;
want(imgs == "0", || "the card still draws the picture as an <img>".into())?;
let alt = c.eval_str(admin, "document.querySelector('.machine-card .estate-picture').getAttribute('aria-label')")?;
want(alt.contains(&format!("{boxes} box")), || format!("the picture's text says {alt:?}; gunnar has {boxes} box(es)"))?;
// The lines: gunnar's, in gunnar's order, each with gunnar's verdict.
let api = c.eval(admin, "fetch('/api/machine').then(r => r.json())")?;
let want_states: Vec<String> = api.path("glance").map(|g| g.arr().iter().map(|x| format!("{}={}", x.text("key"), x.text("state"))).collect()).unwrap_or_default();
let drawn = c.eval(admin, "[...document.querySelectorAll('.machine-card ul.glance li')].map(li => li.dataset.key + '=' + li.dataset.state)")?;
let drawn: Vec<String> = drawn.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect();
want(drawn == want_states, || format!("the card draws {drawn:?}; gunnar says {want_states:?}"))?;
let tones = c.eval(admin, "[...document.querySelectorAll('.machine-card ul.glance li')].map(li => li.dataset.state + ':' + li.querySelector('.badge').className)")?;
for t in tones.arr().iter().filter_map(|x| x.as_str()) {
let (state, class) = t.split_once(':').unwrap_or(("", ""));
let tone = match state { "ok" => "pass", "warn" => "warn", "bad" => "bad", _ => "plain" };
want(class.split_whitespace().any(|c| c == tone), || format!("gunnar said {state}, the badge is {class:?}"))?;
}
Ok(format!("picture of {boxes} box(es); lines {drawn:?}"))
});
// ★ **The picture follows the console's theme, live** (owner, 2026-10-05:
// "the SVG is nice BUT make it follow the chosen theme of the web site; we
// have 2 themes"). The drawing names its colours as page tokens
// (`var(--estate-…, skade's)`) and console.css maps them onto the theme's
// own; so the colours a browser PAINTS the picture with — read off it
// with getComputedStyle, not off the markup — must be the console's
// tokens in the light theme, the dark theme's after the header's ☾ switch
// is pressed, the surprise theme's own board after ✦ (owner, same day: a
// third theme), and the light's again after ☀, on the SAME picture: no
// fetch, no redraw. And the boxes read in order (owner, same day): this
// gunnar's main box on the top row, the console that calls it below.
go(rows, "the-estate-picture-follows-the-theme-switch-live", || {
c.navigate(admin, &format!("{}/admin/overview", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('.machine-card .estate-picture svg g[data-box] rect') && !!document.querySelector('button.theme-toggle')", 30_000)
.map_err(|e| format!("no inline picture or no theme switch: {e}"))?;
let topo = c.eval(admin, "fetch('/api/machine/topology').then(r => r.json())")?;
let boxes = topo.path("boxes").map(|b| b.arr().to_vec()).unwrap_or_default();
let me = boxes.iter().find(|b| b.path("this_box").and_then(|v| v.as_bool()) == Some(true)).map(|b| b.text("name")).ok_or("gunnar names no box as this box")?;
let console = boxes.iter().find(|b| b.text("role") == "console").map(|b| b.text("name")).ok_or("no console box on the rig")?;
// Start light whatever the last row left: the switch walks ☀ → ☾ → ✦
// and round, so light is at most two presses away.
let light = "!document.documentElement.hasAttribute('data-theme')";
for _ in 0..2 {
if c.eval_str(admin, &format!("String({light})"))? == "true" {
break;
}
c.eval(admin, "document.querySelector('button.theme-toggle').click()")?;
std::thread::sleep(Duration::from_millis(200));
}
c.wait_for(admin, light, 5_000)?;
c.eval(admin, "document.querySelector('.machine-card .estate-picture svg').__uitest_same = 'yes'")?;
// What the picture is painted with, and what the console's tokens
// are, in the theme that is on now.
let read = format!(
"(() => {{ const svg = document.querySelector('.machine-card .estate-picture svg'); \
const me = svg.querySelector('g[data-box=' + JSON.stringify({me}) + ']'); \
const cs = e => getComputedStyle(e); \
const probe = document.createElement('i'); document.body.appendChild(probe); \
const tok = v => {{ probe.style.color = 'var(' + v + ')'; return cs(probe).color; }}; \
const out = {{ theme: document.documentElement.getAttribute('data-theme') || 'light', \
same: svg.__uitest_same || '', \
board: cs(svg.querySelector('#board stop')).stopColor, \
edge: cs(me.querySelector('rect')).stroke, \
name: cs(me.querySelector('text')).fill, \
lamp: cs(me.querySelector('circle')).fill, \
surface: tok('--surface'), ink_dim: tok('--ink-dim'), ink: tok('--ink'), pass: tok('--pass'), \
accent: tok('--accent') }}; \
probe.remove(); return out; }})()",
me = esc(&me),
);
let paint = |c: &mut Cdp| -> Result<Vec<(String, String)>, String> {
let j = c.eval(admin, &read)?;
Ok(["theme", "same", "board", "edge", "name", "lamp", "surface", "ink_dim", "ink", "pass", "accent"].iter().map(|k| (k.to_string(), j.text(k))).collect())
};
let get = |p: &[(String, String)], k: &str| p.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()).unwrap_or_default();
// Painted with the console's tokens: board on --surface, main's edge
// --ink-dim (the surprise theme's own board: --accent, its purple),
// its name --ink, its `up` lamp --pass.
let painted_as_the_console = |p: &[(String, String)]| -> Result<(), String> {
let edge = if get(p, "theme") == "surprise" { "accent" } else { "ink_dim" };
for (drawn, token) in [("board", "surface"), ("edge", edge), ("name", "ink"), ("lamp", "pass")] {
want(!get(p, drawn).is_empty() && get(p, drawn) == get(p, token), || {
format!("{} theme: the picture's {drawn} is painted {:?}, the console's --{} is {:?}: {p:?}", get(p, "theme"), get(p, drawn), token.replace('_', "-"), get(p, token))
})?;
}
want(get(p, "same") == "yes", || format!("{} theme: the picture was replaced, not repainted: {p:?}", get(p, "theme")))
};
let light_paint = paint(c)?;
painted_as_the_console(&light_paint)?;
// ☾: the header's own switch, pressed.
c.eval(admin, "document.querySelector('button.theme-toggle').click()")?;
c.wait_for(admin, "document.documentElement.getAttribute('data-theme') === 'dark'", 5_000)
.map_err(|e| format!("the switch did not turn the console dark: {e}"))?;
let dark_paint = paint(c)?;
painted_as_the_console(&dark_paint)?;
for k in ["board", "edge", "name", "lamp"] {
want(get(&light_paint, k) != get(&dark_paint, k), || format!("the picture's {k} is {:?} in both themes", get(&dark_paint, k)))?;
}
// ✦: the third theme, its own board.
c.eval(admin, "document.querySelector('button.theme-toggle').click()")?;
c.wait_for(admin, "document.documentElement.getAttribute('data-theme') === 'surprise'", 5_000)
.map_err(|e| format!("the switch did not move the console from dark to surprise: {e}"))?;
let surprise_paint = paint(c)?;
painted_as_the_console(&surprise_paint)?;
for k in ["board", "edge"] {
want(get(&surprise_paint, k) != get(&dark_paint, k) && get(&surprise_paint, k) != get(&light_paint, k), || {
format!("the picture's {k} is {:?} in surprise, as in another theme", get(&surprise_paint, k))
})?;
}
// ☀: and back, still the same picture.
c.eval(admin, "document.querySelector('button.theme-toggle').click()")?;
c.wait_for(admin, light, 5_000).map_err(|e| format!("the switch did not turn the console light again: {e}"))?;
let again = paint(c)?;
painted_as_the_console(&again)?;
want(get(&again, "board") == get(&light_paint, "board") && get(&again, "edge") == get(&light_paint, "edge"), || format!("light again is not light: {again:?} vs {light_paint:?}"))?;
// Reading order: the main box on top, the console that calls it below.
let tops = c.eval_str(
admin,
&format!(
"(() => {{ const t = n => document.querySelector('.machine-card .estate-picture svg g[data-box=' + JSON.stringify(n) + '] rect').getBoundingClientRect().top; \
return t({}) + ' ' + t({}); }})()",
esc(&me),
esc(&console)
),
)?;
let (main_top, console_top) = tops.split_once(' ').map(|(a, b)| (a.parse::<f64>().unwrap_or(0.0), b.parse::<f64>().unwrap_or(0.0))).unwrap_or((0.0, 0.0));
want(main_top < console_top, || format!("the console is not on the row below the main box: main top {main_top}, console top {console_top}"))?;
Ok(format!(
"light: board {} edge {} lamp {}; dark: board {} edge {} lamp {}; surprise: board {} edge {} lamp {}; light again; same picture; {me} on top, {console} below",
get(&light_paint, "board"),
get(&light_paint, "edge"),
get(&light_paint, "lamp"),
get(&dark_paint, "board"),
get(&dark_paint, "edge"),
get(&dark_paint, "lamp"),
get(&surprise_paint, "board"),
get(&surprise_paint, "edge"),
get(&surprise_paint, "lamp")
))
});
// ★ **Three themes, one switch** (owner, 2026-10-05: a third theme,
// "surprise"). The header's switch walks ☀ light → ☾ dark → ✦ surprise →
// ☀ light, its label always naming the NEXT one; each press must put the
// theme's attribute on <html>, remember the choice where the pre-paint
// script reads it, and repaint — read off the page as a browser painted it:
// a token (--bg) and the body's own ground, the estate picture's board, the
// face the text is set in. The surprise face (Manrope, bundled) must be
// fetched and loaded in surprise and NEVER asked for in light or dark —
// no face of it requested by the page, no byte of it over the wire —
// which is half of "light and dark look exactly as before". A resource
// entry alone is not a download: a new page in the same renderer can list
// a woff2 a page before it left in the memory cache, with nothing
// transferred and its face never requested (measured 2026-10-05, one
// light page in three). And a reload in surprise comes back in surprise.
go(rows, "the-theme-switch-walks-light-dark-surprise-and-each-one-applies", || {
let url = format!("{}/admin/overview", r.origin());
let out = (|| -> Result<String, String> {
c.navigate_fresh(admin, &url)?;
c.wait_for(admin, "!!document.querySelector('.machine-card .estate-picture svg #board stop') && !!document.querySelector('button.theme-toggle')", 30_000)
.map_err(|e| format!("no inline picture or no theme switch: {e}"))?;
let attr = "(document.documentElement.getAttribute('data-theme') || 'light')";
// Start light: at most two presses away.
for _ in 0..2 {
if c.eval_str(admin, attr)? == "light" {
break;
}
c.eval(admin, "document.querySelector('button.theme-toggle').click()")?;
std::thread::sleep(Duration::from_millis(200));
}
// A fresh document in light, so no face of the surprise theme has
// been asked for in it yet.
c.navigate_fresh(admin, &url)?;
c.wait_for(admin, "!!document.querySelector('.machine-card .estate-picture svg #board stop') && !!document.querySelector('button.theme-toggle')", 30_000)?;
let read = "(() => { const r = document.documentElement; \
const probe = document.createElement('i'); document.body.appendChild(probe); \
const tok = v => { probe.style.color = 'var(' + v + ')'; return getComputedStyle(probe).color; }; \
let store = ''; try { store = localStorage.getItem('gunnar-theme') || ''; } catch (e) { store = 'storage throws'; } \
const svg = document.querySelector('.machine-card .estate-picture svg'); \
const out = { theme: r.getAttribute('data-theme') || 'light', store, \
label: (document.querySelector('button.theme-toggle') || {}).textContent || '', \
bg: tok('--bg'), body: getComputedStyle(document.body).backgroundColor, \
surface: tok('--surface'), accent: tok('--accent'), \
board: svg ? getComputedStyle(svg.querySelector('#board stop')).stopColor : 'no picture', \
font: getComputedStyle(document.body).fontFamily, \
hairline: getComputedStyle(document.querySelector('header.topbar')).borderImageSource.slice(0, 15), \
wire: performance.getEntriesByType('resource').filter(e => /manrope-[0-9]+[.]woff2/.test(e.name) && e.transferSize > 0).map(e => e.name.split('/').pop()).join(' '), \
entries: performance.getEntriesByType('resource').filter(e => /manrope-[0-9]+[.]woff2/.test(e.name)).map(e => e.name.split('/').pop() + ' transfer ' + e.transferSize + ' B, ' + Math.round(e.duration) + ' ms, delivery ' + JSON.stringify(e.deliveryType)).join('; '), \
asked: [...document.fonts].filter(f => f.family.replace(/[\"']/g, '') === 'Manrope' && f.status !== 'unloaded').map(f => f.weight + ':' + f.status).join(' '), \
loaded: [...document.fonts].filter(f => f.family.replace(/[\"']/g, '') === 'Manrope' && f.status === 'loaded').map(f => f.weight).join(' '), \
age: String(Math.round(performance.now())) + ' ms' }; \
probe.remove(); return out; })()";
const KEYS: [&str; 15] = ["theme", "store", "label", "bg", "body", "surface", "accent", "board", "font", "hairline", "wire", "entries", "asked", "loaded", "age"];
let snap = |c: &mut Cdp| -> Result<Vec<(String, String)>, String> {
let j = c.eval(admin, read)?;
Ok(KEYS.iter().map(|k| (k.to_string(), j.text(k))).collect())
};
let get = |p: &[(String, String)], k: &str| p.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()).unwrap_or_default();
let mut said = Vec::new();
let first = snap(c)?;
want(get(&first, "theme") == "light" && get(&first, "label") == "☾ Dark", || format!("not light, or the switch does not offer ☾ Dark: {first:?}"))?;
want(get(&first, "bg") == "rgb(244, 244, 244)" && get(&first, "body") == get(&first, "bg"), || format!("light's ground is not #f4f4f4: {first:?}"))?;
want(get(&first, "wire").is_empty() && get(&first, "asked").is_empty() && !get(&first, "font").contains("Manrope"), || format!("light names, asks for or downloads the surprise face: {first:?}"))?;
said.push(format!("light: bg {} board {} font {}; surprise face asked for: none; its entries: [{}]", get(&first, "bg"), get(&first, "board"), get(&first, "font"), get(&first, "entries")));
let mut prev = first.clone();
let mut seen = vec![first.clone()];
// (the theme a press lands on, the label it must then show, its --bg
// when it is one this row can name exactly, its board's first stop)
for (theme, label, bg, board) in [
("dark", "✦ Surprise", "", ""),
("surprise", "☀ Light", "rgb(8, 11, 24)", "rgb(17, 21, 42)"),
("light", "☾ Dark", "rgb(244, 244, 244)", ""),
] {
c.eval(admin, "document.querySelector('button.theme-toggle').click()")?;
c.wait_for(admin, &format!("{attr} === {}", esc(theme)), 5_000)
.map_err(|e| format!("a press from {} did not land on {theme}: {e}", get(&prev, "theme")))?;
if theme == "surprise" {
// A face is fetched when text is first laid out in it;
// wait for the body's (400), not merely the first one in —
// the memory cache can hand back 600 and 800 while 400 is
// still on its way.
c.wait_for(admin, "document.body.offsetHeight > 0 && document.fonts.status === 'loaded' && [...document.fonts].some(f => f.family.replace(/[\"']/g, '') === 'Manrope' && f.weight === '1 499' && f.status === 'loaded')", 10_000)
.map_err(|e| format!("surprise: the bundled body face never loaded: {e}"))?;
}
let now = snap(c)?;
let what = || format!("{now:?}");
want(get(&now, "label") == label, || format!("{theme}: the switch reads {:?}, not {label:?} (the theme a press goes to): {}", get(&now, "label"), what()))?;
want(get(&now, "store") == theme, || format!("{theme}: the choice is stored as {:?}: {}", get(&now, "store"), what()))?;
want(get(&now, "body") == get(&now, "bg"), || format!("{theme}: the body's ground is not the theme's --bg: {}", what()))?;
want(get(&now, "bg") != get(&prev, "bg") && get(&now, "board") != get(&prev, "board"), || {
format!("{theme}: the token or the picture did not change from {}: {} vs {prev:?}", get(&prev, "theme"), what())
})?;
want(get(&now, "board") == get(&now, "surface"), || format!("{theme}: the picture's board is not the theme's --surface: {}", what()))?;
if !bg.is_empty() {
want(get(&now, "bg") == bg, || format!("{theme}: --bg is {:?}, not {bg}: {}", get(&now, "bg"), what()))?;
}
if !board.is_empty() {
want(get(&now, "board") == board, || format!("{theme}: the board is {:?}, not {board}: {}", get(&now, "board"), what()))?;
}
if theme == "surprise" {
want(get(&now, "font").starts_with("Manrope"), || format!("surprise: the text is set in {:?}: {}", get(&now, "font"), what()))?;
want(get(&now, "accent") == "rgb(139, 92, 246)", || format!("surprise: --accent is {:?}: {}", get(&now, "accent"), what()))?;
want(get(&now, "hairline").starts_with("linear-gradient"), || format!("surprise: the header carries no gradient: {}", what()))?;
// Body text, labels and the headline each on their cut:
// all three faces loaded, from the only source they name,
// the bundle's own /manrope-*.woff2.
want(get(&now, "loaded") == "1 499 500 749 750 1000", || format!("surprise: not every cut of the face loaded: {}", what()))?;
} else {
want(!get(&now, "font").contains("Manrope") && get(&now, "hairline") == "none", || {
format!("{theme}: the surprise face or hairline leaks into it: {}", what())
})?;
}
if theme == "dark" {
want(get(&now, "wire").is_empty() && get(&now, "asked").is_empty(), || format!("dark asked for or downloaded the surprise face: {}", what()))?;
}
said.push(format!("{theme}: bg {} board {} font {}{}", get(&now, "bg"), get(&now, "board"), get(&now, "font").split(',').next().unwrap_or(""),
if theme == "surprise" { format!(" (loaded weights {}; entries [{}])", get(&now, "loaded"), get(&now, "entries")) } else { String::new() }));
prev = now.clone();
seen.push(now);
if theme == "surprise" {
// Remembered: a reload comes back in surprise, the switch
// offering ☀ Light.
c.navigate_fresh(admin, &url)?;
c.wait_for(admin, "!!document.querySelector('button.theme-toggle') && !!document.querySelector('.machine-card .estate-picture svg #board stop')", 30_000)?;
let back = snap(c)?;
want(get(&back, "theme") == "surprise" && get(&back, "label") == "☀ Light" && get(&back, "bg") == "rgb(8, 11, 24)", || {
format!("a reload in surprise did not come back in surprise: {back:?}")
})?;
said.push("reload: still surprise".into());
prev = back;
}
}
let bgs: std::collections::BTreeSet<String> = seen.iter().take(3).map(|p| get(p, "bg")).collect();
want(bgs.len() == 3, || format!("three themes, but {} grounds: {bgs:?}", bgs.len()))?;
Ok(said.join(" ; "))
})();
// Whatever happened, bob is left in light, as the rows after this
// one (and their pictures) expect him.
let _ = c.eval(admin, "(() => { try { localStorage.setItem('gunnar-theme', 'light'); } catch (e) {} \
document.documentElement.removeAttribute('data-theme'); return 'ok'; })()");
out
});
go(rows, "instruments-does-not-repeat-the-menu", || {
c.navigate(admin, &format!("{}/admin", r.origin()))?;
c.wait_for(admin, &format!("{MAIN_TEXT}.includes(\"The console's own identity\")"), 30_000)?;
let seen = panels(c, admin)?;
want(!seen.iter().any(|p| p == "Pages"), || format!("Instruments still draws a Pages panel: {seen:?}"))?;
let links = c.eval_str(admin, "String(document.querySelectorAll('main .admin-links, main a.btn[href=\"/machine\"]').length)")?;
want(links == "0", || format!("{links} menu link block(s) on Instruments"))?;
Ok(format!("panels {seen:?}"))
});
go(rows, "storage-says-each-disk-once-and-names-whose-it-is", || {
c.navigate(admin, &format!("{}/admin/storage", r.origin()))?;
c.wait_for(admin, "[...document.querySelectorAll('section.panel > header h2')].some(h => h.textContent.trim() === 'Layout')", 30_000)
.map_err(|e| format!("no Layout panel on Storage: {e}"))?;
let labels = c.eval_str(admin, "[...document.querySelectorAll('dt')].map(e => e.textContent.trim()).join(' | ')")?;
want(labels.split(" | ").any(|l| l == "/serialdat on") && labels.split(" | ").any(|l| l == "/data is"), || format!("the disks are not each named: {labels}"))?;
for gone in ["Backing device", "Backing class"] {
want(!labels.split(" | ").any(|l| l == gone), || format!("`{gone}` is back: {labels}"))?;
}
let seen = panels(c, admin)?;
// Layout always; Data set and /serialdat at most once (a rig with no
// installed disks has no /serialdat to draw).
for (panel, most) in [("Layout", 1), ("Data set", 1), ("/serialdat", 1)] {
let n = seen.iter().filter(|p| *p == panel).count();
want(n <= most && (panel != "Layout" || n == 1), || format!("{panel} drawn {n} times: {seen:?}"))?;
}
// The installer's record is there, folded: asked for, not shown.
let folded = c.eval_str(admin, "String(!!document.querySelector('details.installer-record') && !document.querySelector('details.installer-record').open)")?;
want(folded == "true", || "the installer's record is not a closed fold".into())?;
Ok(format!("panels {seen:?}"))
});
go(rows, "the-readme-tab-is-the-readme-and-the-clone-line", || {
c.navigate(admin, &format!("{}/repo/alice/uitest", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('nav.repo-tabs a.on')", 30_000)?;
// The clone line is drawn when the console knows its git base
// (`/auth/status`'s git_url); a rig with none draws none, and says so.
let status = c.eval(admin, "fetch('/auth/status').then(r => r.json())")?;
let has_base = !status.text("git_url").is_empty();
if has_base {
c.wait_for(admin, "!!document.querySelector('p.clone-line code')", 30_000)
.map_err(|e| format!("no clone line on the Readme tab although the console has a git base: {e}"))?;
}
std::thread::sleep(Duration::from_millis(1500));
let seen = panels(c, admin)?;
want(!seen.iter().any(|p| p == "References" || p == "Mirrored from"), || format!("the Readme tab draws more than the README: {seen:?}"))?;
c.navigate(admin, &format!("{}/repo/alice/uitest/graph", r.origin()))?;
c.wait_for(admin, "[...document.querySelectorAll('section.panel > header h2')].some(h => h.textContent.trim() === 'References')", 30_000)
.map_err(|e| format!("the references are not on History: {e}"))?;
// The panel's header is drawn before its list has answered: wait for
// the list (or its failure), not for the header beside it — this row
// read the panel before its list under load on 2026-10-04 and said
// main was missing.
// A wait that runs out is not the verdict; the read below is.
let _ = c.wait_for(
admin,
"(() => { const s=[...document.querySelectorAll('section.panel')].find(s => s.querySelector('h2') && s.querySelector('h2').textContent.trim() === 'References');
return !!s && (s.innerText.includes('main') || !!s.querySelector('.failure')); })()",
30_000,
);
let refs = c.eval_str(admin, "String([...document.querySelectorAll('section.panel')].find(s => s.querySelector('h2').textContent.trim() === 'References').innerText.includes('main'))")?;
want(refs == "true", || "History's References do not list main".into())?;
Ok(format!("Readme: {seen:?} (clone line {}); History carries References", if has_base { "drawn" } else { "none: no git base" }))
});
go(rows, "a-page-says-one-line-and-a-question-mark", || {
let mut walked = Vec::new();
for path in ["/admin/overview", "/admin/storage", "/admin", "/access", "/mirrors"] {
c.navigate(admin, &format!("{}{path}", r.origin()))?;
header(c, admin)?;
std::thread::sleep(Duration::from_millis(1500));
// Every hint's OWN text (not what waits behind its "?") is one line.
let long = c.eval(
admin,
"[...document.querySelectorAll('main .hint, main .empty p')].filter(e => !e.closest('details.why')).map(e => {
const c = e.cloneNode(true); c.querySelectorAll('details').forEach(d => d.remove());
return c.textContent.trim(); }).filter(t => t.length > 200)",
)?;
let long: Vec<String> = long.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect();
want(long.is_empty(), || format!("{path} shows {} long text(s) unasked: {:?}", long.len(), long.iter().map(|t| http::cut(t, 80)).collect::<Vec<_>>()))?;
let open = c.eval_str(admin, "String(document.querySelectorAll('main details.why[open]').length)")?;
want(open == "0", || format!("{path} opens {open} \"?\" by itself"))?;
let asks = c.eval_str(admin, "String(document.querySelectorAll('main details.why').length)")?;
walked.push(format!("{path}: {asks} ?"));
}
// A "?" opens on a click and says the rest.
c.navigate(admin, &format!("{}/admin", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('.admin-banner details.why summary')", 30_000)?;
c.eval(admin, "document.querySelector('.admin-banner details.why summary').click()")?;
let said = c.eval_str(admin, "document.querySelector('.admin-banner details.why').open ? document.querySelector('.admin-banner details.why p').textContent.trim() : ''")?;
want(!said.is_empty(), || "the banner's \"?\" does not open".into())?;
Ok(walked.join(", "))
});
// ── the live card: a twin dies, the open card goes red ─────────────────
go(rows, "a-twin-that-stops-goes-red-in-the-open-card", || {
// A real follower polls this gunnar as a member of group twin (the
// dual heartbeat), then stops. gunnar decides `silent` after its bound
// (the rig's 5 s); the OPEN card — no navigation, no reload — must show
// gunnar's verdict on its own: the twin's chip, its link and its words.
let dir = r.work.join("live-twin");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("data")).map_err(|e| e.to_string())?;
let key = dir.join("twin_ed25519");
let made = std::process::Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "uitest-twin", "-f"])
.arg(&key)
.status()
.map_err(|e| format!("ssh-keygen: {e}"))?;
want(made.success(), || "ssh-keygen failed".into())?;
let pubkey = std::fs::read_to_string(key.with_extension("pub")).map_err(|e| e.to_string())?;
r.control(&["user", "add", "uitest-twin", "--kind", "machine", "--key", pubkey.trim()])?;
let _ = r.control(&["group", "add", "twin"]);
r.control(&["group", "member", "add", "twin", "uitest-twin"])?;
// The twin's poll: `twin follow --once` every second — each run is one
// Mirror.LogHead carrying the twin's report (the dual heartbeat), which
// is all gunnar's verdict reads. Stopping the loop is the twin dying.
let beat = |r: &Rig| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let (cli, at, data, key, log) = (r.cli.clone(), r.at(), dir.join("data"), key.clone(), dir.join("follow.log"));
let flag = stop.clone();
let handle = std::thread::spawn(move || {
while !flag.load(std::sync::atomic::Ordering::Relaxed) {
let _ = std::process::Command::new(&cli)
.args(["control", "twin", "follow", "--once", "--at", &at, "--data-root"])
.arg(&data)
.arg("-i")
.arg(&key)
.stdout(std::process::Stdio::null())
.stderr(std::fs::OpenOptions::new().create(true).append(true).open(&log).map(std::process::Stdio::from).unwrap_or(std::process::Stdio::null()))
.status();
std::thread::sleep(Duration::from_millis(1000));
}
});
(stop, handle)
};
let end = |(stop, handle): (std::sync::Arc<std::sync::atomic::AtomicBool>, std::thread::JoinHandle<()>)| {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = handle.join();
};
let state = "(document.querySelector('.machine-card .estate-picture svg g[data-box=\"uitest-twin\"]') || {dataset:{}}).dataset.state || ''";
let link = "(document.querySelector('.machine-card .estate-picture svg g[data-link=\"uitest-twin\"]') || {dataset:{}}).dataset.state || ''";
let mut twin = Some(beat(r));
let outcome = (|| -> Result<String, String> {
c.navigate(admin, &format!("{}/admin/overview", r.origin()))?;
// Heard: `up`, or `behind` (this twin applies nothing) — both lit.
let heard = format!("['up','behind'].includes({state})");
c.wait_for(admin, &heard, 40_000)
.map_err(|e| format!("the polling twin never showed up on the card: {e}"))?;
let up_link = c.eval_str(admin, link)?;
let pulses = c.eval_str(admin, "String(!!document.querySelector('.machine-card .estate-picture svg g[data-link=\"uitest-twin\"] animate'))")?;
want(["up", "behind"].contains(&up_link.as_str()) && pulses == "true", || format!("the link into a twin that polls is {up_link:?}, pulsing {pulses}"))?;
// The open page, marked: a reload or a navigation would lose this.
c.eval(admin, "window.__still_open = 'yes'")?;
let t0 = Instant::now();
if let Some(t) = twin.take() {
end(t);
}
c.wait_for(admin, &format!("({state}) === 'silent'"), 25_000)
.map_err(|e| format!("the twin stopped and the open card did not turn it silent within 25 s: {e}"))?;
let red_after = t0.elapsed().as_secs();
let still = c.eval_str(admin, "String(window.__still_open || '')")?;
want(still == "yes", || "the card went red by a reload or a navigation, not in place".into())?;
let words = c.eval_str(admin, "(document.querySelector('.machine-card .estate-picture svg g[data-box=\"uitest-twin\"]') || {textContent:''}).textContent")?;
want(words.contains("silent for") || words.contains("never heard"), || format!("gunnar's words are not on the silent chip: {words:?}"))?;
let dead_link = c.eval_str(admin, link)?;
let still_line = c.eval_str(admin, "String(!document.querySelector('.machine-card .estate-picture svg g[data-link=\"uitest-twin\"] animate'))")?;
want(dead_link == "silent" && still_line == "true", || format!("the link into the dead twin is {dead_link:?}, still {still_line}"))?;
// gunnar's own answer agrees, word for word.
let topo = c.eval(admin, "fetch('/api/machine/topology').then(r => r.json())")?;
let row = topo.path("boxes").and_then(|b| b.arr().iter().find(|x| x.text("name") == "uitest-twin").cloned()).ok_or("gunnar's answer has no uitest-twin")?;
want(row.text("state") == "silent" && words.contains(&row.text("headline")), || format!("the card says {words:?}; gunnar says {} / {}", row.text("state"), row.text("headline")))?;
// And back: the twin polls again, the card turns it up again.
twin = Some(beat(r));
c.wait_for(admin, &heard, 25_000)
.map_err(|e| format!("the twin came back and the card did not light it within 25 s: {e}"))?;
Ok(format!("lit → silent in the open card {red_after} s after the twin stopped (\"{}\"), link still red; lit again when it polled again", row.text("headline")))
})();
if let Some(t) = twin.take() {
end(t);
}
// Leave the rig as it was: no twin member for the rows after this one.
let _ = r.control(&["group", "member", "remove", "twin", "uitest-twin"]);
outcome
});
// ── plain page titles ──────────────────────────────────────────────────
go(rows, "no-page-header-carries-an-explanatory-subtitle", || {
let mut walked = Vec::new();
for (sess, path) in [(admin, "/admin/overview"), (admin, "/admin/storage"), (admin, "/admin"), (admin, "/access"), (admin, "/mirrors"), (admin, "/account"), (person, "/repos"), (person, "/account"), (person, "/account/activity")] {
c.navigate(sess, &format!("{}{path}", r.origin()))?;
let (h1, line) = header(c, sess)?;
let data = line.is_empty() || line.starts_with("Server key SHA256:");
want(data, || format!("{path} ({h1}) carries a subtitle: {line:?}"))?;
walked.push(format!("{path}={h1}"));
}
Ok(walked.join(" "))
});
// ── the events a person is shown ───────────────────────────────────────
go(rows, "a-person-is-shown-only-events-of-repositories-they-may-read", || {
// kim listens FIRST (non-blocking), then bob makes a change to a
// PRIVATE repository and waits for it on his own feed — the control.
c.eval(
person,
"(() => { window.__heard = []; const es = new EventSource('/api/events');
es.addEventListener('feed', e => window.__heard.push(String(e.data)));
window.__es = es; return 'listening'; })()",
)?;
std::thread::sleep(Duration::from_millis(1500));
let got = c.eval(
admin,
"new Promise(resolve => {
const tag = 'private-' + Math.random().toString(36).slice(2, 10);
const es = new EventSource('/api/events');
const done = (ok, why) => { clearTimeout(t); es.close(); resolve({ok, why, tag}); };
const t = setTimeout(() => done(false, 'nothing arrived in 20 s'), 20000);
es.addEventListener('feed', e => { if (String(e.data).includes(tag)) done(true, 'arrived'); });
es.onopen = () => fetch('/api/repos', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({namespace:'alice', name: tag})});
})",
)?;
let tag = got.text("tag");
want(got.path("ok").and_then(|v| v.as_bool()) == Some(true), || format!("the admin's own feed did not carry the change: {}", got.text("why")))?;
std::thread::sleep(Duration::from_secs(3));
let heard = c.eval(person, "(() => { const h = window.__heard || []; if (window.__es) window.__es.close(); return h; })()")?;
let heard: Vec<String> = heard.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect();
want(!heard.iter().any(|l| l.contains(&tag)), || format!("kim (a viewer) was told about alice/{tag}, a private repository: {heard:?}"))?;
// And kim's Activity page (You → Activity) does not draw it either.
c.navigate(person, &format!("{}/account/activity", r.origin()))?;
c.wait_for(person, &format!("{MAIN_TEXT}.length > 20"), 30_000)?;
std::thread::sleep(Duration::from_secs(2));
let page = c.eval_str(person, MAIN_TEXT)?;
want(!page.contains(&tag), || format!("kim's Activity page shows alice/{tag}: {}", http::cut(&page, 300)))?;
Ok(format!("bob heard alice/{tag}; kim heard {} lines, none about it", heard.len()))
});
// ── the system principals ──────────────────────────────────────────────
go(rows, "the-identities-list-hides-the-boxs-own-principals-until-asked", || {
c.navigate(admin, &format!("{}/access", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('table caption')", 30_000)?;
let api = c.eval(admin, "fetch('/api/principals').then(r => r.json())")?;
let system: Vec<String> = api.path("items").map(|i| i.arr().iter().filter(|p| p.text("kind") != "user").map(|p| p.text("id")).collect()).unwrap_or_default();
want(!system.is_empty(), || format!("the rig has no machine principal to hide: {}", http::cut(&api.dump(), 200)))?;
let names = |c: &mut Cdp| -> Result<Vec<String>, String> {
let ids = c.eval(admin, "[...([...document.querySelectorAll('table')].find(t => t.querySelector('caption')?.textContent === 'Principals')?.querySelectorAll('tbody td.oid') ?? [])].map(td => td.textContent.trim())")?;
Ok(ids.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect())
};
let hidden = names(c)?;
want(!hidden.iter().any(|id| system.contains(id)), || format!("system principals listed by default: {hidden:?} (system {system:?})"))?;
c.eval(admin, "(() => { const t = document.querySelector('label.toggle input'); t.click(); return 'toggled'; })()")?;
std::thread::sleep(Duration::from_millis(500));
let shown = names(c)?;
want(system.iter().all(|id| shown.contains(id)), || format!("the toggle does not show them: {shown:?} (system {system:?})"))?;
Ok(format!("{} system principals hidden, shown on the toggle", system.len()))
});
// ── pictures of the new navigation, for the owner ──────────────────────
go(rows, "the-new-navigation-is-photographed", || {
let dir = r.work.join("shots");
let mut saved = Vec::new();
let _ = c.viewport(person, 1280, 900);
let _ = c.viewport(admin, 1280, 900);
for (n, sess, path, open) in [
("01-person-repositories", person, "/repos", ""),
("02-person-you-menu", person, "/repos", "header.topbar details.menu"),
("03-person-account", person, "/account", ""),
("04-person-activity", person, "/account/activity", ""),
("05-admin-administration-menu", admin, "/repos", "header.topbar details.admin-menu"),
("06-admin-overview", admin, "/admin/overview", ""),
("07-admin-storage", admin, "/admin/storage", ""),
("08-repo-code-tab", admin, "/repo/alice/uitest", ""),
("09-repo-activity-tab", admin, "/repo/alice/uitest/activity", ""),
("10-repo-settings-tab", admin, "/repo/alice/uitest/settings", ""),
] {
c.navigate(sess, &format!("{}{path}", r.origin()))?;
let _ = c.wait_for(sess, "!!document.querySelector('header.page-header h1')", 20_000);
std::thread::sleep(Duration::from_millis(1200));
if !open.is_empty() {
c.eval(sess, &format!("(() => {{ const d = document.querySelector({}); if (d) d.open = true; return 'ok'; }})()", esc(open)))?;
std::thread::sleep(Duration::from_millis(300));
}
let to = dir.join(format!("{n}.png"));
c.screenshot(sess, &to)?;
c.eval(sess, "(() => { document.querySelectorAll('details.menu').forEach(d => d.open = false); return 'closed'; })()")?;
saved.push(n);
}
// A phone: the top bar wraps.
let _ = c.viewport(person, 390, 844);
c.navigate(person, &format!("{}/repos", r.origin()))?;
std::thread::sleep(Duration::from_millis(1200));
c.screenshot(person, &dir.join("11-person-phone.png"))?;
c.eval(person, "(() => { const d = document.querySelector('header.topbar details.menu'); if (d) d.open = true; return 'ok'; })()")?;
std::thread::sleep(Duration::from_millis(300));
c.screenshot(person, &dir.join("12-person-phone-you-menu.png"))?;
// The TOP BAR must collapse cleanly (owner): nothing of it past the
// phone's edge. What a wide table does to the page is reported.
let wraps = c.eval(person, "(() => { const b = document.querySelector('header.topbar'); return [...b.querySelectorAll('*')].every(e => e.getBoundingClientRect().right <= window.innerWidth + 1) && b.getBoundingClientRect().right <= window.innerWidth + 1; })()")?;
let page_width = c.eval_str(person, "String(document.documentElement.scrollWidth)")?;
let wide = c.eval_str(
person,
"[...document.querySelectorAll('body *')].filter(e => { const r = e.getBoundingClientRect(); return r.right > window.innerWidth + 1 && !e.closest('table'); })
.slice(0, 12).map(e => e.tagName + '.' + e.className + ' ' + Math.round(e.getBoundingClientRect().right)).join(' | ') + ' ; scrollWidth ' + document.documentElement.scrollWidth",
)?;
let _ = c.viewport(person, 1280, 900);
want(wraps.as_bool() == Some(true), || format!("on a 390 px phone the top bar runs past the edge: {wide}"))?;
Ok(format!("{} pictures in {}; on a 390 px phone the top bar wraps inside the screen (the page is {page_width} px wide: {wide})", saved.len() + 2, dir.display()))
});
// ── the header's mark is big enough to see (owner, 2026-10-05) ────────
// "the new gunnar logo — it's so small I can't see it": 34 x 19 px. Now
// 64 x 35 on a desktop, and on a 390 px phone the largest that keeps the
// bar's first line whole (42 x 23), in every theme — and the bar pays
// nothing for it: 52 px tall on a desktop, and on a phone the mark is no
// taller than the other things on its line (so it cannot be what makes
// the bar taller), with the brand, Repositories and the theme switch
// still on one line and nothing past the screen's edge.
go(rows, "the-header-mark-is-big-enough-and-the-bar-keeps-its-height", || {
let measure = "(() => { const b = e => e.getBoundingClientRect(); \
const bar = b(document.querySelector('header.topbar')), mark = b(document.querySelector('.brand-mark-small')), \
link = b(document.querySelector('header.topbar .top-link')), sw = b(document.querySelector('button.theme-toggle')); \
const mid = r => Math.round(r.top + r.height / 2); \
return [bar.height, mark.width, mark.height, mid(mark), mid(link), mid(sw), Math.max(link.height, sw.height), sw.right, document.documentElement.clientWidth] \
.map(v => Math.round(v)).join(' '); })()";
let mut said = Vec::new();
let out = (|| -> Result<(), String> {
for theme in ["light", "dark", "surprise"] {
c.eval(person, &format!("(() => {{ try {{ localStorage.setItem('gunnar-theme', {}); }} catch (e) {{}} return 'ok'; }})()", esc(theme)))?;
for (w, h) in [(1280u32, 900u32), (480, 900), (390, 844)] {
// A phone's width is measured as a phone draws it: overlay scrollbars, so a
// long page (kim's, late in the full matrix) does not take 15 px of the 390.
if w > 720 {
c.viewport(person, w, h)?;
} else {
viewport_phone(c, person, w, h)?;
}
c.navigate_fresh(person, &format!("{}/repos", r.origin()))?;
c.wait_for(person, "!!document.querySelector('.brand-mark-small') && !!document.querySelector('button.theme-toggle') && !!document.querySelector('header.topbar .top-link')", 20_000)?;
let _ = c.eval(person, "document.fonts.ready.then(() => true)");
std::thread::sleep(Duration::from_millis(400));
// Load-tolerant (r5.3 batch matrix 2026-10-08: "dark 390 px: bar 136, mark
// 49x27 … switch right 120" in the full matrix, green alone): measure until two
// reads 300 ms apart agree, at most ~10 s, so a layout still settling under load
// (the viewport, the stylesheet, the badge) is not judged. A bar that is really
// wrong stays wrong and is still refused below.
let mut m = c.eval_str(person, measure)?;
for _ in 0..33 {
std::thread::sleep(Duration::from_millis(300));
let again = c.eval_str(person, measure)?;
if again == m {
break;
}
m = again;
}
let n: Vec<i64> = m.split(' ').filter_map(|t| t.parse().ok()).collect();
want(n.len() == 9, || format!("{theme} {w} px: nothing to measure: {m}"))?;
let (bar, mw, mh, mark_mid, link_mid, sw_mid, line, sw_right, iw) = (n[0], n[1], n[2], n[3], n[4], n[5], n[6], n[7], n[8]);
let bar_text = c.eval_str(person, "[...document.querySelector('header.topbar').children].map(e => { const b = e.getBoundingClientRect(); return (e.className || e.tagName) + '@' + Math.round(b.left) + ',' + Math.round(b.top) + '+' + Math.round(b.width) + ':' + (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 24); }).join(' | ')").unwrap_or_default();
let what = || format!("{theme} {w} px: bar {bar}, mark {mw}x{mh}, line {line}, middles mark {mark_mid} link {link_mid} switch {sw_mid}, switch right {sw_right} of {iw} (the bar reads {bar_text:?})");
let (min_w, min_h) = if w > 440 { (60, 33) } else { (40, 22) };
want(mw >= min_w && mh >= min_h, || format!("the mark is too small to see (want {min_w}x{min_h} or more) — {}", what()))?;
want(((mw as f64) / (mh as f64) - 1392.0 / 768.0).abs() < 0.1, || format!("the mark is not 1392:768 — {}", what()))?;
if w == 1280 {
want(bar == 52 && mh + 8 <= bar, || format!("the bar is not the 52 px it was — {}", what()))?;
} else {
want(mh <= line, || format!("the mark is the tallest thing on its line, so it makes the bar taller — {}", what()))?;
want((mark_mid - link_mid).abs() <= 4 && (mark_mid - sw_mid).abs() <= 4 && sw_right <= iw, || {
format!("the brand, Repositories and the switch are not one line inside the screen — {}", what())
})?;
}
said.push(format!("{theme} {w}px: mark {mw}x{mh} bar {bar}"));
}
}
Ok(())
})();
let _ = c.viewport(person, 1280, 900);
let _ = c.eval(person, "(() => { try { localStorage.setItem('gunnar-theme', 'light'); } catch (e) {} document.documentElement.removeAttribute('data-theme'); return 'ok'; })()");
out?;
Ok(said.join(" ; "))
});
// ── the three themes, photographed for the owner (2026-10-05) ─────────
// The main pages in ☀ light, ☾ dark and ✦ surprise, each chosen the way a
// person's choice is remembered (the stored word, applied before the
// first paint), into shots/themes/<theme>/. Pictures, not proof: the row
// above measures; these are for eyes, and for the light and dark pictures
// to be compared against the console before the third theme.
go(rows, "the-main-pages-are-photographed-in-all-three-themes", || {
let dir = r.work.join("shots").join("themes");
let mut saved = 0;
let _ = c.viewport(person, 1280, 900);
let _ = c.viewport(admin, 1280, 900);
let set = |c: &mut Cdp, sess: &str, theme: &str| -> Result<(), String> {
c.eval(sess, &format!("(() => {{ try {{ localStorage.setItem('gunnar-theme', {}); }} catch (e) {{}} return 'ok'; }})()", esc(theme)))?;
Ok(())
};
let settle = |c: &mut Cdp, sess: &str| {
let _ = c.eval(sess, "document.fonts.ready.then(() => 'ok')");
std::thread::sleep(Duration::from_millis(1200));
};
let out = (|| -> Result<(), String> {
for theme in ["light", "dark", "surprise"] {
set(c, person, theme)?;
set(c, admin, theme)?;
for (n, sess, path, open) in [
("01-person-repositories", person, "/repos", ""),
("02-person-you-menu", person, "/repos", "header.topbar details.menu"),
("03-person-account", person, "/account", ""),
("04-person-activity", person, "/account/activity", ""),
("05-admin-administration-menu", admin, "/repos", "header.topbar details.admin-menu"),
("06-admin-overview", admin, "/admin/overview", ""),
("07-admin-storage", admin, "/admin/storage", ""),
("08-repo-code-tab", admin, "/repo/alice/uitest", ""),
("09-repo-activity-tab", admin, "/repo/alice/uitest/activity", ""),
("10-repo-settings-tab", admin, "/repo/alice/uitest/settings", ""),
] {
c.navigate_fresh(sess, &format!("{}{path}", r.origin()))?;
let _ = c.wait_for(sess, "!!document.querySelector('header.page-header h1')", 20_000);
let want_attr = if theme == "light" { "null".to_owned() } else { esc(theme) };
c.wait_for(sess, &format!("document.documentElement.getAttribute('data-theme') === {want_attr}"), 5_000)
.map_err(|e| format!("{theme}: {path} did not open in the stored theme: {e}"))?;
settle(c, sess);
if !open.is_empty() {
c.eval(sess, &format!("(() => {{ const d = document.querySelector({}); if (d) d.open = true; return 'ok'; }})()", esc(open)))?;
std::thread::sleep(Duration::from_millis(300));
}
c.screenshot(sess, &dir.join(theme).join(format!("{n}.png")))?;
c.eval(sess, "(() => { document.querySelectorAll('details.menu').forEach(d => d.open = false); return 'closed'; })()")?;
saved += 1;
}
let _ = c.viewport(person, 390, 844);
c.navigate_fresh(person, &format!("{}/repos", r.origin()))?;
settle(c, person);
c.screenshot(person, &dir.join(theme).join("11-person-phone.png"))?;
c.eval(person, "(() => { const d = document.querySelector('header.topbar details.menu'); if (d) d.open = true; return 'ok'; })()")?;
std::thread::sleep(Duration::from_millis(300));
c.screenshot(person, &dir.join(theme).join("12-person-phone-you-menu.png"))?;
c.eval(person, "(() => { document.querySelectorAll('details.menu').forEach(d => d.open = false); return 'closed'; })()")?;
let _ = c.viewport(person, 1280, 900);
saved += 2;
}
Ok(())
})();
// Whatever happened, both people are left in light, as they came.
let _ = c.viewport(person, 1280, 900);
for sess in [person, admin] {
let _ = set(c, sess, "light");
let _ = c.eval(sess, "document.documentElement.removeAttribute('data-theme'); 'ok'");
}
out?;
Ok(format!("{saved} pictures in {} (light, dark, surprise)", dir.display()))
});
// ── the typeset README follows the theme (owner, 2026-10-06) ───────────
// "The rendered README is locked to a white background with black text;
// the HTML view follows the theme." The typeset page is an SVG the server
// drew with docbook's paper colours baked in (`fill="#ffffff"` under ink
// `#16191d`, links `#0a58ca`, code `#f5f6f8`, rules `#cfd4da`); the
// stylesheet maps each onto the theme's own token. Asserted on the
// COMPUTED paint of the SVG's own elements against a probe painted with
// the token itself, in all three themes, so a token that changes is
// followed and a rule that stops matching is caught. One picture per
// theme into shots/readme/, taken before the verdict so a red run still
// shows what it measured.
go(rows, "the-typeset-readme-follows-the-theme-in-all-three", || {
let wt = r.work.join("wt-readme-theme");
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@vetra.se"])?;
std::fs::write(
wt.join("README.md"),
"# Gunnar, typeset\n\nA paragraph with a [link](https://example.com), `inline code`, **bold** and *italic*.\n\n\
> A block quote, quietly.\n\n| Column | Value |\n| ------ | ----- |\n| one | 1 |\n| two | 2 |\n\n\
```rust\nfn main() {\n println!(\"the code block\");\n}\n```\n\n- one\n- two\n\n---\n\nThe end.\n",
)
.map_err(|e| e.to_string())?;
r.git(&wt, &["add", "README.md"])?;
r.git(&wt, &["commit", "-qm", "a readme with every element the page draws"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/readme-theme"), "main"])?;
let dir = r.work.join("shots").join("readme");
let _ = c.viewport(admin, 1280, 900);
// `[paint, token]` pairs: what the SVG's element paints, and what a
// probe painted with the theme's token paints, read the same way.
let read = "(() => { const svg = document.querySelector('.readme-page svg'); \
const probe = document.createElement('div'); document.body.appendChild(probe); \
const tok = v => { probe.style.color = 'var(' + v + ')'; return getComputedStyle(probe).color; }; \
const fillOf = sel => { const e = svg.querySelector(sel); return e ? getComputedStyle(e).fill : 'none drawn'; }; \
const strokeOf = sel => { const e = svg.querySelector(sel); return e ? getComputedStyle(e).stroke : 'none drawn'; }; \
const out = { \
paper: [fillOf('path.typst-shape[fill=\"#ffffff\"]'), tok('--surface')], \
box: [getComputedStyle(document.querySelector('.readme-page')).backgroundColor, tok('--surface')], \
ink: [fillOf('[fill=\"#16191d\"]'), tok('--ink')], \
link: [fillOf('[fill=\"#0a58ca\"]'), tok('--readme-link')], \
code: [fillOf('[fill=\"#f5f6f8\"]'), tok('--surface-raised')], \
rule: [strokeOf('[stroke=\"#cfd4da\"]'), tok('--line-strong')] }; \
probe.remove(); return JSON.stringify(out); })()";
// Every theme is walked and photographed whatever the first one says:
// a red run must still show all three.
let mut said = Vec::new();
let mut wrong: Vec<String> = Vec::new();
let out = (|| -> Result<(), String> {
for theme in ["light", "dark", "surprise"] {
c.eval(admin, &format!("(() => {{ try {{ localStorage.setItem('gunnar-theme', {}); }} catch (e) {{}} return 'ok'; }})()", esc(theme)))?;
c.navigate_fresh(admin, &format!("{}/repo/alice/readme-theme", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('.readme-page svg path.typst-shape')", 60_000)
.map_err(|e| format!("{theme}: the typeset README was not drawn: {e}"))?;
let want_attr = if theme == "light" { "null".to_owned() } else { esc(theme) };
c.wait_for(admin, &format!("document.documentElement.getAttribute('data-theme') === {want_attr}"), 5_000)
.map_err(|e| format!("{theme}: the page did not open in the stored theme: {e}"))?;
let _ = c.eval(admin, "document.fonts.ready.then(() => 'ok')");
std::thread::sleep(Duration::from_millis(800));
c.eval(admin, "(() => { const p = document.querySelector('.readme-page'); if (p) p.scrollIntoView({block: 'start'}); return 'ok'; })()")?;
std::thread::sleep(Duration::from_millis(300));
c.screenshot(admin, &dir.join(format!("{theme}.png")))?;
let j = crate::json::parse(&c.eval_str(admin, read)?).map_err(|e| format!("{theme}: {e}"))?;
let mut line = Vec::new();
for part in ["paper", "box", "ink", "link", "code", "rule"] {
let pair = j.get(part).map(|p| p.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect::<Vec<_>>()).unwrap_or_default();
let (paint, token) = (pair.first().cloned().unwrap_or_default(), pair.get(1).cloned().unwrap_or_default());
if token.is_empty() || paint != token {
wrong.push(format!("{theme}: the README's {part} paints {paint:?}, the theme's token is {token:?}"));
}
line.push(format!("{part} {paint}"));
}
// And the complaint itself, said plainly: no theme but light
// draws a white sheet.
if theme != "light" {
let paper = j.get("paper").map(|p| p.arr().first().and_then(|x| x.as_str()).unwrap_or("").to_owned()).unwrap_or_default();
if paper == "rgb(255, 255, 255)" || paper == "rgb(244, 244, 244)" {
wrong.push(format!("{theme}: the README is still a white sheet ({paper})"));
}
}
said.push(format!("{theme}: {}", line.join(", ")));
}
want(wrong.is_empty(), || format!("the typeset page does not follow the theme — {}", wrong.join(" ; ")))
})();
let _ = c.eval(admin, "(() => { try { localStorage.setItem('gunnar-theme', 'light'); } catch (e) {} document.documentElement.removeAttribute('data-theme'); return 'ok'; })()");
out?;
Ok(format!("{} ; pictures in {}", said.join(" ; "), dir.display()))
});
// ── an account that has bought nothing (owner 2026-10-17) ─────────────
// ONE panel, "Plan": monetize's `none` reads "Free" and the button "Buy a
// plan" — never "None yet", never "Subscription" — and the button lands
// on the ONE sign-up page. Checked against monetize's own answer.
go(rows, "an-account-that-has-bought-nothing-reads-free-and-buy-a-plan", || {
want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
let api = c.eval(admin, "fetch('/api/account/subscription/bundles').then(r => r.json()).then(w => ({ state: String((w.plan || {}).state ?? '') }))")?;
// No gauge (a shop selling no plans yet) or monetize's `none`: no plan.
want(api.text("state") == "none" || api.text("state").is_empty(), || format!("monetize does not say bob holds no plan at this point: {}", http::cut(&api.dump(), 300)))?;
c.navigate_fresh(admin, &format!("{}/account", r.origin()))?;
c.wait_for(admin, "!!document.getElementById('account-buy-grow')", 30_000).map_err(|e| {
let body = c.eval_str(admin, MAIN_TEXT).unwrap_or_default();
format!("You → Account drew no button ({e}): {}", http::cut(&body, 400))
})?;
let plan = c.eval_str(admin, "document.getElementById('account-plan').innerText.replace(/\\s+/g, ' ').trim()")?;
let main = c.eval_str(admin, MAIN_TEXT)?;
want(plan.contains("Free") && plan.contains("Buy a plan"), || format!("the Plan panel: {plan}"))?;
for never in ["None yet", "Subscription", "unknown", "contact us", "failed"] {
want(!main.contains(never), || format!("the page says {never:?}: {}", http::cut(&main, 300)))?;
}
c.eval(admin, "document.getElementById('account-buy-grow').click()")?;
c.wait_for(admin, "location.pathname === '/signup' && document.getElementById('heading').textContent === 'Buy a plan'", 20_000)
.map_err(|e| format!("Buy a plan did not land on the sign-up page's Buy a plan: {e}"))?;
Ok(plan)
});
// ── the buy button goes STRAIGHT to the vendor ─────────────────────────
let Some(_) = r.bench.as_ref() else {
go(rows, "the-offer-buttons-go-straight-to-the-vendors-hosted-checkout", || {
Err("NOT RUN: no --bench-monetize (a metered rig with the fake Stripe is what this row needs)".into())
});
return;
};
// ── Buying is the ONE sign-up page (owner 2026-10-17) ──────────────────
//
// The console's own More room panel is gone: a signed-in person buys and
// grows on `/signup?plan=10gb` (Account's ONE button opens it). These rows
// hold that page to what the panel's rows held: the window's slow reading
// is said while it lasts, a refused window says the server's sentence,
// the steps carry monetize's prices, and Upgrade goes straight to the
// vendor's checkout.
const SIGNUP_PROBE: &str = "(() => ({step: document.getElementById('step-name')?.textContent.trim() ?? '',
price: document.getElementById('price-month')?.textContent.trim() ?? '',
msg: document.getElementById('msg')?.textContent.trim() ?? '',
err: (document.getElementById('msg')?.className ?? '').includes('err'),
go: !!document.getElementById('go') && !document.getElementById('go').disabled,
heading: document.getElementById('heading')?.textContent.trim() ?? ''}))()";
let signup = |c: &mut Cdp| -> Result<(), String> {
c.navigate(person, &format!("{}/signup?plan=10gb", r.origin()))?;
c.wait_for(person, "location.pathname === '/signup' && !!document.getElementById('heading')", 30_000)
};
// ① The window read slowly: the server's "Awaiting the cloud provider…"
// while it lasts, then the steps — never an empty page.
go(rows, "the-first-visit-while-the-cloud-reading-is-slow-says-awaiting-then-the-steps", || {
let (code, said) = r.bench_post("/reading/slow/700")?;
want(code == 200, || format!("the bench would not slow its reading: {code} {said}"))?;
let watched = (|| -> Result<String, String> {
signup(c)?;
let began = Instant::now();
let mut awaited_ms = None::<u128>;
loop {
let p = c.eval(person, SIGNUP_PROBE)?;
let last = p.dump();
if p.text("msg").contains("Awaiting the cloud provider") && awaited_ms.is_none() {
awaited_ms = Some(began.elapsed().as_millis());
}
if !p.text("price").is_empty() && p.text("step") != "…" && !p.text("step").is_empty() {
break;
}
if began.elapsed() > Duration::from_secs(60) {
return Err(format!("no steps within 60 s: {}", http::cut(&last, 300)));
}
std::thread::sleep(Duration::from_millis(100));
}
let took = began.elapsed().as_millis();
let awaited = awaited_ms.ok_or_else(|| format!("the page never said \"Awaiting the cloud provider…\" before the steps at {took} ms"))?;
want(awaited < 5_000, || format!("\"Awaiting the cloud provider…\" first said only at {awaited} ms"))?;
Ok(format!("\"Awaiting the cloud provider…\" from {awaited} ms, the steps at {took} ms"))
})();
let (code, said) = r.bench_post("/reading/normal")?;
want(code == 200, || format!("the bench would not restore its reading: {code} {said}"))?;
watched
});
// ② A REFUSED window: the server's sentence, nothing to press; served
// again, the page (reloaded, as a person does) draws the steps.
go(rows, "a-refused-window-says-the-servers-sentence-and-nothing-can-be-pressed-then-served-draws-the-steps", || {
let (code, said) = r.bench_post("/window/refuse")?;
want(code == 200, || format!("the bench would not refuse the window: {code} {said}"))?;
let watched = (|| -> Result<String, String> {
signup(c)?;
c.wait_for(person, "(document.getElementById('msg')?.className ?? '').includes('err')", 30_000)
.map_err(|e| format!("a refused window said nothing ({e}): {}", c.eval(person, SIGNUP_PROBE).map(|j| j.dump()).unwrap_or_default()))?;
let p = c.eval(person, SIGNUP_PROBE)?;
want(!p.path("go").and_then(|g| g.as_bool()).unwrap_or(true), || format!("Upgrade can be pressed on a refused window: {}", p.dump()))?;
let server = c.eval_str(person, "fetch('/api/account/subscription/bundles').then(r => r.json()).then(j => j.message ?? j.error ?? '')")?;
let line = p.text("msg");
want(!server.is_empty() && line == server, || format!("the page's line {line:?} is not the server's sentence {server:?}"))?;
let (code, said) = r.bench_post("/window/serve")?;
want(code == 200, || format!("the bench would not serve the window: {code} {said}"))?;
signup(c)?;
c.wait_for(person, "(document.getElementById('price-month')?.textContent.trim() ?? '') !== ''", 30_000)
.map_err(|e| format!("served again, no steps ({e})"))?;
Ok(format!("refused: {:?}; served: the steps", http::cut(&line, 120)))
})();
let (code, said) = r.bench_post("/window/serve")?;
want(code == 200, || format!("the bench would not serve the window again: {code} {said}"))?;
watched
});
// ③ The steps carry monetize's price and period; − / + step through them.
go(rows, "the-signup-steps-carry-monetizes-price", || {
signup(c)?;
c.wait_for(person, "(document.getElementById('price-month')?.textContent.trim() ?? '') !== ''", 30_000)?;
let mut seen = Vec::new();
for _ in 0..8 {
let p = c.eval(person, SIGNUP_PROBE)?;
let (step, price) = (p.text("step"), p.text("price"));
want(!step.is_empty() && price.chars().any(|c| c.is_ascii_digit()), || format!("a step without monetize's price: {}", p.dump()))?;
seen.push(format!("{step} {price}"));
if c.eval(person, "document.getElementById('plus').disabled")?.as_bool() == Some(true) {
break;
}
c.eval(person, "(() => { document.getElementById('plus').click(); return 'plus'; })()")?;
}
Ok(seen.join(" · "))
});
// ④ Upgrade goes STRAIGHT to the vendor's hosted checkout, same tab.
go(rows, "the-offer-buttons-go-straight-to-the-vendors-hosted-checkout", || {
signup(c)?;
c.wait_for(person, "(document.getElementById('price-month')?.textContent.trim() ?? '') !== ''", 30_000)?;
// The account's own size is the first step (not buyable): step to one that is.
for _ in 0..8 {
if c.eval(person, "!document.getElementById('go').disabled")?.as_bool() == Some(true) {
break;
}
c.eval(person, "(() => { document.getElementById('plus').click(); return 'plus'; })()")?;
}
c.call(
Some(person),
"Fetch.enable",
r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#,
)?;
c.events.clear();
let before = c.eval_str(person, "location.href")?;
c.eval(person, "(() => { document.getElementById('go').click(); return 'pressed'; })()")?;
let paused = c.paused_request(person, 30_000);
let navigated: Vec<String> = c
.events
.iter()
.filter(|e| e.text("method") == "Page.frameNavigated" && e.text("params.frame.parentId").is_empty())
.map(|e| e.text("params.frame.url"))
.collect();
let Some((id, url)) = paused else {
let _ = c.call(Some(person), "Fetch.disable", "{}");
let now = c.eval_str(person, "location.href").unwrap_or_default();
let said = c.eval(person, SIGNUP_PROBE).map(|j| j.dump()).unwrap_or_default();
return Err(format!("Upgrade did not go to the vendor within 30 s: still at {now}; the page says {}", http::cut(&said, 600)));
};
let page = format!("<!doctype html><title>Checkout (fake Stripe)</title><h1>Fake Stripe checkout</h1><p>{url}</p>");
c.call(
Some(person),
"Fetch.fulfillRequest",
&format!(
r#"{{"requestId":{},"responseCode":200,"responseHeaders":[{{"name":"Content-Type","value":"text/html"}}],"body":{}}}"#,
esc(&id),
esc(&b64(page.as_bytes()))
),
)?;
c.wait_for(person, "location.host === 'checkout.fake-stripe.test'", 30_000)?;
let landed = c.eval_str(person, "location.href")?;
let _ = c.call(Some(person), "Fetch.disable", "{}");
want(landed.starts_with("https://checkout.fake-stripe.test/c/pay/cs_test_"), || format!("landed on {landed}"))?;
let between: Vec<&String> = navigated.iter().filter(|u| !u.starts_with("https://checkout.fake-stripe.test/")).collect();
want(between.is_empty(), || format!("an intermediate page between {before} and the checkout: {between:?}"))?;
c.navigate(person, &format!("{}/account", r.origin()))?;
Ok(format!("pressed once on {before} → {landed}, same tab, no page between"))
});
}