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