uitest/src/signup.rs
//! **The ONE sign-up page** (owner, 2026-10-07: "go design it") — driven as a
//! person drives it, against the bench monetize and the mock cloud.
//!
//! * The front page's "Sign up account" button (owner 2026-10-15; before it
//! ONE link, "Sign up for paid service") opens THIS page, where the visitor
//! chooses between "Account on <host>" (selected) and "Private Gunnar
//! instance"; the old `/private` lands here too.
//! * The account's plans are a list of ONE currency (test2, 2026-10-08: every
//! + changed the currency; owner 2026-10-15: a list, the free account
//! first): the bench sells the kit's shape, a plan per market, and an
//! English browser is answered in the default market; a Danish one in DKK.
//! * Where monetize sells no private Gunnar (test2's: no `[estate_orders]`),
//! no "Private Gunnar" choice is drawn and no configuration sentence is
//! shown: a second bench, `--no-estates`, behind a console of its own.
//! * Where monetize HAS `[estate_orders]` but no step can be bought (test2's
//! today: its cloud lays no estate, every step unavailable — round 5.3), the
//! same: monetize's `Sells` says so, and the page draws what it is given. A
//! third bench, `--estates-unlaid`.
//! * Not signed in: "Sign up for an account", the private Gunnar's picture at the
//! first step, ONE − / +; + until it stops is Gigantic, the boxes visibly
//! bigger; the price line and the resources are monetize's words.
//! * At 390 px the picture column collapses as on the front page.
//! * The account half (`?plan=10gb`): "Sign up for an account", the list.
//! * Signed in (bob): "New instance" with his account filled in and LOCKED;
//! "Grow your account" starts at what he has, its button says Upgrade;
//! "Grow acme's Gunnar" is COMING — said, and no button (the rig's console
//! is told it is acme's, `GUNNAR_UI_INSTANCE`, so the line is drawable).
//!
//! The pictures go to `<work>/shots/signup/` for the owner. Needs
//! `--bench-monetize` (monetize is what sells); without it the rows say NOT RUN.
use std::time::Duration;
use crate::cdp::Cdp;
use crate::http;
use crate::matrix::{go, want, Row};
use crate::rig::Rig;
/// The page's state as a person would read it.
const READ: &str = "(() => { const $ = (id) => document.getElementById(id); const box = (r) => { const b = $('box-' + r); return b ? Math.round(b.getBoundingClientRect().width) : 0; };
return { heading: $('heading').textContent, step: $('step-name').textContent, res: $('step-res').textContent,
month: $('price-month').textContent, total: $('price-total').textContent,
minus: $('minus').disabled, plus: $('plus').disabled, sizeShown: !$('size').hidden,
go: $('go').hidden ? null : $('go').textContent, goDisabled: $('go').disabled,
coming: $('coming').hidden ? '' : $('coming').textContent, msg: $('msg').textContent,
account: $('account').value, locked: $('account').readOnly,
choices: [...document.querySelectorAll('#choice button')].map(b => b.textContent + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')),
plans: [...document.querySelectorAll('#plans button')].map(b => b.querySelector('.plan-name').textContent + ' = ' + b.querySelector('.plan-price').textContent + (b.getAttribute('aria-checked') === 'true' ? '*' : '')),
plansShown: !$('plans').hidden, estateShown: !$('estate').hidden,
labels: ['front', 'main', 'twin'].map(r => $('label-' + r).textContent),
boxes: ['front', 'main', 'twin'].map(box),
rings: document.querySelectorAll('#pic .ring').length, caption: $('pic-caption').textContent,
wide: document.documentElement.scrollWidth, inner: window.innerWidth,
art: getComputedStyle(document.querySelector('.art')).position }; })()";
/// The ladder has answered (a step name, not the placeholder).
const LOADED: &str = "(() => { const s = document.getElementById('step-name').textContent; return s !== '' && s !== '\u{2026}'; })()";
fn s(j: &crate::json::Json, k: &str) -> String {
j.path(k).and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default()
}
fn b(j: &crate::json::Json, k: &str) -> bool {
j.path(k).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn n(j: &crate::json::Json, k: &str, i: usize) -> f64 {
j.path(k).map(|a| a.arr().get(i).and_then(|v| v.as_f64()).unwrap_or(0.0)).unwrap_or(0.0)
}
/// The account's plan rows, as a person reads them: "<name> = <price>".
const PLAN_ROWS: &str = "[...document.querySelectorAll('#plans button')].map(b => b.querySelector('.plan-name').textContent + ' = ' + b.querySelector('.plan-price').textContent).join(' | ')";
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, bob: &str) {
let names = [
"signup-page-the-front-pages-one-paid-link-opens-it",
"signup-page-a-stranger-signs-up-for-a-new-private-gunnar-mini-to-gigantic",
"signup-page-at-phone-width-the-picture-column-collapses",
"signup-page-a-stranger-signs-up-for-an-account",
"signup-page-signed-in-a-new-instance-is-theirs-and-the-account-locked",
"signup-page-signed-in-grow-your-account-starts-at-what-they-have",
"signup-page-signed-in-grow-an-instance-asks-its-own-account",
"signup-page-the-console-leads-a-signed-in-person-to-it",
"signup-page-the-account-ladder-steps-sizes-in-one-currency",
"signup-page-no-private-choice-where-none-is-sold",
"signup-page-no-private-choice-where-no-step-can-be-bought",
"signup-page-a-new-visitor-walks-the-room-bar-and-checks-out-the-stop-chosen",
];
if r.bench_control.is_none() {
for name in names {
go(rows, name, || Err("NOT RUN: no --bench-monetize (monetize is what sells)".into()));
}
return;
}
let dir = r.work.join("shots").join("signup");
let _ = std::fs::create_dir_all(&dir);
// The owner's three findings of 2026-10-08, before and after: the pictures
// he asked for, apart from the page's own gallery.
let fix = r.work.join("shots").join("signup-fix");
let _ = std::fs::create_dir_all(&fix);
let mut anon = String::new();
go(rows, names[0], || {
anon = c.isolated_page()?;
c.viewport(&anon, 1440, 900)?;
c.navigate(&anon, &format!("{}/login", r.origin()))?;
// Let /auth/status reveal whatever it reveals; then the page as a person sees it.
c.wait_for(&anon, "new Promise(r => setTimeout(() => r(true), 1500))", 5_000)?;
c.screenshot(&anon, &fix.join("first-page.png"))?;
let line = c.eval_str(&anon, "(() => { const p = document.getElementById('paid-link'); return (p && !p.hidden) ? p.innerText.replace(/\\s+/g, ' ').trim() : ''; })()")?;
want(line == "Sign up account", || format!("the sign-up button reads {line:?}, not \"Sign up account\""))?;
let href = c.eval_str(&anon, "(() => { const a = document.getElementById('paid-link'); return a ? a.getAttribute('href') : 'no paid-link'; })()")?;
want(href == "/signup", || format!("the paid link leads to {href}"))?;
c.eval_str(&anon, "(() => { document.getElementById('paid-link').click(); return 'clicked'; })()")?;
c.wait_for(&anon, "location.pathname === '/signup' && document.querySelectorAll('#choice button').length === 2", 20_000)
.map_err(|_| "the paid link did not open the sign-up page with its two choices".to_owned())?;
let choices = c.eval_str(&anon, "[...document.querySelectorAll('#choice button')].map(b => b.textContent).join(' | ')")?;
let host = c.eval_str(&anon, "location.hostname")?;
want(choices == format!("Account on {host} | Private Gunnar instance"), || format!("the visitor chooses between {choices:?}"))?;
c.navigate(&anon, &format!("{}/private?company=acme", r.origin()))?;
c.wait_for(&anon, "location.pathname === '/signup'", 20_000).map_err(|_| "/private did not land on the sign-up page".to_owned())?;
let landed = c.eval_str(&anon, "location.search")?;
want(landed == "?plan=private&company=acme", || format!("/private?company=acme landed on {landed}"))?;
Ok(format!("{line:?} -> {href}: {choices}; /private?company=acme -> /signup{landed}"))
});
go(rows, names[1], || {
want(!anon.is_empty(), || "the row before failed".to_owned())?;
c.viewport(&anon, 1440, 900)?;
c.navigate(&anon, &format!("{}/signup?plan=private", r.origin()))?;
c.wait_for(&anon, LOADED, 60_000).map_err(|_| "the ladder never arrived from monetize".to_owned())?;
std::thread::sleep(Duration::from_millis(500));
let at = c.eval(&anon, READ)?;
want(s(&at, "heading") == "Sign up for an account", || format!("heading {:?}", s(&at, "heading")))?;
want(b(&at, "estateShown") && !b(&at, "plansShown"), || "the private Gunnar draws no server picture, or the account's list".to_owned())?;
// One shop 2 (owner 2026-10-07): a visitor buying a private Gunnar first
// makes their account here — the button says so, in the owner's words.
let host = c.eval_str(&anon, "location.hostname")?;
want(s(&at, "go") == format!("Create your account at {host}"), || format!("button {:?}", s(&at, "go")))?;
want(b(&at, "minus") && !b(&at, "plus"), || "at the first step: − off, + on".to_owned())?;
want(s(&at, "month").contains("EUR a month") && s(&at, "total").contains("prepaid"), || format!("the price is not monetize's words: {} / {}", s(&at, "month"), s(&at, "total")))?;
want(s(&at, "res").contains("cores") && s(&at, "res").contains("GB data"), || format!("resources {:?}", s(&at, "res")))?;
let labels = at.path("labels").map(|l| l.dump()).unwrap_or_default();
want(labels == r#"["UI","SERVER","REPLICA"]"#, || format!("labels {labels}"))?;
want(at.path("rings").and_then(|v| v.as_f64()) == Some(2.0) && s(&at, "caption") == "Gunnar instance protected by VPN", || "not a double ring with its caption".to_owned())?;
let first = s(&at, "step");
let main_small = n(&at, "boxes", 1);
c.screenshot(&anon, &dir.join("1-anonymous-private-first-step-desktop.png"))?;
let mut steps = vec![first.clone()];
for _ in 0..12 {
if c.eval(&anon, "document.getElementById('plus').disabled")?.as_bool() == Some(true) {
break;
}
c.eval_str(&anon, "(() => { document.getElementById('plus').click(); return 'up'; })()")?;
std::thread::sleep(Duration::from_millis(350));
steps.push(c.eval_str(&anon, "document.getElementById('step-name').textContent")?);
}
let top = c.eval(&anon, READ)?;
std::thread::sleep(Duration::from_millis(400));
c.screenshot(&anon, &dir.join("2-anonymous-private-last-step-desktop.png"))?;
want(b(&top, "plus") && !b(&top, "minus"), || "at the top: + off, − on".to_owned())?;
let main_big = n(&top, "boxes", 1);
want(main_big > main_small * 1.3, || format!("SERVER did not visibly grow: {main_small} -> {main_big} px"))?;
want(s(&top, "month") != s(&at, "month"), || "the price did not move with the size".to_owned())?;
want(s(&top, "res").contains("cores") && !b(&top, "goDisabled"), || format!("the top step is not sold: {:?}", s(&top, "res")))?;
// Back to the first step for the rows after this one.
for _ in 0..12 {
c.eval_str(&anon, "(() => { document.getElementById('minus').click(); return 'down'; })()")?;
}
Ok(format!("{} ({} {}) .. {} ({} {}); SERVER {main_small} -> {main_big} px", steps.first().cloned().unwrap_or_default(), s(&at, "res"), s(&at, "month"), steps.last().cloned().unwrap_or_default(), s(&top, "res"), s(&top, "month")))
});
go(rows, names[2], || {
want(!anon.is_empty(), || "the rows before failed".to_owned())?;
c.viewport(&anon, 390, 844)?;
c.navigate(&anon, &format!("{}/signup?plan=private", r.origin()))?;
c.wait_for(&anon, LOADED, 60_000).map_err(|_| "the ladder never arrived".to_owned())?;
std::thread::sleep(Duration::from_millis(600));
let at = c.eval(&anon, READ)?;
c.screenshot(&anon, &dir.join("3-anonymous-private-phone.png"))?;
want(s(&at, "art") == "fixed", || format!("the picture column did not become the ground: {}", s(&at, "art")))?;
let (wide, inner) = (at.path("wide").and_then(|v| v.as_f64()).unwrap_or(0.0), at.path("inner").and_then(|v| v.as_f64()).unwrap_or(0.0));
want(wide <= inner + 1.0, || format!("the page is wider than the phone: {wide} > {inner}"))?;
for _ in 0..12 {
c.eval_str(&anon, "(() => { document.getElementById('plus').click(); return 'up'; })()")?;
}
std::thread::sleep(Duration::from_millis(500));
c.screenshot(&anon, &dir.join("4-anonymous-private-last-step-phone.png"))?;
c.viewport(&anon, 1440, 900)?;
Ok(format!("390 px: one column, the picture behind it, {wide} px wide"))
});
go(rows, names[3], || {
want(!anon.is_empty(), || "the rows before failed".to_owned())?;
c.viewport(&anon, 1440, 900)?;
c.navigate(&anon, &format!("{}/signup?plan=10gb", r.origin()))?;
c.wait_for(&anon, &format!("{LOADED} || document.getElementById('msg').textContent !== ''"), 30_000).map_err(|_| "the account plans never arrived".to_owned())?;
std::thread::sleep(Duration::from_millis(400));
let at = c.eval(&anon, READ)?;
c.screenshot(&anon, &dir.join("5-anonymous-account-desktop.png"))?;
want(s(&at, "heading") == "Sign up for an account", || format!("heading {:?}", s(&at, "heading")))?;
// The visitor's choices (owner 2026-10-15): "Account on <host>", pressed.
let host = c.eval_str(&anon, "location.hostname")?;
let choices = at.path("choices").map(|l| l.dump()).unwrap_or_default();
want(choices.contains(&format!("Account on {host}*")), || format!("the choice: {choices}"))?;
let plans = at.path("plans").map(|l| l.dump()).unwrap_or_default();
want(plans.contains("Free account = Free") && at.path("plans").map(|v| v.arr().len()).unwrap_or(0) >= 2 && s(&at, "msg").is_empty(), || format!("not the free account and monetize's plans: {plans} {:?}", s(&at, "msg")))?;
want(!b(&at, "estateShown") && !b(&at, "sizeShown"), || "the server picture is drawn for the account".to_owned())?;
// The first paid plan is chosen: its button says what is paid (owner's buy rule, 2026-10-23).
want(s(&at, "go").starts_with("Pay ") && !s(&at, "go").contains("month"), || format!("button {:?}", s(&at, "go")))?;
Ok(plans)
});
go(rows, names[4], || {
c.viewport(bob, 1440, 900)?;
c.navigate(bob, &format!("{}/signup?plan=private", r.origin()))?;
c.wait_for(bob, LOADED, 60_000).map_err(|_| "the ladder never arrived".to_owned())?;
std::thread::sleep(Duration::from_millis(500));
let at = c.eval(bob, READ)?;
c.screenshot(bob, &dir.join("6-signed-in-new-instance-desktop.png"))?;
want(s(&at, "heading") == "Sign up for a new private Gunnar", || format!("a new instance's heading: {:?}", s(&at, "heading")))?;
want(s(&at, "account") == "bob" && b(&at, "locked"), || format!("the account is not bob's, locked: {:?} locked={}", s(&at, "account"), b(&at, "locked")))?;
let choices = at.path("choices").map(|l| l.dump()).unwrap_or_default();
want(choices.contains("New instance*"), || format!("the choice: {choices}"))?;
Ok(format!("bob, locked; {choices}"))
});
// gunnar.rs sells room as PLANS (owner, 2026-10-05): the bench does too
// for this row, and stops again after it, as the room-plan rows do.
let plans_on = r.bench_post("/plans/on");
go(rows, names[5], || {
let (code, said) = plans_on.clone()?;
want(code == 200, || format!("the bench would not sell plans: {code} {said}"))?;
c.viewport(bob, 1440, 900)?;
c.navigate(bob, &format!("{}/signup?plan=10gb", r.origin()))?;
c.wait_for(bob, &format!("{LOADED} || document.getElementById('msg').textContent !== ''"), 60_000).map_err(|_| "the account's offers never arrived".to_owned())?;
std::thread::sleep(Duration::from_millis(500));
let at = c.eval(bob, READ)?;
c.screenshot(bob, &dir.join("7-signed-in-grow-account-desktop.png"))?;
want(s(&at, "heading") == "Grow your account", || format!("heading {:?}", s(&at, "heading")))?;
want(s(&at, "go") == "Upgrade", || format!("button {:?}", s(&at, "go")))?;
want(b(&at, "minus"), || "the − is not at its floor where the account starts".to_owned())?;
// One step up: a bigger plan, pressable.
c.eval_str(bob, "(() => { document.getElementById('plus').click(); return 'up'; })()")?;
std::thread::sleep(Duration::from_millis(400));
let up = c.eval(bob, READ)?;
c.screenshot(bob, &dir.join("7b-signed-in-grow-account-one-step-up-desktop.png"))?;
want(!b(&up, "goDisabled") && s(&up, "step") != s(&at, "step"), || format!("one step up is not a plan to press: {:?}", s(&up, "step")))?;
Ok(format!("starts at {:?} ({}); {}; + {:?} ({})", s(&at, "step"), s(&at, "month"), s(&at, "total"), s(&up, "step"), s(&up, "month")))
});
let _ = r.bench_post("/plans/off");
go(rows, names[6], || {
c.viewport(bob, 1440, 900)?;
c.navigate(bob, &format!("{}/signup?plan=grow", r.origin()))?;
c.wait_for(bob, "document.getElementById('heading').textContent.startsWith('Grow acme')", 20_000)
.map_err(|_| "the private instance's growth is not offered as acme's".to_owned())?;
// ★ One shop 4 (2026-10-07): growing an instance is REAL — the page asks
// the instance's own ladder on the signed-in account's ticket. acme is
// no order of bob's at the bench, so monetize's one sentence is shown
// and nothing is offered to press: no step, no − / +, no Upgrade.
c.wait_for(bob, "document.getElementById('msg').textContent !== ''", 30_000)
.map_err(|_| "the instance's ladder was never answered".to_owned())?;
std::thread::sleep(Duration::from_millis(400));
let at = c.eval(bob, READ)?;
c.screenshot(bob, &dir.join("8-signed-in-grow-an-instance-not-theirs-desktop.png"))?;
want(s(&at, "heading") == "Grow acme\u{2019}s Gunnar", || format!("heading {:?}", s(&at, "heading")))?;
want(s(&at, "coming").is_empty(), || format!("it still says coming: {:?}", s(&at, "coming")))?;
want(s(&at, "msg").contains("bought, read and changed by its gunnar.rs account"), || format!("not monetize's sentence: {:?}", s(&at, "msg")))?;
want(!b(&at, "sizeShown"), || "a − / + is drawn for an instance that is not bob's".to_owned())?;
want(b(&at, "goDisabled"), || "Upgrade can be pressed for an instance that is not bob's".to_owned())?;
Ok(s(&at, "msg"))
});
// ★ The login page sends a signed-in person past its offers, so the
// console carries the door: You → Account's ONE button (owner
// 2026-10-17), where /auth/status says it leads; a new instance is a
// choice on the sign-up page itself.
go(rows, names[7], || {
c.viewport(bob, 1440, 900)?;
c.navigate(bob, &format!("{}/account", r.origin()))?;
c.wait_for(bob, "!!document.getElementById('account-buy-grow')", 30_000)
.map_err(|_| "no button on Account".to_owned())?;
std::thread::sleep(Duration::from_millis(800));
let door = c.eval_str(bob, "document.getElementById('account-buy-grow').getAttribute('href')")?;
let word = c.eval_str(bob, "document.getElementById('account-buy-grow').textContent.trim()")?;
c.screenshot(bob, &dir.join("9-signed-in-console-account-button-desktop.png"))?;
want(door == "/signup?plan=10gb", || format!("the button leads elsewhere: {door}"))?;
c.eval_str(bob, "(() => { document.getElementById('account-buy-grow').click(); return 'clicked'; })()")?;
// The button's word = the heading = the tab (owner 2026-10-17).
c.wait_for(bob, &format!("location.pathname === '/signup' && document.getElementById('heading').textContent === {w} && (document.querySelector('#choice button[aria-pressed=\"true\"]') || {{}}).textContent === {w}", w = crate::json::esc(&word)), 20_000)
.map_err(|_| format!("the button ({word:?}) did not open the sign-up page under the same word"))?;
Ok(door)
});
// ★ The kit's `[signup.plans]` shape — a plan per market, in its currency —
// with two sizes each, so the − / + has sizes to step through. Back to
// the bench's own after the row.
let markets_on = r.bench_post("/signup/markets");
go(rows, names[8], || {
let (code, said) = markets_on.clone()?;
want(code == 200, || format!("the bench would not sell a plan per market: {code} {said}"))?;
// A fresh visitor on an English browser: a language that names no market.
let page = c.isolated_page()?;
c.viewport(&page, 1440, 900)?;
c.navigate(&page, &format!("{}/signup?plan=10gb", r.origin()))?;
c.wait_for(&page, &format!("{LOADED} || document.getElementById('msg').textContent !== ''"), 30_000).map_err(|_| "the account plans never arrived".to_owned())?;
std::thread::sleep(Duration::from_millis(500));
c.screenshot(&page, &fix.join("signup-10gb.png"))?;
// The ONE box's rows, after the free account.
let rows = c.eval_str(&page, PLAN_ROWS)?;
let seen: Vec<String> = rows.split(" | ").filter(|r| !r.starts_with("Free account")).map(str::to_owned).collect();
let currencies: std::collections::BTreeSet<String> = seen.iter().filter_map(|l| l.split(" = ").nth(1)).filter_map(|p| p.split_whitespace().nth(1)).map(str::to_owned).collect();
want(currencies.len() == 1, || format!("the plans are in more than ONE currency: {seen:?}"))?;
want(seen.len() == 2 && seen[0].starts_with("10 GB") && seen[1].starts_with("50 GB"), || format!("the list is not the sizes: {seen:?}"))?;
// The server reads the browser's languages: a Dane is answered in DKK.
let dk = http::req(&r.ui_addr(), "GET", "/auth/signup/ladder", &[("accept-language", "da-DK,da;q=0.9,en;q=0.8".to_owned())], None)?;
want(dk.status == 200 && dk.body.contains("DKK") && !dk.body.contains("SEK") && !dk.body.contains("EUR"), || format!("a Danish browser: {} {}", dk.status, http::cut(&dk.body, 300)))?;
Ok(format!("{} ; Danish browser: DKK only", seen.join(" -> ")))
});
let _ = r.bench_post("/signup/normal");
// ★ test2's monetize sells no private Gunnar (no `[estate_orders]`): the
// owner chose "Private Gunnar" and read "this monetize sells no private
// estates ([estate_orders] is absent)" with no − / +.
go(rows, names[9], || {
let (Some(addr), Some(origin)) = (r.unsold_addr(), r.unsold_origin()) else {
return Err("NOT RUN: no console that sells no private Gunnar (a second bench monetize, --no-estates)".into());
};
no_private_choice(c, &addr, &origin, &fix.join("signup-private-where-none-is-sold.png"))
});
// ★ Round 5.3 (test2 2026-10-08): `/auth/private/ladder` answered 200 with
// all six steps `available: false` — "the upcloud cloud cannot lay an
// estate" — and /signup still offered "Private Gunnar", a choice nobody
// could buy. monetize's `Sells` is the ladder's answer now; the console
// over it draws no private choice and no − / + for one.
go(rows, names[10], || {
let (Some(addr), Some(origin)) = (r.unlaid_addr(), r.unlaid_origin()) else {
return Err("NOT RUN: no console whose monetize lays no private Gunnar (a third bench monetize, --estates-unlaid)".into());
};
no_private_choice(c, &addr, &origin, &fix.join("signup-private-where-no-step-can-be-bought.png"))
});
// ★ Round 5.3.3 (owner on test2 2026-10-09: "+ / − don't work", "should be
// 2 ways of signup"): the bench sells the KIT's plans priced by the rule,
// and a new visitor's − / + walks the same stops an account grows on —
// 10 GB · 1 month, then 12 months from 10 GB to 1 TB — each its own
// price, beside the "Private Gunnar" choice; the stop chosen is the one
// the checkout is minted for, at its price. No cap, no room-left words.
let kit_on = r.bench_post("/plans/kit");
go(rows, names[11], || {
let (code, said) = kit_on.clone()?;
want(code == 200, || format!("the bench would not sell the kit's plans: {code} {said}"))?;
// The price basis is read once, as monetize starts: wait for it here,
// not in the page (a ladder asked before it is the one-plan table).
let began = std::time::Instant::now();
loop {
let l = http::req(&r.ui_addr(), "GET", "/auth/signup/ladder", &[("accept-language", "sv-SE,sv;q=0.9".to_owned())], None)?;
if l.status == 200 && l.body.matches("\"id\"").count() >= 7 {
break;
}
if began.elapsed() > Duration::from_secs(60) {
return Err(format!("the ladder never priced the kit's plans: {} {}", l.status, http::cut(&l.body, 400)));
}
std::thread::sleep(Duration::from_millis(500));
}
let page = c.isolated_page()?;
c.viewport(&page, 1440, 900)?;
c.navigate(&page, &format!("{}/signup?plan=10gb", r.origin()))?;
c.wait_for(&page, &format!("{LOADED} || document.getElementById('msg').textContent !== ''"), 30_000).map_err(|_| "the account plans never arrived".to_owned())?;
std::thread::sleep(Duration::from_millis(500));
let at = c.eval(&page, READ)?;
let choices = at.path("choices").map(|l| l.dump()).unwrap_or_default();
want(at.path("choices").map(|v| v.arr().len()) == Some(2) && choices.contains("Private Gunnar"), || format!("not two ways in: {choices}"))?;
want(s(&at, "msg").is_empty(), || format!("the page says {:?}", s(&at, "msg")))?;
c.screenshot(&page, &fix.join("signup-room-bar-first-stop.png"))?;
// The list (owner 2026-10-15): the free account, then every stop the
// rule prices, each its own price — 10 GB · 1 month, then 12 months
// from 10 GB to 1 TB.
let rows = c.eval_str(&page, PLAN_ROWS)?;
let seen: Vec<&str> = rows.split(" | ").filter(|r| !r.starts_with("Free account")).collect();
let steps: std::collections::BTreeSet<&str> = seen.iter().filter_map(|l| l.split(" = ").next()).collect();
let prices: std::collections::BTreeSet<&str> = seen.iter().filter_map(|l| l.split(" = ").nth(1)).collect();
want(seen.len() >= 7 && steps.len() == seen.len() && prices.len() == seen.len(), || format!("the list is not seven priced stops: {seen:?}"))?;
want(seen[0].contains("1 month") && seen.last().is_some_and(|l| l.contains("1000")), || format!("not 10 GB · 1 month up to 1 TB: {seen:?}"))?;
// The last stop, picked: the one checked.
c.eval_str(&page, "(() => { const all = document.querySelectorAll('#plans button'); all[all.length - 1].click(); return 'picked'; })()")?;
std::thread::sleep(Duration::from_millis(300));
c.screenshot(&page, &fix.join("signup-room-bar-top-stop.png"))?;
let chosen = c.eval_str(&page, "document.querySelector('#plans button[aria-checked=\"true\"] .plan-name').textContent")?;
want(seen.last().is_some_and(|l| l.starts_with(&chosen)), || format!("the last stop is not the one checked: {chosen:?}"))?;
let text = c.eval_str(&page, "document.body.innerText")?.to_ascii_lowercase();
for never in ["room left", " cap ", "capacity", "limit"] {
want(!text.contains(never), || format!("the visitor reads {never:?}: {}", http::cut(&text, 400)))?;
}
Ok(seen.join(" | "))
});
let _ = r.bench_post("/plans/off");
}
/// **A console over a monetize that sells no private Gunnar**, as a person
/// reads its sign-up page: no "Private Gunnar instance" choice, the account's
/// list of plans, no configuration sentence; `/auth/status` hands out no
/// private door; `/auth/private/ladder` is the console's own refusal.
fn no_private_choice(c: &mut Cdp, addr: &str, origin: &str, shot: &std::path::Path) -> Result<String, String> {
{
let page = c.isolated_page()?;
c.viewport(&page, 1440, 900)?;
c.navigate(&page, &format!("{origin}/signup?plan=private"))?;
c.wait_for(&page, "document.getElementById('step-name').textContent !== '\u{2026}' || document.getElementById('msg').textContent !== ''", 30_000)
.map_err(|_| "the page never settled".to_owned())?;
std::thread::sleep(Duration::from_millis(800));
c.screenshot(&page, shot)?;
let at = c.eval(&page, READ)?;
let text = c.eval_str(&page, "document.body.innerText")?;
for config in ["estate_orders", "sells no private estates", "monetize", "cannot lay an estate", "lay_estate"] {
want(!text.contains(config), || format!("a configuration sentence on the page ({config}): {:?}", s(&at, "msg")))?;
}
let choices = at.path("choices").map(|l| l.dump()).unwrap_or_default();
want(!choices.contains("Private Gunnar"), || format!("a private choice where none is sold: {choices}"))?;
want(s(&at, "heading") == "Sign up for an account" && b(&at, "plansShown"), || format!("not the account's list: {:?} list shown {}", s(&at, "heading"), b(&at, "plansShown")))?;
let st = http::req(addr, "GET", "/auth/status", &[], None)?;
let j = crate::json::parse(&st.body)?;
want(j.path("private_url").and_then(|v| v.as_str().map(str::to_owned)).is_none(), || format!("/auth/status hands out a private door: {}", http::cut(&st.body, 300)))?;
want(j.path("signup_url").and_then(|v| v.as_str().map(str::to_owned)).as_deref() == Some("/signup"), || format!("no sign-up page for the paid link: {}", http::cut(&st.body, 300)))?;
let ladder = http::req(addr, "GET", "/auth/private/ladder", &[], None)?;
want(ladder.status == 404 && ladder.body.contains("A private Gunnar is not sold here.") && !ladder.body.contains("estate_orders"), || format!("the private ladder answered {} {}", ladder.status, http::cut(&ladder.body, 200)))?;
Ok(format!("{choices}; {} at {}; the private ladder: {}", s(&at, "heading"), s(&at, "step"), http::cut(&ladder.body, 80)))
}
}