browser-tests/tests/buyer.rs
//! ★ **The buyer's pages in a real browser** (lane HOLGER-UI-TESTS): Holger's front page and
//! login split "Sign up account | Log in"; a visitor buys a plan ("Pay <amount>"), pays at the
//! (fake) vendor, is welcomed on the site and registers the passkey with the browser's virtual
//! authenticator; signed in, the one buy page offers "Grow your plan" and "New private instance" with
//! no e-mail field, a private instance is paid for, it grows ("Grow acme's Holger" →
//! "Upgrade"), "Your purchases" lists it, and an unpaid order is cancelled on
//! `/cancel/<reference>`. No link or request goes to the ONE box's own site.
use holger_ui_browser_tests::browser::{Browser, NEVER};
use holger_ui_browser_tests::rig::Rig;
use monetize_site_buy::testkit::{STRIPE, PAID_WORDS};
/// ★ **LOGIN-1 (owner 2026-10-27): `/login` IS the login; sign-up is reached from the front
/// page only.** The login shows its one "Log in" (the passkey sign-in) and no Sign up link or
/// button anywhere on the page; the front page's "Sign up account" opens the account's sign-up.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_login_is_the_login_and_the_front_page_reaches_the_sign_up() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
let tab = browser.tab().await.unwrap();
tab.open(&rig.at("/login")).await.unwrap();
tab.until("the login's Log in", "(() => { const e = document.getElementById('login'); return !!e && e.offsetParent !== null && e.textContent.trim() === 'Log in'; })()").await;
// The sign-up door has had its time to answer (it used to reveal a Sign up half).
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let signup = tab.js("[...document.querySelectorAll('a, button')].filter(e => e.offsetParent !== null && (/sign\\s*up/i.test(e.textContent) || (e.getAttribute('href') || '').includes('/signup'))).map(e => e.outerHTML)").await.unwrap();
assert_eq!(signup, serde_json::json!([]), "/login offers a sign-up: {}", tab.text().await);
assert!(!tab.text().await.to_lowercase().contains("sign up"), "/login speaks of a sign-up: {}", tab.text().await);
// ★ HOL-LOGIN (owner 2026-10-27, holger.rs): a root that sells accounts points a newcomer to
// the front page, as Gunnar's /login does; the invite sentence is a private instance's.
tab.until("the newcomer's line, to the front page", NEWCOMER_LINE).await;
assert_eq!(tab.js(NEWCOMER_TEXT).await.unwrap(), serde_json::json!(NEWCOMER_SOLD), "{}", tab.text().await);
assert_eq!(tab.js("document.querySelector('#invite-offer a') && document.querySelector('#invite-offer a').getAttribute('href')").await.unwrap(), serde_json::json!("/"), "the line links the front page");
assert!(!tab.text().await.contains("invite link"), "a root that sells accounts sends a newcomer to an invite: {}", tab.text().await);
tab.assert_no_link_to(NEVER).await.unwrap();
tab.open(&rig.at("/")).await.unwrap();
tab.until("the front page's Sign up account", "(() => { const e = document.getElementById('signup-link'); return !!e && !e.hidden && e.textContent.trim() === 'Sign up account'; })()").await;
assert!(tab.shown("#enter").await, "the front page's second button, the door: {}", tab.text().await);
tab.assert_no_link_to(NEVER).await.unwrap();
tab.click("#signup-link").await;
tab.until_url("Sign up account opens the site's own sign-up", &rig.at("/signup")).await;
tab.until("the account's sign-up, chosen", "document.getElementById('heading').textContent === 'Sign up for an account'").await;
tab.assert_no_link_to(NEVER).await.unwrap();
browser.assert_nothing_went_to(NEVER, &rig.base, STRIPE);
}
/// The newcomer's line under "Log in", shown.
const NEWCOMER_LINE: &str = "(() => { const e = document.getElementById('invite-offer'); return !!e && !e.hidden && e.offsetParent !== null && e.textContent.trim() !== ''; })()";
const NEWCOMER_TEXT: &str = "document.getElementById('invite-offer').textContent.replace(/\\s+/g, ' ').trim()";
/// Gunnar's words on a root that sells accounts (gunnar-ui `noPasskeyHere`).
const NEWCOMER_SOLD: &str = "New here? Make an account from the front page.";
/// A private instance's words: nothing is sold there, an admin invites.
const NEWCOMER_INVITE: &str = "New here? Ask whoever runs this Holger for an invite link.";
/// ★ **HOL-LOGIN: a private instance's /login keeps the invite sentence.** Where the root sells
/// nothing (`/-/signup` answers no shop), the newcomer is told to ask whoever runs this Holger
/// for an invite link, with no link to the front page and no sign-up.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_private_instances_login_tells_a_newcomer_to_ask_for_an_invite() {
let rig = Rig::start().await;
rig.sell_nothing();
let browser = Browser::launch().await.expect("chromium starts");
let tab = browser.tab().await.unwrap();
tab.open(&rig.at("/login")).await.unwrap();
tab.until("the newcomer's line", NEWCOMER_LINE).await;
assert_eq!(tab.js(NEWCOMER_TEXT).await.unwrap(), serde_json::json!(NEWCOMER_INVITE), "{}", tab.text().await);
assert_eq!(tab.js("document.querySelectorAll('#invite-offer a').length").await.unwrap(), serde_json::json!(0), "no link on a private instance's line");
assert!(!tab.text().await.contains("front page"), "a private instance sends a newcomer to a shop it has not: {}", tab.text().await);
assert!(!tab.text().await.to_lowercase().contains("sign up"), "{}", tab.text().await);
browser.assert_nothing_went_to(NEVER, &rig.base, STRIPE);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_visitor_pays_registers_the_passkey_and_buys_on_the_account() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
let tab = browser.tab().await.unwrap();
// ── a visitor: the plans, the name, "Pay <amount>" ──
// ★ ALICA-2: /signup with no ?plan= preselects nothing; the buyer presses the account.
tab.open(&rig.at("/signup")).await.unwrap();
// Two things on offer: none pressed and no field until one is; one alone opens at once.
tab.until("the choices, or the one thing on offer", "document.querySelectorAll('#choice button').length >= 2 || document.querySelectorAll('#plans > *').length >= 2").await;
let choices = tab.js("document.querySelectorAll('#choice button').length").await.unwrap().as_u64().unwrap_or(0);
if choices >= 2 {
// BUY-ONE-2: signed out, the one buy page's two tabs, in order.
let labels = tab.js("[...document.querySelectorAll('#choice button')].map(b => b.textContent.trim())").await.unwrap();
let labels: Vec<String> = serde_json::from_value(labels).unwrap();
assert_eq!(labels.len(), 2, "{labels:?}");
assert!(labels[0].starts_with("Account on ") && labels[1] == "New private instance", "signed out: {labels:?}");
assert_eq!(tab.js("[...document.querySelectorAll('#choice button')].filter(b => b.getAttribute('aria-pressed') === 'true').length").await.unwrap(), serde_json::json!(0), "nothing preselected: {}", tab.text().await);
assert!(!tab.js(SEES_FORM).await.unwrap().as_bool().unwrap_or(true), "no field before a choice: {}", tab.text().await);
tab.js("[...document.querySelectorAll('#choice button')].find(b => b.textContent.startsWith('Account on ')).click(); true").await.unwrap();
}
tab.until("the plans", "document.querySelectorAll('#plans > *').length >= 2").await;
// The paid plan, chosen as a buyer chooses it: its row.
let paid = serde_json::to_string(PAID_WORDS).unwrap();
tab.js(&format!("[...document.querySelectorAll('#plans > *')].find(r => r.textContent.includes({paid})).click()")).await.unwrap();
let pay = format!("Pay {}", PAID_WORDS.trim_end_matches(" a month"));
tab.until(&format!("the button says {pay:?}"), &format!("document.getElementById('go').textContent === {}", serde_json::to_string(&pay).unwrap())).await;
assert!(tab.shown("#f-email").await, "a visitor gives the e-mail for the welcome mail");
tab.fill("#account", "newbie").await;
tab.fill("#email", "newbie@mock.invalid").await;
tab.assert_no_link_to(NEVER).await.unwrap();
tab.click("#go").await;
// ── the vendor's page (the fake's), paid, and the way back ──
let checkout = tab.until_url("the paid plan goes to the vendor", STRIPE).await;
let (session, back) = {
let b = rig.fake.book();
let c = b.checkout(&checkout).expect("the checkout the page went to").clone();
assert!(!c.setup && c.amount_minor > 0, "a paid plan, not the card check: {c:?}");
(c.id.clone(), c.return_url())
};
assert!(rig.fake.pay(&checkout), "the vendor's paid event");
rig.the_box_makes_the_account("newbie", &session);
assert!(back.starts_with("https://localhost/welcome?session="), "the way back is the site's own welcome: {back}");
tab.open(&rig.back(&back)).await.unwrap();
// ── the welcome page, then the passkey step ──
tab.until("the welcome page says the account is ready", "document.getElementById('headline').textContent === 'Your account is ready'").await;
tab.click_text("Register your security key now").await;
tab.until_url("the passkey step", &rig.at("/login")).await;
tab.until("the register panel", "document.getElementById('register').textContent === 'Touch your security key'").await;
assert!(tab.text().await.contains("Your account newbie is ready"), "{}", tab.text().await);
tab.click("#register").await;
tab.until("holger's session in this tab", holger_ui_browser_tests::browser::SIGNED_IN).await;
assert!(rig.holger_root().join(holger_server_lib::sold::SOLD_REPOS_REL).is_file());
let bound = holger_server_lib::sold::read(&rig.holger_root());
assert!(bound["newbie"].owner.starts_with("passkey:") || !bound["newbie"].owner.is_empty(), "the passkey is bound to the paid account: {:?}", bound["newbie"]);
// ★ TOPBAR (owner 2026-10-27; Gunnar is the template): nothing named "New instance" in the
// top bar; Account → Buy a plan opens the ONE buy page, its tabs "Grow your plan" | "New
// private instance" (BUY-ONE-2, owner GO 2026-10-27).
tab.open(&rig.at("/plan")).await.unwrap();
tab.until("the Plan panel's Buy a plan", "(() => { const a = document.getElementById('account-buy-grow'); return !!a && a.offsetParent !== null; })()").await;
let top = tab.js("(() => { const t = document.querySelector('header.topbar'); return t ? t.textContent : ''; })()").await.unwrap();
assert!(!top.as_str().unwrap_or("").contains("New instance"), "the top bar offers a New instance: {top}");
tab.click("#account-buy-grow").await;
tab.until("the two tabs", "[...document.querySelectorAll('#choice button')].map(b => b.textContent).join('|').includes('New private instance')").await;
let tabs = tab.js("[...document.querySelectorAll('#choice button')].filter(b => b.offsetParent !== null).map(b => b.textContent.trim())").await.unwrap();
let tabs: Vec<String> = serde_json::from_value(tabs).unwrap();
assert_eq!(tabs, vec!["Grow your plan".to_owned(), "New private instance".to_owned()], "Account → Buy a plan: the one buy page's two tabs, in order");
tab.until("the account's own name, short", "document.getElementById('account-hint').textContent === 'newbie (you)'").await;
// The beta line is the SITE's words (RA5-41, holger b91770f9): Holger's own, in its terms
// for packages, as `holger_pages` hands them to the shared buy page; never Gunnar's "data".
let beta = holger_estate_front::buy::holger_pages().beta_line;
assert_eq!(beta, "Beta: we replicate your packages, but cannot guarantee them.");
assert!(tab.text().await.contains(&beta), "the beta line: {}", tab.text().await);
assert!(!tab.text().await.contains("we replicate your data"), "Gunnar's beta line on Holger's buy page: {}", tab.text().await);
// ── signed in: the one buy page's two tabs, no e-mail field ──
tab.open(&rig.at("/signup")).await.unwrap();
tab.until("the signed-in choices", "document.querySelectorAll('#choice button').length >= 2").await;
let choices = tab.js("[...document.querySelectorAll('#choice button')].map(b => b.textContent)").await.unwrap();
let words: Vec<String> = serde_json::from_value(choices).unwrap();
assert_eq!(words, vec!["Grow your plan".to_owned(), "New private instance".to_owned()], "{words:?}");
tab.until("no e-mail field signed in", "document.getElementById('f-email').getClientRects().length === 0").await;
// ── a private instance, paid ──
tab.click_text("New private instance").await;
tab.until("the private ladder", "document.getElementById('heading').textContent === 'New private Holger' && document.querySelectorAll('#plans > *, #step-name').length > 0").await;
assert!(!tab.shown("#f-email").await, "signed in: the order mails the account's own address");
tab.fill("#company", "acme").await;
tab.until("the private button says Pay <amount>", "/^Pay \\S/.test(document.getElementById('go').textContent)").await;
tab.click("#go").await;
let checkout = tab.until_url("the instance goes to the vendor", STRIPE).await;
{
let b = rig.fake.book();
let c = b.checkout(&checkout).unwrap();
let monetize_site_buy::testkit::What::Estate { account, email, new_account, .. } = &c.what else { panic!("an estate order: {c:?}") };
assert_eq!((account.as_str(), email.as_str(), *new_account), ("newbie", "", false), "the account's own order, no e-mail");
}
assert!(rig.fake.pay(&checkout));
// ── back from paying, and a reload: only the order's status, nothing to pay again ──
// (owner 2026-10-23: Stripe ONCE per buy; "double still", test2 2026-10-25)
tab.open(&rig.at("/paid/estate/acme")).await.unwrap();
only_the_status(&tab, "back from paying for acme").await;
tab.js("window.__before_reload = true; location.reload(); true").await.unwrap();
tab.until("the reloaded page", "!window.__before_reload && document.readyState === 'complete'").await;
only_the_status(&tab, "acme's way back, reloaded").await;
// ★ BUYFLOW-4, finding 4: "paid, setting up" — the order's steps, and once it is ready every
// step done and the link to acme's Holger.
tab.until("acme's steps", "document.querySelectorAll('#progress li').length >= 4 && document.querySelector('#progress li').className === 'done'").await;
tab.until("acme is ready: every step done, and the link to it", "[...document.querySelectorAll('#progress li')].every(li => li.className === 'done') && [...document.querySelectorAll('#state a')].some(a => a.getAttribute('href').startsWith('https://acme.'))").await;
only_the_status(&tab, "acme ready").await;
assert_eq!(rig.fake.book().checkouts_of("estate").len(), 1, "one Stripe page for acme: the way back opened none");
// ── Grow acme's Holger → Upgrade ──
tab.open(&rig.at("/signup?plan=grow&instance=acme")).await.unwrap();
tab.until("the grow heading", "document.getElementById('heading').textContent === 'Grow acme’s Holger'").await;
tab.until("the grow button", "document.getElementById('go').textContent === 'Upgrade'").await;
// ── Your purchases ──
tab.open(&rig.at("/plan")).await.unwrap();
tab.until("Your purchases", "[...document.querySelectorAll('h2')].some(h => h.textContent === 'Your purchases')").await;
tab.until("acme in Your purchases", "document.body.innerText.includes('acme.')").await;
tab.assert_no_link_to(NEVER).await.unwrap();
// ── an unpaid order, cancelled on /cancel/<reference> ──
tab.open(&rig.at("/signup?plan=account")).await.unwrap();
tab.until("the account's grow", "document.getElementById('go').textContent === 'Upgrade'").await;
tab.until("a bundle", "!document.getElementById('go').disabled").await;
tab.click("#go").await;
let checkout = tab.until_url("the grow goes to the vendor", STRIPE).await;
let reference = rig.fake.book().checkout(&checkout).unwrap().reference.clone();
tab.open(&rig.at(&format!("/cancel/{reference}"))).await.unwrap();
tab.until("the cancelled page", "!document.getElementById('cancelled').hidden").await;
tab.click_text("Cancel order").await;
tab.until("the order is cancelled", "document.body.innerText.includes('Cancelled')").await;
assert_eq!(rig.fake.book().count("Purchase.Cancel"), 1, "the ONE box heard the cancel");
tab.assert_no_link_to(NEVER).await.unwrap();
browser.assert_nothing_went_to(NEVER, &rig.base, STRIPE);
}
/// **Back from paying: the order's status and nothing else** (owner 2026-10-23: Stripe ONCE per
/// buy). The status says the payment was received; the choices, the order form and its button
/// are hidden, and no shown button says "Pay …".
/// Whether any field or label of the order form is laid out on the screen.
const SEES_FORM: &str = "[...document.querySelectorAll('#form input, #form label')].some(e => getComputedStyle(e).visibility !== 'hidden' && e.getClientRects().length > 0)";
async fn only_the_status(tab: &holger_ui_browser_tests::browser::Tab, at: &str) {
tab.until(&format!("{at}: the order's status"), "!!document.getElementById('state') && document.getElementById('state').textContent.includes('Payment received')").await;
for id in ["#go", "#form", "#choice"] {
assert!(!tab.shown(id).await, "{at}: `{id}` is hidden after paying: {}", tab.text().await);
}
// ★ PAID-3: what the buyer SEES — Holger's CSS draws the form as a grid, so its `hidden`
// attribute alone is no proof: no field and no label of the order form is on the screen.
assert!(!tab.js(SEES_FORM).await.unwrap().as_bool().unwrap_or(true), "{at}: the order form is on the screen after paying: {}", tab.text().await);
let pay = tab.js("[...document.querySelectorAll('button')].filter(b => !b.hidden && b.offsetParent !== null).map(b => b.textContent.trim()).filter(t => t.startsWith('Pay'))").await.unwrap();
assert_eq!(pay, serde_json::json!([]), "{at}: a paid buyer is never shown a Pay button: {}", tab.text().await);
}