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/signuppages.rs

//! ★ **The first page's two buttons and the sign-up page's account choice, in
//! a real browser** (owner 2026-10-15).
//!
//! * The first page (`login.html` as a METERED console serves it, the three
//!   `login-offer.*` files spliced in as `auth::routes::login_html` does):
//!   "Sign up account" and "Log in" are TWO buttons side by side on one row,
//!   the same width; the free line reads "Host your own (setup guide)";
//!   "Sign up account" opens the sign-up page.
//! * The sign-up page (`signup.html`, byte for byte): "Sign up for an
//!   account"; the LEFT choice, "Account on <host>", selected, with a list of
//!   three — the free account (read only), 10 GB · 1 month, 10 GB · 6 months,
//!   each with the price the ONE box words; the server picture, its − / + and
//!   its price hidden until "Private Gunnar instance" is pressed.
//! * The order: every row makes the account the same way (`gunnar/account`);
//!   a plan comes back to the sign-up page to be paid, the free one goes on
//!   to the console.
//!
//! Both pages are served by a stub on loopback that answers the console's
//! JSON, and driven by headless Chrome over the DevTools Protocol with REAL
//! mouse clicks at each element's own box. Pictures go to
//! `$SIGNUPPAGES_SHOTS` when it is set.
//!
//! Needs `GUNNAR_UI_CHROME` (a Chrome or chrome-headless-shell binary);
//! without it each test says NOT RUN and returns.

#![cfg(test)]

use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use crate::cdp::{Cdp, Chrome};
use crate::json::{esc, parse, Json};

const LOGIN: &str = include_str!("../../crates/gunnar-ui-server/src/auth/login.html");
const SIGNUP: &str = include_str!("../../crates/gunnar-ui-server/src/auth/signup.html");

/// The first page as a metered console assembles it (`auth::routes::login_html`). Since LOGIN-1
/// (gunnar-ui 7b5ae4a, owner 2026-10-27) nothing is spliced in: /login is the login, and sign-up
/// is the front page's; the three `login-offer.*` files are gone, and their `include_str!`
/// kept this whole crate from compiling (lane ACCT-PAGE, 2026-10-29). The first-page tests here
/// still look for the old "Sign up account" button: stink row ACCT-UITEST.
fn metered_login() -> String {
    LOGIN.to_owned()
}

/// A visitor on a console that sells both: an account here and a private Gunnar.
const VISITOR: &str = r#"{"authenticated":false,"bootstrap_spent":true,"account_offer":"10GB","buy_url":"/signup?plan=10gb","signup_url":"/signup","private_url":"/signup?plan=private","may_buy":true}"#;

/// The ONE box's account plans as `/auth/signup/ladder` passes them on: its
/// names, its price words and its periods (solo.toml, 2026-10-15).
const PLANS: &str = r#"{"market":"SE","plans":[
    {"id":"gunnar/10gb-1m","name":"10 GB","resources_words":"10 GB of room, kept on the server and its replica","price_words":"110.00 SEK a month","period_days":30},
    {"id":"gunnar/10gb-6m","name":"10 GB","resources_words":"10 GB of room, kept on the server and its replica","price_words":"594.00 SEK for 180 days","period_days":180}]}"#;

const PRIVATE: &str = r#"{"apex":"gunnar.rs","start":0,"floor":0,"steps":[
    {"name":"Mini","resources_words":"2 cores, 40 GB data","monthly_words":"40.00 EUR a month","total_words":"120.00 EUR · 3 months prepaid","available":true,
     "sizes":{"front_plan":"f","main_plan":"m","main_data_gib":40},
     "machines":[{"role":"front","cores":1,"memory_mib":2048},{"role":"main","cores":2,"memory_mib":4096},{"role":"twin","cores":1,"memory_mib":2048}]},
    {"name":"Small","resources_words":"4 cores, 80 GB data","monthly_words":"80.00 EUR a month","total_words":"240.00 EUR · 3 months prepaid","available":true,
     "sizes":{"front_plan":"f","main_plan":"m2","main_data_gib":80},
     "machines":[{"role":"front","cores":1,"memory_mib":2048},{"role":"main","cores":4,"memory_mib":8192},{"role":"twin","cores":2,"memory_mib":4096}]}]}"#;

type Posted = Arc<Mutex<Vec<(String, String)>>>;

fn serve() -> (u16, Posted) {
    serve_answering(r#"{"words":"recorded"}"#)
}

/// [`serve`], answering `POST /auth/signup/start` with `start` (monetize's answer, as the
/// console passes it on).
fn serve_answering(start: &'static str) -> (u16, Posted) {
    let l = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
    let port = l.local_addr().unwrap().port();
    let posted: Posted = Default::default();
    let login = Arc::new(metered_login());
    let seen = posted.clone();
    std::thread::spawn(move || {
        for conn in l.incoming().flatten() {
            let (login, seen) = (login.clone(), seen.clone());
            std::thread::spawn(move || answer(conn, &login, &seen, start));
        }
    });
    (port, posted)
}

fn answer(mut conn: TcpStream, login: &str, posted: &Posted, start: &str) {
    let _ = conn.set_read_timeout(Some(Duration::from_secs(5)));
    let mut r = BufReader::new(conn.try_clone().unwrap());
    let mut line = String::new();
    if r.read_line(&mut line).is_err() {
        return;
    }
    let mut parts = line.split_whitespace();
    let (method, target) = (parts.next().unwrap_or("").to_owned(), parts.next().unwrap_or("/").to_owned());
    let mut len = 0usize;
    loop {
        let mut h = String::new();
        if r.read_line(&mut h).is_err() || h.trim().is_empty() {
            break;
        }
        if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
            len = v.trim().parse().unwrap_or(0);
        }
    }
    let mut body = vec![0u8; len];
    let _ = r.read_exact(&mut body);
    let path = target.split('?').next().unwrap_or("/").to_owned();
    let (code, kind, out): (u16, &str, String) = match (method.as_str(), path.as_str()) {
        ("GET", "/login") => (200, "text/html; charset=utf-8", login.to_owned()),
        ("GET", "/signup") => (200, "text/html; charset=utf-8", SIGNUP.to_owned()),
        ("GET", "/auth/status") => (200, "application/json", VISITOR.to_owned()),
        ("GET", "/auth/signup/ladder") => (200, "application/json", PLANS.to_owned()),
        ("GET", "/auth/private/ladder") => (200, "application/json", PRIVATE.to_owned()),
        ("GET", "/auth/signup/name") => (200, "application/json", r#"{"free":true}"#.to_owned()),
        ("POST", p) => {
            posted.lock().unwrap().push((p.to_owned(), String::from_utf8_lossy(&body).into_owned()));
            let said = if p == "/auth/signup/start" { start } else { r#"{"words":"recorded"}"# };
            (200, "application/json", said.to_owned())
        }
        _ => (404, "text/plain", "no".to_owned()),
    };
    let head = format!("HTTP/1.1 {code} X\r\ncontent-type: {kind}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", out.len());
    let _ = conn.write_all(head.as_bytes());
    let _ = conn.write_all(out.as_bytes());
}

fn chrome() -> Option<String> {
    let c = std::env::var("GUNNAR_UI_CHROME").ok().filter(|c| !c.is_empty());
    if c.is_none() {
        eprintln!("NOT RUN: set GUNNAR_UI_CHROME to a Chrome binary to open the first page and the sign-up page");
    }
    c
}

fn free_port() -> u16 {
    TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}

fn open(bin: &str, tag: &str, w: u32, h: u32) -> (Chrome, Cdp, String) {
    let port = free_port();
    let profile = std::env::temp_dir().join(format!("signuppages-{tag}-{}", std::process::id()));
    let chrome = Chrome::launch(bin, &profile, port).expect("chrome starts");
    let mut cdp = Cdp::attach(port).expect("cdp");
    let sess = cdp.page().expect("a page");
    cdp.viewport(&sess, w, h).expect("the viewport");
    (chrome, cdp, sess)
}

fn shot(c: &mut Cdp, sess: &str, name: &str) {
    if let Ok(dir) = std::env::var("SIGNUPPAGES_SHOTS") {
        let _ = std::fs::create_dir_all(&dir);
        std::thread::sleep(Duration::from_millis(400));
        let _ = c.screenshot(sess, &std::path::Path::new(&dir).join(name));
    }
}

/// A real mouse click at the middle of the element `css` names.
fn click(c: &mut Cdp, sess: &str, css: &str) {
    let at = c
        .eval(sess, &format!("(() => {{ const r = document.querySelector({}).getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()", esc(css)))
        .expect("its box");
    let xy: Vec<f64> = at.arr().iter().filter_map(Json::as_f64).collect();
    for kind in ["mousePressed", "mouseReleased"] {
        c.call(Some(sess), "Input.dispatchMouseEvent", &format!(r#"{{"type":"{kind}","x":{},"y":{},"button":"left","clickCount":1}}"#, xy[0], xy[1])).expect("the mouse");
    }
}

/// Is the element `id` drawn (not inside anything hidden, not display:none)?
fn visible(c: &mut Cdp, sess: &str, id: &str) -> bool {
    c.eval(sess, &format!("(() => {{ const b = document.getElementById({}); if (!b) return false; const s = getComputedStyle(b); return !b.closest('[hidden]') && s.display !== 'none' && s.visibility !== 'hidden' && b.getClientRects().length > 0; }})()", esc(id)))
        .unwrap()
        .as_bool()
        .unwrap_or(false)
}

fn text(c: &mut Cdp, sess: &str, expr: &str) -> String {
    c.eval_str(sess, expr).unwrap_or_default()
}

/// The two buttons' boxes: [x, y, width, height] each.
const BOXES: &str = "(() => { const r = (id) => { const b = document.getElementById(id).getBoundingClientRect(); return [b.x, b.y, b.width, b.height]; }; return [r('paid-link'), r('login')]; })()";

fn boxes(c: &mut Cdp, sess: &str) -> (Vec<f64>, Vec<f64>) {
    let j = c.eval(sess, BOXES).unwrap();
    let both: Vec<Vec<f64>> = j.arr().iter().map(|b| b.arr().iter().filter_map(Json::as_f64).collect()).collect();
    (both[0].clone(), both[1].clone())
}

#[test]
fn the_first_page_has_sign_up_account_and_log_in_side_by_side() {
    let Some(bin) = chrome() else { return };
    let (port, _) = serve();
    for (w, h, tag) in [(1440, 900, "desktop"), (390, 844, "phone")] {
        let (_chrome, mut c, sess) = open(&bin, &format!("login-{tag}"), w, h);
        c.navigate(&sess, &format!("http://127.0.0.1:{port}/login")).expect("the page loads");
        c.wait_for(&sess, "!document.getElementById('paid-link').hidden", 15_000).expect("/auth/status revealed the sign-up button");
        shot(&mut c, &sess, &format!("1-first-page-{tag}.png"));
        assert_eq!(text(&mut c, &sess, "document.getElementById('paid-link').innerText.trim()"), "Sign up account");
        assert_eq!(text(&mut c, &sess, "document.getElementById('login').innerText.trim()"), "Log in");
        assert_eq!(text(&mut c, &sess, "document.getElementById('paid-link').getAttribute('href')"), "/signup");
        let lines = text(&mut c, &sess, "[...document.querySelectorAll('.offers .offer')].map(p => p.innerText.replace(/\\s+/g, ' ').trim()).join(' | ')");
        assert_eq!(lines, "Host your own (setup guide)", "{tag}: the line above the buttons");
        let page = text(&mut c, &sess, "document.body.innerText");
        for gone in ["Free option", "Sign up for paid service", "Log in with passkey"] {
            assert!(!page.contains(gone), "{tag}: {gone:?} is still on the first page");
        }
        let (signup, login) = boxes(&mut c, &sess);
        assert!(signup[2] > 60.0 && login[2] > 60.0, "{tag}: both drawn: {signup:?} {login:?}");
        assert!((signup[1] - login[1]).abs() < 1.0 && (signup[3] - login[3]).abs() < 1.0, "{tag}: not one row: {signup:?} {login:?}");
        assert!((signup[2] - login[2]).abs() <= 1.0, "{tag}: not the same width: {signup:?} {login:?}");
        assert!(signup[0] + signup[2] <= login[0], "{tag}: \"Sign up account\" is not the left one: {signup:?} {login:?}");
        // The row is the column's width: the two buttons fill it.
        let row = c.eval(&sess, "document.querySelector('.entry-buttons').getBoundingClientRect().width").unwrap().as_f64().unwrap_or(0.0);
        assert!((login[0] + login[2] - signup[0] - row).abs() <= 1.0, "{tag}: the buttons do not fill the row ({row} px)");
        assert_eq!(text(&mut c, &sess, "getComputedStyle(document.getElementById('login')).backgroundColor === getComputedStyle(document.getElementById('paid-link')).backgroundColor ? 'same' : 'two'"), "two", "{tag}: Log in is the primary, Sign up account the other kind");
        if tag == "desktop" {
            click(&mut c, &sess, "#paid-link");
            c.wait_for(&sess, "location.pathname === '/signup' && document.querySelectorAll('#plans button').length === 3", 15_000)
                .expect("\"Sign up account\" opens the sign-up page on the account");
        }
    }
}

/// What the sign-up page shows: heading, choices (pressed marked *), the plan rows
/// (checked marked *), and which of the picture, − / + and price are drawn.
fn read_signup(c: &mut Cdp, sess: &str) -> (String, String, String) {
    let heading = text(c, sess, "document.getElementById('heading').textContent");
    let choices = text(c, sess, "[...document.querySelectorAll('#choice button')].map(b => b.textContent + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')).join(' | ')");
    let plans = text(c, sess, "[...document.querySelectorAll('#plans button')].map(b => b.querySelector('.plan-name').textContent + ' = ' + b.querySelector('.plan-price').textContent + (b.getAttribute('aria-checked') === 'true' ? '*' : '')).join(' | ')");
    (heading, choices, plans)
}

#[test]
fn the_sign_up_page_starts_on_the_account_and_draws_the_servers_only_for_a_private_gunnar() {
    let Some(bin) = chrome() else { return };
    let (port, _) = serve();
    let (_chrome, mut c, sess) = open(&bin, "signup", 1440, 900);
    c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
    c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the account's plans arrived");
    shot(&mut c, &sess, "2-signup-account-desktop.png");
    let (heading, choices, plans) = read_signup(&mut c, &sess);
    assert_eq!(heading, "Sign up for an account");
    assert_eq!(choices, "Account on 127.0.0.1* | Private Gunnar instance", "the account LEFT and selected");
    assert_eq!(plans, "Free account = Free | 10 GB · 1 month = 110.00 SEK a month* | 10 GB · 6 months = 594.00 SEK for 180 days");
    let note = text(&mut c, &sess, "document.querySelector('#plans button .plan-note').textContent");
    assert_eq!(note, "Read only: you can read and clone, but not write to your own repositories. A card is checked at Stripe; nothing is charged.");
    for id in ["estate", "size", "price"] {
        assert!(!visible(&mut c, &sess, id), "#{id} is drawn for the account");
    }
    assert!(visible(&mut c, &sess, "plans") && visible(&mut c, &sess, "go"));
    // Picking a row moves the one check.
    click(&mut c, &sess, "#plans button[data-plan='gunnar/10gb-6m']");
    assert!(read_signup(&mut c, &sess).2.ends_with("594.00 SEK for 180 days*"), "the 6 months are not chosen");
    click(&mut c, &sess, "#plans button[data-plan='gunnar/account']");
    assert!(read_signup(&mut c, &sess).2.starts_with("Free account = Free*"), "the free account is not chosen");
    assert!(text(&mut c, &sess, "document.getElementById('first-account').textContent").contains("buy room when you want to write"), "the free account's line");
    // The private Gunnar: the server picture, its − / + and its price — and no list.
    click(&mut c, &sess, "#choice button[data-mode='private']");
    c.wait_for(&sess, "document.getElementById('step-name').textContent === 'Mini'", 15_000).expect("the private ladder arrived");
    shot(&mut c, &sess, "3-signup-private-desktop.png");
    let (heading, choices, _) = read_signup(&mut c, &sess);
    assert_eq!(heading, "Sign up for an account", "a visitor signs up for an account first, whichever way");
    assert_eq!(choices, "Account on 127.0.0.1 | Private Gunnar instance*");
    for id in ["estate", "size", "price"] {
        assert!(visible(&mut c, &sess, id), "#{id} is not drawn for a private Gunnar");
    }
    assert!(!visible(&mut c, &sess, "plans"), "the account's list is drawn for a private Gunnar");
    assert_eq!(text(&mut c, &sess, "document.getElementById('price-month').textContent"), "40.00 EUR a month");
    assert_eq!(text(&mut c, &sess, "['front','main','twin'].map(r => document.getElementById('label-' + r).textContent).join(' ')"), "UI SERVER REPLICA");
    // And back: the picture goes again.
    click(&mut c, &sess, "#choice button[data-mode='account']");
    c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans again");
    assert!(!visible(&mut c, &sess, "estate") && visible(&mut c, &sess, "plans"), "the picture stays for the account");
    // At phone width the page fits.
    c.viewport(&sess, 390, 844).unwrap();
    std::thread::sleep(Duration::from_millis(300));
    shot(&mut c, &sess, "4-signup-account-phone.png");
    let wide = c.eval(&sess, "document.documentElement.scrollWidth <= window.innerWidth + 1").unwrap().as_bool();
    assert_eq!(wide, Some(true), "the sign-up page is wider than a phone");
}

/// ★ B (owner 2026-10-15): the free row orders the free account; a plan row orders ITS plan
/// (one checkout, the account made after the payment); both go on to the console.
#[test]
fn the_free_row_orders_the_account_and_a_plan_row_its_own_plan() {
    let Some(bin) = chrome() else { return };
    for (row, next) in [("gunnar/10gb-6m", "/"), ("gunnar/account", "/")] {
        let (port, posted) = serve();
        let (_chrome, mut c, sess) = open(&bin, &format!("order-{}", row.replace('/', "-")), 1440, 900);
        c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
        c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans");
        click(&mut c, &sess, &format!("#plans button[data-plan='{row}']"));
        c.eval(&sess, "(() => { document.getElementById('account').value = 'alice'; document.getElementById('email').value = 'alice@example.com'; return true; })()").unwrap();
        // The owner's buy rule (2026-10-23): a paid plan says Pay <amount>; the free account its own words.
        let go = text(&mut c, &sess, "document.getElementById('go').textContent");
        if row == "gunnar/account" { assert_eq!(go, "Create your account at 127.0.0.1"); } else { assert!(go.starts_with("Pay "), "{go:?}"); }
        click(&mut c, &sess, "#go");
        c.wait_for(&sess, "document.getElementById('msg').textContent !== '' && !document.getElementById('msg').textContent.startsWith('Checking') && !document.getElementById('msg').textContent.startsWith('Creating')", 10_000).expect("the order was answered");
        let sent = posted.lock().unwrap().clone();
        assert_eq!(sent.len(), 1, "{row}: {sent:?}");
        assert_eq!(sent[0].0, "/auth/signup/start");
        let body = parse(&sent[0].1).expect("json");
        // ★ B (owner 2026-10-15): a plan row is its own checkout; the free row makes the free account.
        assert_eq!(body.text("plan"), row, "{row}: what is ordered");
        assert_eq!(body.text("next"), next, "{row}: where it goes after the passkey");
    }
}

/// ★ **The free account goes to Stripe's card check, and nothing is charged** (owner
/// 2026-10-15): the free row says so; the order is the account (`gunnar/account`, on to the
/// console after the passkey); monetize answers with Stripe's page, and the browser goes
/// THERE — the request is caught before it leaves (Fetch), the page having said that
/// nothing is charged — and the welcome page is told this tab paid nothing.
#[test]
fn the_free_account_goes_to_stripes_card_check_and_nothing_is_charged() {
    let Some(bin) = chrome() else { return };
    let (port, posted) = serve_answering(r#"{"url":"https://checkout.fake-stripe.test/c/pay/cs_test_card1"}"#);
    let (_chrome, mut c, sess) = open(&bin, "card-check", 1440, 900);
    c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
    c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans");
    click(&mut c, &sess, "#plans button[data-plan='gunnar/account']");
    let row = text(&mut c, &sess, "document.querySelector(\"#plans button[data-plan='gunnar/account']\").innerText.replace(/\\s+/g, ' ').trim()");
    assert!(row.contains("Free account") && row.contains("A card is checked at Stripe; nothing is charged."), "the free row: {row}");
    c.eval(&sess, "(() => { document.getElementById('account').value = 'alice'; document.getElementById('email').value = 'alice@example.com'; return true; })()").unwrap();
    c.call(Some(&sess), "Fetch.enable", r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#).expect("Fetch");
    c.events.clear();
    click(&mut c, &sess, "#go");
    let Some((id, url)) = c.paused_request(&sess, 15_000) else {
        panic!("the free account did not go to Stripe; the page says {}", text(&mut c, &sess, "document.getElementById('msg').textContent"));
    };
    assert_eq!(url, "https://checkout.fake-stripe.test/c/pay/cs_test_card1", "to the page monetize answered");
    // The navigation is aborted before it leaves: the page stays, as it was when it went.
    let _ = c.call(Some(&sess), "Fetch.failRequest", &format!(r#"{{"requestId":{},"errorReason":"Aborted"}}"#, esc(&id)));
    let _ = c.call(Some(&sess), "Fetch.disable", "{}");
    let said = text(&mut c, &sess, "document.getElementById('msg').textContent");
    let told = text(&mut c, &sess, "sessionStorage.getItem('gunnar_card_check') || ''");
    shot(&mut c, &sess, "5-signup-free-card-check.png");
    assert_eq!(said, "Taking you to Stripe to check your card. Nothing is charged.");
    assert_eq!(told, "1", "the welcome page is told nothing was paid");
    let sent = posted.lock().unwrap().clone();
    assert_eq!(sent.len(), 1, "{sent:?}");
    let body = parse(&sent[0].1).expect("json");
    assert_eq!((sent[0].0.as_str(), body.text("plan").as_str(), body.text("next").as_str()), ("/auth/signup/start", "gunnar/account", "/"), "the account, on to the console");
}

/// ★ **B (owner 2026-10-15): a plan row goes to Stripe ONCE** — its own payment page (caught by
/// Fetch before it leaves), "Taking you to pay …", and the welcome page is NOT told it was a
/// card check (it says "Payment received").
#[test]
fn a_plan_row_goes_to_stripe_once_to_pay() {
    let Some(bin) = chrome() else { return };
    let (port, posted) = serve_answering(r#"{"url":"https://checkout.fake-stripe.test/c/pay/cs_test_pay1"}"#);
    let (_chrome, mut c, sess) = open(&bin, "plan-row", 1440, 900);
    c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
    c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans");
    click(&mut c, &sess, "#plans button[data-plan='gunnar/10gb-1m']");
    c.eval(&sess, "(() => { document.getElementById('account').value = 'alice'; document.getElementById('email').value = 'alice@example.com'; return true; })()").unwrap();
    c.call(Some(&sess), "Fetch.enable", r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#).expect("Fetch");
    c.events.clear();
    click(&mut c, &sess, "#go");
    let Some((id, url)) = c.paused_request(&sess, 15_000) else {
        panic!("the plan row did not go to Stripe; the page says {}", text(&mut c, &sess, "document.getElementById('msg').textContent"));
    };
    let _ = c.call(Some(&sess), "Fetch.failRequest", &format!(r#"{{"requestId":{},"errorReason":"Aborted"}}"#, esc(&id)));
    let _ = c.call(Some(&sess), "Fetch.disable", "{}");
    assert_eq!(url, "https://checkout.fake-stripe.test/c/pay/cs_test_pay1");
    assert_eq!(text(&mut c, &sess, "document.getElementById('msg').textContent"), "Taking you to pay …");
    assert_eq!(text(&mut c, &sess, "sessionStorage.getItem('gunnar_card_check') || ''"), "", "not a card check");
    let sent = posted.lock().unwrap().clone();
    assert_eq!(sent.len(), 1, "ONE order: {sent:?}");
    let body = parse(&sent[0].1).expect("json");
    assert_eq!((body.text("plan").as_str(), body.text("next").as_str()), ("gunnar/10gb-1m", "/"));
}