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

nordisk/gunnar-ui

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