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