uitest/src/steppers.rs
//! ★ **The sign-up page's − / +, in a real browser** (owner 2026-10-12: "the
//! plus and minus buttons haven't worked for days" on test2.gunnar.rs).
//!
//! MEASURED that day: test2's `/auth/signup/ladder` answered ONE plan
//! (`gunnar/10gb-1m`, 110.00 SEK a month) — the ONE box sells one size per
//! country — so the page drew − and + both DISABLED, on every visit. Nothing
//! to step through, and two buttons that looked broken.
//!
//! The page itself (`signup.html`, byte for byte) is 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 the button's own
//! box): a button that something covers is not clicked.
//!
//! * one size on offer: no − / + is drawn (RED before: both shown, disabled);
//! * three sizes (the sign-up page, a visitor's private Gunnar — since
//! 2026-10-15 the account is a list to pick from, `signuppages`): + and −
//! change the size and the price shown;
//! * the room buy (signed in, "Grow your account"): + and − change the size
//! and the price, and the order sent carries the chosen size.
//!
//! 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, Json};
const PAGE: &str = include_str!("../../crates/gunnar-ui-server/src/auth/signup.html");
/// What the stub answers, by path; and every POST it was sent.
struct Stub {
status: String,
ladder: String,
private: String,
bundles: String,
posted: Arc<Mutex<Vec<(String, String)>>>,
}
fn serve(stub: Stub) -> (u16, Arc<Mutex<Vec<(String, String)>>>) {
let l = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let port = l.local_addr().unwrap().port();
let posted = stub.posted.clone();
let stub = Arc::new(stub);
std::thread::spawn(move || {
for conn in l.incoming().flatten() {
let stub = stub.clone();
std::thread::spawn(move || answer(conn, &stub));
}
});
(port, posted)
}
fn answer(mut conn: TcpStream, stub: &Stub) {
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", "/signup") => (200, "text/html; charset=utf-8", PAGE.to_owned()),
("GET", "/auth/status") => (200, "application/json", stub.status.clone()),
("GET", "/auth/signup/ladder") => (200, "application/json", stub.ladder.clone()),
("GET", "/auth/private/ladder") => (200, "application/json", stub.private.clone()),
("GET", "/api/account/subscription/bundles") => (200, "application/json", stub.bundles.clone()),
("POST", p) => {
stub.posted.lock().unwrap().push((p.to_owned(), String::from_utf8_lossy(&body).into_owned()));
(200, "application/json", r#"{"words":"recorded"}"#.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 drive the sign-up page's − / +");
}
c
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
/// A browser on `url`, its page loaded and its ladder answered.
fn open(bin: &str, url: &str, tag: &str) -> (Chrome, Cdp, String) {
let port = free_port();
let profile = std::env::temp_dir().join(format!("steppers-{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.navigate(&sess, url).expect("the page loads");
cdp.wait_for(&sess, "(() => { const s = document.getElementById('step-name').textContent; return s !== '' && s !== '\u{2026}'; })()", 15_000).expect("the ladder answered");
(chrome, cdp, sess)
}
/// A person's click: the mouse pressed and released at the middle of the
/// element's own box.
fn click(c: &mut Cdp, sess: &str, id: &str) {
let at = c
.eval(sess, &format!("(() => {{ const r = document.getElementById({}).getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()", esc(id)))
.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");
}
}
fn shown(c: &mut Cdp, sess: &str) -> (String, String) {
let j = c.eval(sess, "[document.getElementById('step-name').textContent, document.getElementById('price-month').textContent]").unwrap();
let v: Vec<String> = j.arr().iter().map(|x| x.as_str().unwrap_or("").to_owned()).collect();
(v[0].clone(), v[1].clone())
}
fn visible(c: &mut Cdp, sess: &str, id: &str) -> bool {
c.eval(sess, &format!("(() => {{ const b = document.getElementById({}); const s = getComputedStyle(b); return !b.closest('[hidden]') && s.visibility !== 'hidden' && s.display !== 'none'; }})()", esc(id)))
.unwrap()
.as_bool()
.unwrap_or(false)
}
const VISITOR: &str = r#"{"authenticated":false,"account_offer":"10GB","buy_url":"/signup?plan=10gb","signup_url":"/signup","private_url":"/signup?plan=private","may_buy":true}"#;
/// One step of a private Gunnar's ladder, as `/auth/private/ladder` answers it.
fn step(name: &str, month: &str) -> String {
format!(
r#"{{"name":"{name}","resources_words":"{name}: 2 cores, 40 GB data","monthly_words":"{month}","total_words":"{month} · 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}}]}}"#
)
}
/// RED on test2's answer (2026-10-12): one size, and − / + drawn dead.
#[test]
fn one_size_on_offer_draws_no_dead_minus_or_plus() {
let Some(bin) = chrome() else { return };
let private = format!(r#"{{"apex":"gunnar.rs","start":0,"floor":0,"steps":[{}]}}"#, step("Mini", "40.00 EUR a month"));
let (port, _) = serve(Stub { status: VISITOR.into(), ladder: "{}".into(), private, bundles: "{}".into(), posted: Default::default() });
let (_chrome, mut c, sess) = open(&bin, &format!("http://127.0.0.1:{port}/signup?plan=private"), "one");
assert_eq!(shown(&mut c, &sess), ("Mini".to_owned(), "40.00 EUR a month".to_owned()), "one price, in the visitor's currency");
assert!(!visible(&mut c, &sess, "minus") && !visible(&mut c, &sess, "plus"), "one size: no − / + to press");
}
#[test]
fn plus_and_minus_step_the_size_and_the_price_on_the_sign_up_page() {
let Some(bin) = chrome() else { return };
let private = format!(
r#"{{"apex":"gunnar.rs","start":0,"floor":0,"steps":[{},{},{}]}}"#,
step("Mini", "40.00 EUR a month"),
step("Small", "80.00 EUR a month"),
step("Medium", "160.00 EUR a month")
);
let (port, _) = serve(Stub { status: VISITOR.into(), ladder: "{}".into(), private, bundles: "{}".into(), posted: Default::default() });
let (_chrome, mut c, sess) = open(&bin, &format!("http://127.0.0.1:{port}/signup?plan=private"), "signup");
assert!(visible(&mut c, &sess, "minus") && visible(&mut c, &sess, "plus"));
assert_eq!(shown(&mut c, &sess).0, "Mini");
click(&mut c, &sess, "plus");
assert_eq!(shown(&mut c, &sess), ("Small".to_owned(), "80.00 EUR a month".to_owned()), "+ steps up");
click(&mut c, &sess, "plus");
assert_eq!(shown(&mut c, &sess), ("Medium".to_owned(), "160.00 EUR a month".to_owned()));
click(&mut c, &sess, "minus");
assert_eq!(shown(&mut c, &sess), ("Small".to_owned(), "80.00 EUR a month".to_owned()), "− steps down");
}
#[test]
fn plus_and_minus_step_the_room_buy_and_the_order_carries_the_chosen_size() {
let Some(bin) = chrome() else { return };
let status = r#"{"authenticated":true,"user":"bob","account":"bob","buy_url":"/signup?plan=10gb","may_buy":true}"#;
let bundles = r#"{"plan":{"label":"10 GB","state":"active","price_words":"110.00 SEK a month","ends_words":"until 12 Nov"},
"offers":[{"id":"10gb","label":"+10 GB","price_words":"110.00 SEK","period":"month","action":"raise","can_serve":true},
{"id":"50gb","label":"+50 GB","price_words":"400.00 SEK","period":"month","action":"raise","can_serve":true},
{"id":"100gb","label":"+100 GB","price_words":"700.00 SEK","period":"month","action":"raise","can_serve":true}]}"#;
let (port, posted) = serve(Stub { status: status.into(), ladder: "{}".into(), private: "{}".into(), bundles: bundles.into(), posted: Default::default() });
let (_chrome, mut c, sess) = open(&bin, &format!("http://127.0.0.1:{port}/signup?plan=10gb"), "grow");
assert_eq!(shown(&mut c, &sess).0, "10 GB", "starts at what the account has");
click(&mut c, &sess, "plus");
assert_eq!(shown(&mut c, &sess), ("+10 GB".to_owned(), "110.00 SEK".to_owned()));
click(&mut c, &sess, "plus");
click(&mut c, &sess, "plus");
assert_eq!(shown(&mut c, &sess), ("+100 GB".to_owned(), "700.00 SEK".to_owned()));
click(&mut c, &sess, "minus");
assert_eq!(shown(&mut c, &sess), ("+50 GB".to_owned(), "400.00 SEK".to_owned()));
click(&mut c, &sess, "go");
c.wait_for(&sess, "document.getElementById('msg').textContent === 'recorded'", 5_000).expect("the order was answered");
let sent = posted.lock().unwrap().clone();
assert_eq!(sent, [("/api/account/subscription/buy-bundle".to_owned(), r#"{"bundle":"50gb"}"#.to_owned())], "the order carries the chosen size");
}