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

//! **Room as plans** (owner, 2026-10-05; buying on the ONE sign-up page
//! since 2026-10-17) — a deployment that sells plans priced by monetize's
//! rule, driven as a person drives it against the bench monetize and the FAKE
//! Stripe: Account's ONE button opens `/signup?plan=10gb`, whose − / + steps
//! through monetize's rows and whose Upgrade goes to the vendor; the plan is
//! read back on Account's Plan panel.
//!
//! * the first visit (a free account): "Buy a plan", and "10 GiB · 1 month"
//!   and the 12-month plans at 10 / 100 / 500 GiB, each with its price;
//! * after a paid month: Account's Plan reads the plan, its storage and the
//!   date it is paid until, "Grow your account", and Prolong; the sign-up
//!   page starts at the plan held and offers the 12-month upgrades;
//! * the Account page calls its renewal "Prolong";
//! * a prolong (Account's Prolong): paid at the vendor, the page back from it
//!   reads "Prolonged until <the new end>" — nothing about room or disk — and
//!   Paid until moves one month on;
//! * a store with little room left: every step can be bought and the page
//!   names no room left, no cap, no limit;
//! * an upgrade to 12 months, its page saying what it did.
//!
//! `admin` is bob's session, on bob's own account.

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 sign-up page's state, as a person sees it.
const SIGNUP: &str = "(() => ({step: document.getElementById('step-name')?.textContent.trim() ?? '',
    month: document.getElementById('price-month')?.textContent.trim() ?? '',
    total: document.getElementById('price-total')?.textContent.trim() ?? '',
    go: !!document.getElementById('go') && !document.getElementById('go').disabled,
    minus: !!document.getElementById('minus') && !document.getElementById('minus').disabled,
    plus: !!document.getElementById('plus') && !document.getElementById('plus').disabled,
    msg: document.getElementById('msg')?.textContent.trim() ?? '',
    heading: document.getElementById('heading')?.textContent.trim() ?? '',
    tab: (document.querySelector('#choice button[aria-pressed=\"true\"]') || {}).textContent ?? '',
    text: document.body.innerText}))()";

/// One step of the ladder: its name, its price words, its period line, and
/// whether Upgrade can be pressed on it.
#[derive(Debug, Clone)]
struct Step {
    name: String,
    month: String,
    total: String,
    buyable: bool,
}

/// **The ladder** on `/signup?plan=10gb`, read step by step with − / +; the
/// page's heading and tab with it.
struct Ladder {
    steps: Vec<Step>,
    heading: String,
    tab: String,
    text: String,
}

fn ladder(c: &mut Cdp, r: &Rig, sess: &str) -> Result<Ladder, String> {
    c.navigate(sess, &format!("{}/signup?plan=10gb", r.origin()))?;
    c.wait_for(sess, "(document.getElementById('price-month')?.textContent.trim() ?? '') !== ''", 60_000)
        .map_err(|e| format!("the sign-up page drew no step ({e}): {}", http::cut(&c.eval(sess, SIGNUP).map(|j| j.text("text")).unwrap_or_default(), 400)))?;
    for _ in 0..12 {
        if c.eval(sess, SIGNUP)?.path("minus").and_then(|v| v.as_bool()) != Some(true) {
            break;
        }
        c.eval(sess, "(() => { document.getElementById('minus').click(); return 1; })()")?;
    }
    let mut steps = Vec::new();
    let mut last = c.eval(sess, SIGNUP)?;
    for _ in 0..12 {
        steps.push(Step { name: last.text("step"), month: last.text("month"), total: last.text("total"), buyable: last.path("go").and_then(|v| v.as_bool()) == Some(true) });
        if last.path("plus").and_then(|v| v.as_bool()) != Some(true) {
            break;
        }
        c.eval(sess, "(() => { document.getElementById('plus').click(); return 1; })()")?;
        last = c.eval(sess, SIGNUP)?;
    }
    Ok(Ladder { steps, heading: last.text("heading"), tab: last.text("tab"), text: last.text("text") })
}

/// Account's Plan panel, read: the plan's name, its figures, the button.
fn plan_panel(c: &mut Cdp, r: &Rig, sess: &str) -> Result<crate::json::Json, String> {
    c.navigate_fresh(sess, &format!("{}/account", r.origin()))?;
    c.wait_for(sess, "!!document.getElementById('plan-name')", 60_000)
        .map_err(|e| format!("Account drew no Plan ({e}): {}", http::cut(&c.eval_str(sess, MAIN_TEXT).unwrap_or_default(), 400)))?;
    c.eval(
        sess,
        "(() => { const p = document.getElementById('account-plan');
            return { name: document.getElementById('plan-name').textContent.trim(),
                     text: p.textContent, button: (document.getElementById('account-buy-grow') || {}).textContent ?? '',
                     prolong: !!document.getElementById('account-prolong'),
                     until: [...p.querySelectorAll('.metric')].find(m => m.querySelector('dt').textContent.trim() === 'Paid until')?.querySelector('dd').textContent.trim() ?? '' }; })()",
    )
}

fn strings(j: &crate::json::Json, p: &str) -> Vec<String> {
    j.path(p).map(|a| a.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect()).unwrap_or_default()
}

/// On the sign-up page, step to the step whose name starts with `starts`,
/// press Upgrade, go to the FAKE Stripe's hosted page (intercepted), pay
/// there, and answer the order's reference.
fn pay_for(c: &mut Cdp, r: &Rig, sess: &str, starts: &str) -> Result<String, String> {
    let l = ladder(c, r, sess)?;
    let at = l.steps.iter().position(|s| s.name.starts_with(starts) && s.buyable).ok_or_else(|| format!("no buyable step {starts:?}: {:?}", l.steps))?;
    // `ladder` left the page on its last step: back to the one wanted.
    for _ in at + 1..l.steps.len() {
        c.eval(sess, "(() => { document.getElementById('minus').click(); return 1; })()")?;
    }
    let press = format!(
        "(() => {{ if (document.getElementById('step-name').textContent.trim().startsWith({}) && !document.getElementById('go').disabled) {{ document.getElementById('go').click(); return true; }} return false; }})()",
        esc(starts)
    );
    pay_with(c, r, sess, &press, starts)
}

/// [`pay_for`] with the press written out: `press` is an expression that
/// presses (answering `true`) or answers `false`.
fn pay_with(c: &mut Cdp, r: &Rig, sess: &str, press: &str, starts: &str) -> Result<String, String> {
    c.call(Some(sess), "Fetch.enable", r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#)?;
    c.events.clear();
    let pressed = c.eval(sess, press)?;
    if pressed.as_bool() != Some(true) {
        let _ = c.call(Some(sess), "Fetch.disable", "{}");
        return Err(format!("no button starting {starts:?}: {}", http::cut(&c.eval_str(sess, MAIN_TEXT).unwrap_or_default(), 400)));
    }
    let Some((id, url)) = c.paused_request(sess, 30_000) else {
        let _ = c.call(Some(sess), "Fetch.disable", "{}");
        return Err(format!("{starts:?} did not go to the vendor within 30 s; the page says {}", http::cut(&c.eval_str(sess, MAIN_TEXT).unwrap_or_default(), 400)));
    };
    let _ = c.call(Some(sess), "Fetch.failRequest", &format!(r#"{{"requestId":{},"errorReason":"Aborted"}}"#, esc(&id)));
    let _ = c.call(Some(sess), "Fetch.disable", "{}");
    let cs = url.rsplit('/').next().unwrap_or_default().split('?').next().unwrap_or_default().to_owned();
    want(cs.starts_with("cs_test_"), || format!("not a checkout session: {url}"))?;
    let (code, paid) = r.bench_post(&format!("/pay/{cs}"))?;
    want(code == 200 && paid.contains("200"), || format!("paying {cs}: {code} {paid}"))?;
    let reference = paid.lines().next().unwrap_or_default().trim_start_matches("reference=").to_owned();
    want(!reference.is_empty(), || format!("the bench named no order for {cs}: {paid}"))?;
    Ok(reference)
}

/// The order page the vendor returns to: its room's steps as they were SEEN,
/// until `done`.
fn follow_room(c: &mut Cdp, r: &Rig, sess: &str, reference: &str) -> Result<Vec<String>, String> {
    let path = format!("{}/paid/{}", r.origin(), reference);
    c.navigate(sess, &path)?;
    c.wait_for(sess, "!!document.getElementById('room-progress')", 30_000)
        .map_err(|_| format!("no room progress on {path}: {}", http::cut(&c.eval_str(sess, MAIN_TEXT).unwrap_or_default(), 400)))?;
    let mut seen: Vec<String> = Vec::new();
    let began = Instant::now();
    loop {
        let state = c.eval_str(sess, "document.getElementById('room-progress')?.dataset.state || ''")?;
        let words = c.eval_str(sess, "document.getElementById('room-progress')?.textContent || ''")?;
        if !state.is_empty() && seen.last().map(|s: &String| !s.starts_with(&state)).unwrap_or(true) {
            seen.push(format!("{state}: {words}"));
        }
        if state == "done" {
            return Ok(seen);
        }
        if began.elapsed() > Duration::from_secs(90) {
            return Err(format!("the room never read done; seen {seen:?}"));
        }
        std::thread::sleep(Duration::from_millis(100));
    }
}

/// The page the vendor returns to, read once its headline is one of `done`:
/// the steps, the headline, the line under it, the days-left line (waited for
/// a moment, it is optional), whether monetize's room line is drawn, and the
/// order panel's whole text.
struct OrderPage {
    steps: Vec<String>,
    title: String,
    detail: String,
    days: String,
    room: bool,
    text: String,
}

fn order_page(c: &mut Cdp, r: &Rig, sess: &str, reference: &str, done: &str) -> Result<OrderPage, String> {
    let path = format!("{}/paid/{}", r.origin(), reference);
    c.navigate_fresh(sess, &path)?;
    let title = "(document.querySelector('#order-title') || {innerText: ''}).innerText.trim()";
    c.wait_for(sess, &format!("{title}.startsWith({})", esc(done)), 60_000).map_err(|_| {
        format!("{path} never read {done:?}: {}", http::cut(&c.eval_str(sess, MAIN_TEXT).unwrap_or_default(), 500))
    })?;
    let _ = c.wait_for(sess, "!!document.getElementById('order-days-left')", 8_000);
    let j = c.eval(
        sess,
        "(() => { const p = [...document.querySelectorAll('section.panel')].find(p => p.querySelector('h2')?.textContent.trim() === 'Your order');
            const t = id => (document.getElementById(id) || {innerText: ''}).innerText.trim();
            return { steps: [...(p ? p.querySelectorAll('ol.steps li') : [])].map(li => li.innerText.trim()),
                     title: t('order-title'), detail: t('order-detail'), days: t('order-days-left'),
                     room: !!document.getElementById('room-progress'), text: p ? p.innerText : '' }; })()",
    )?;
    Ok(OrderPage {
        steps: strings(&j, "steps"),
        title: j.text("title"),
        detail: j.text("detail"),
        days: j.text("days"),
        room: j.path("room").and_then(|v| v.as_bool()) == Some(true),
        text: j.text("text"),
    })
}

/// The date in an "… until 2026-11-05 …" line.
fn date_in(s: &str) -> String {
    s.split_whitespace().find(|w| w.len() >= 10 && w.as_bytes()[4] == b'-' && w.as_bytes()[7] == b'-').map(|w| w[..10].to_owned()).unwrap_or_default()
}

pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, admin: &str) {
    const NAMES: [&str; 5] = [
        "the-first-visit-is-buy-a-plan-with-ten-gib-for-one-month-and-the-twelve-month-plans",
        "after-a-paid-month-the-plan-its-end-prolong-and-the-twelve-month-upgrades-are-shown",
        "the-account-page-calls-its-renewal-prolong",
        "a-paid-prolong-reads-prolonged-until-the-new-end-and-nothing-about-room",
        "plans-past-the-room-left-are-buttons-like-any-other-and-no-room-or-cap-is-named",
    ];
    if r.bench.is_none() {
        for name in NAMES {
            go(rows, name, || Err("NOT RUN: no --bench-monetize (the fake Stripe and the plans are the bench's)".into()));
        }
        return;
    }
    let on = r.bench_post("/plans/on");
    let mut ends_before = String::new();

    go(rows, NAMES[0], || {
        let (code, said) = on.clone()?;
        want(code == 200, || format!("the bench would not sell plans: {code} {said}"))?;
        let plan = plan_panel(c, r, admin)?;
        want(plan.text("name") == "Free" && plan.text("button") == "Buy a plan", || format!("Account's Plan on a first visit: {}", plan.dump()))?;
        let l = ladder(c, r, admin)?;
        want(l.heading == "Buy a plan" && l.tab == "Buy a plan", || format!("the sign-up page reads {:?} / tab {:?}", l.heading, l.tab))?;
        let names: Vec<&str> = l.steps.iter().map(|s| s.name.as_str()).collect();
        let wanted = ["10 GiB · 1 month", "10 GiB · 12 months", "100 GiB · 12 months", "500 GiB · 12 months"];
        want(names == wanted, || format!("the steps are {names:?}, wanted {wanted:?}"))?;
        for s in &l.steps {
            want(s.buyable && s.month.chars().any(|c| c.is_ascii_digit()), || format!("a step without a price or not buyable: {s:?}"))?;
        }
        want(l.steps[0].total == "for one month" && l.steps[1].total == "for one year", || format!("the periods: {:?}", l.steps))?;
        Ok(format!("{:?}", l.steps.iter().map(|s| format!("{} {} {}", s.name, s.month, s.total)).collect::<Vec<_>>()))
    });

    go(rows, NAMES[1], || {
        let reference = pay_for(c, r, admin, "10 GiB · 1 month")?;
        let steps = follow_room(c, r, admin, &reference)?;
        let plan = plan_panel(c, r, admin)?;
        want(plan.text("name") == "10 GiB · 1 month" && plan.text("button") == "Grow your account", || format!("Account's Plan: {}", plan.dump()))?;
        want(plan.text("text").contains("Storage") && plan.path("prolong").and_then(|v| v.as_bool()) == Some(true), || format!("no storage or Prolong: {}", plan.dump()))?;
        ends_before = date_in(&plan.text("until"));
        want(!ends_before.is_empty(), || format!("no Paid until: {}", plan.dump()))?;
        let l = ladder(c, r, admin)?;
        want(l.heading == "Grow your account" && l.tab == "Grow your account", || format!("the sign-up page reads {:?} / tab {:?}", l.heading, l.tab))?;
        want(l.steps.first().is_some_and(|s| s.name == "10 GiB · 1 month" && s.total == "your plan now" && !s.buyable), || format!("the ladder does not start at the plan held: {:?}", l.steps))?;
        for size in ["10", "100", "500"] {
            want(l.steps.iter().any(|s| s.name.starts_with(&format!("Upgrade to {size} GiB · 12 months")) && s.buyable), || format!("no upgrade to {size} GiB: {:?}", l.steps))?;
        }
        want(!l.steps.iter().any(|s| s.name.starts_with("Prolong")), || format!("Prolong is Account's, not the sign-up page's: {:?}", l.steps))?;
        Ok(format!("paid {reference} ({steps:?}); paid until {ends_before}; {:?}", l.steps.iter().map(|s| s.name.clone()).collect::<Vec<_>>()))
    });

    // ★ One name (owner, 2026-10-05): the Account page's renewal says
    // "Prolong", and nothing on it says "renew".
    go(rows, NAMES[2], || {
        let plan = plan_panel(c, r, admin)?;
        c.wait_for(admin, "!!document.getElementById('account-prolong')", 30_000).map_err(|e| {
            format!("the Account page drew no Prolong button ({e}): {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 400))
        })?;
        let label = c.eval_str(admin, "document.getElementById('account-prolong').textContent.trim()")?;
        want(label == "Prolong", || format!("the Account page's button reads {label:?}"))?;
        let text = plan.text("text");
        want(!text.to_lowercase().contains("renew"), || format!("the Plan panel says renew: {text}"))?;
        Ok(format!("Account: Prolong; {}", http::cut(&text, 200)))
    });

    go(rows, NAMES[3], || {
        want(!ends_before.is_empty(), || "no paid month (the row before failed)".to_owned())?;
        plan_panel(c, r, admin)?;
        let reference = pay_with(c, r, admin, "(() => { const b = document.getElementById('account-prolong'); if (!b) return false; b.click(); return true; })()", "Prolong")?;
        let page = order_page(c, r, admin, &reference, "Prolonged until")?;
        let plan = plan_panel(c, r, admin)?;
        let ends_after = date_in(&plan.text("until"));
        want(page.title == format!("Prolonged until {ends_after}"), || format!("the page reads {:?}; the plan is paid until {ends_after}", page.title))?;
        want(page.steps.last().is_some_and(|s| s.ends_with("Prolonged")), || format!("step 3 reads {:?}", page.steps))?;
        want(!page.room && page.detail.is_empty(), || format!("a prolong's page draws a room line or a sentence: room {} detail {:?}", page.room, page.detail))?;
        for never in ["Room added", "room is yours", "extra room", "you now have", "queued for today's batch", "disk", "enlarged"] {
            want(!page.text.contains(never), || format!("the prolong's page says {never:?}: {}", page.text.replace('\n', " | ")))?;
        }
        want(page.days.is_empty() || page.days.ends_with(" left"), || format!("the days-left line reads {:?}", page.days))?;
        want(ends_after > ends_before, || format!("the end did not move: {ends_before} → {ends_after}"))?;
        let (ym, ym2) = (ends_before[..7].to_owned(), ends_after[..7].to_owned());
        let next = {
            let (y, m) = (ym[..4].parse::<u32>().unwrap_or(0), ym[5..7].parse::<u32>().unwrap_or(0));
            if m == 12 { format!("{:04}-01", y + 1) } else { format!("{y:04}-{:02}", m + 1) }
        };
        want(ym2 == next, || format!("one month on from {ends_before} is {next}, Account says {ends_after}"))?;
        let was = std::mem::replace(&mut ends_before, ends_after.clone());
        Ok(format!("prolonged {reference}: {:?} / {:?} / {:?}; the end {was} → {ends_after}", page.steps, page.title, page.days))
    });

    go(rows, NAMES[4], || {
        // test2's set (2026-10-05): 39.8 GiB free less the 2.0 GiB disk floor.
        // Round 5.3: the 100 and 500 GiB plans are past this room and are
        // offered all the same — queued for the resize; the customer reads no cap.
        const ROOM: u64 = 37 * (1 << 30) + 858_993_459;
        let (code, said) = r.bench_post(&format!("/room/small/{ROOM}"))?;
        want(code == 200, || format!("the bench would not shrink its room: {code} {said}"))?;
        let looked = (|| {
            let l = ladder(c, r, admin)?;
            for size in ["10", "100", "500"] {
                want(l.steps.iter().any(|s| s.name.starts_with(&format!("Upgrade to {size} GiB \u{b7} 12 months")) && s.buyable), || format!("Upgrade to {size} GiB is not buyable: {:?}", l.steps))?;
            }
            for never in ["room left", "Larger than", "ask us to make room", "37.8 GiB", "spend cap", "ceiling", "capacity", "We cannot raise", "Nothing has been charged", "is all there is left"] {
                want(!l.text.contains(never), || format!("the page names {never:?} to a customer: {}", http::cut(&l.text, 600)))?;
            }
            Ok::<_, String>(format!("every plan buyable: {:?}", l.steps.iter().map(|s| s.name.clone()).collect::<Vec<_>>()))
        })();
        let back = r.bench_post("/room/normal");
        let looked = looked?;
        let (code, said) = back?;
        want(code == 200, || format!("the bench's room did not come back: {code} {said}"))?;
        Ok(looked)
    });

    let _ = r.bench_post("/plans/off");
}

/// **An upgrade**, its page saying what it did — on bob's 1-month plan after
/// [`rows`] prolonged it. A separate call, made AFTER the revoke row: each
/// paid order is about a minute on the bench, and the revoke row needs bob's
/// step-up (10 minutes from his login) still fresh.
pub fn later_rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, admin: &str) {
    const LATER: [&str; 1] = ["a-paid-upgrade-reads-upgraded-to-twelve-months-until-its-end"];
    if r.bench.is_none() {
        for name in LATER {
            go(rows, name, || Err("NOT RUN: no --bench-monetize (the fake Stripe and the plans are the bench's)".into()));
        }
        return;
    }
    let _ = r.bench_post("/plans/on");

    go(rows, LATER[0], || {
        let reference = pay_for(c, r, admin, "Upgrade to 10 GiB · 12 months")?;
        let page = order_page(c, r, admin, &reference, "Upgraded")?;
        let plan = plan_panel(c, r, admin)?;
        let ends = date_in(&plan.text("until"));
        want(page.title == format!("Upgraded to 12 months until {ends}"), || format!("the page reads {:?}; the plan ends {ends}", page.title))?;
        want(page.steps.last().is_some_and(|s| s.ends_with("Upgraded")), || format!("step 3 reads {:?}", page.steps))?;
        want(page.detail == "You now have 10.0 GiB storage.", || format!("the line under it reads {:?}", page.detail))?;
        want(plan.text("name").contains("12 months"), || format!("the plan is not 12 months now: {}", plan.dump()))?;
        Ok(format!("upgraded {reference}: {:?} / {:?} / {:?}", page.steps, page.title, page.detail))
    });

    let _ = r.bench_post("/plans/off");
}