Gunnar

nordisk/gunnar-ui

uitest/src/accountplan.rs

//! ★ **You → Account: ONE "Plan" panel and ONE button, in a real browser**
//! (owner 2026-10-17: "account → 'Buy a plan', or with a plan 'Grow your
//! account' … the button must be visible and good looking"; "it must be
//! stringent": "Plan", never "Subscription", never "None yet").
//!
//! * A free account (monetize's plan state `none`): the Plan panel reads
//!   "Free", the button "Buy a plan".
//! * An account with a plan: monetize's plan name, its storage and the term
//!   it is paid until; the button "Grow your account".
//! * The button is at least the console's primary button's size, inside the
//!   viewport on first paint, legible (contrast ≥ 4.5), at desktop width and
//!   at 390 px; a real click lands on the ONE sign-up page, whose heading and
//!   tab say the button's word ("Buy a plan" / "Grow your account").
//! * What is used stays on Account, read only, under the Plan.
//! * `/account/storage` is no page of its own: it lands on the same sign-up page.
//! * The Administration strip is ONE line, its link inline.
//!
//! The BUILT console (`trunk build`'s dist) and `signup.html`, byte for byte,
//! behind a loopback stub that answers the console's JSON; headless Chrome over
//! the DevTools Protocol. Pictures go to `$ACCOUNTPLAN_SHOTS` when it is set.
//!
//! Needs `GUNNAR_UI_CHROME` and `GUNNAR_UI_DIST`; without both each test says
//! NOT RUN and returns.

#![cfg(test)]

use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::time::Duration;

use crate::cdp::{Cdp, Chrome};
use crate::json::{esc, Json};

const SIGNUP: &str = include_str!("../../crates/gunnar-ui-server/src/auth/signup.html");

const STATUS: &str = r#"{"authenticated":true,"user":"alma","role":"admin","passkey_enabled":true,"open":false,"account":"alma","account_role":"admin","buy_url":"/signup?plan=10gb","private_url":"/signup?plan=private","signup_url":"/signup","may_buy":true,"shop_here":true}"#;
const HEALTH: &str = r#"{"serving":true,"agent":"gunnar","version":"1.5.0","build_ref":"v1.5.0","uptime_seconds":60,"grpc_authentication_enabled":true,"grpc_bind":"127.0.0.1:1","ssh_bind":"127.0.0.1:2"}"#;

/// The ONE box's rows, as monetize's window passes them on.
const OFFERS: &str = r#"[
    {"id":"gunnar/10gb-1m","label":"10 GB · 1 month","price_words":"110.00 SEK","period":"month","delta":{"pack_bytes":10737418240},"can_serve":true,"reason":"","action":"start"},
    {"id":"gunnar/50gb-1m","label":"50 GB · 1 month","price_words":"450.00 SEK","period":"month","delta":{"pack_bytes":53687091200},"can_serve":true,"reason":"","action":"upgrade"}]"#;

#[derive(Clone, Copy, PartialEq)]
enum Held {
    /// monetize's `none`: a free account.
    Free,
    /// A paid 10 GB month.
    Plan,
}

fn bundles(held: Held) -> String {
    let plan = match held {
        Held::Free => r#"{"state":"none"}"#.to_owned(),
        Held::Plan => r#"{"state":"paid","period":"month","label":"10 GB · 1 month","cap_bytes":10737418240,"paid_until_unix_ms":1795046400000,"days_left":25,"days_total":30,"ends_words":"Paid until 2026-11-18 — 25 days left","price_words":"110.00 SEK per month"}"#.to_owned(),
    };
    format!(r#"{{"offers":{OFFERS},"withheld":0,"plan":{plan},"market":"SE"}}"#)
}

fn renewal(held: Held) -> &'static str {
    match held {
        Held::Free => r#"{"reference":"","can_serve":false,"reason":"","words":"","period":"","nothing_to_renew":true}"#,
        Held::Plan => r#"{"reference":"q1","can_serve":true,"reason":"","words":"","period":"1 month","price_words":"110.00 SEK","nothing_to_renew":false}"#,
    }
}

fn mime(path: &Path) -> &'static str {
    match path.extension().and_then(|e| e.to_str()) {
        Some("html") => "text/html; charset=utf-8",
        Some("js") => "text/javascript",
        Some("wasm") => "application/wasm",
        Some("css") => "text/css",
        Some("svg") => "image/svg+xml",
        Some("webp") => "image/webp",
        _ => "application/octet-stream",
    }
}

fn answer(mut conn: TcpStream, dist: &Path, held: Held) {
    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 json = |s: &str| (200u16, "application/json", s.as_bytes().to_vec());
    let (code, kind, out): (u16, &str, Vec<u8>) = match (method.as_str(), path.as_str()) {
        ("GET", "/auth/status") => json(STATUS),
        ("GET", "/api/health") => json(HEALTH),
        ("GET", "/api/account/actions") => json(r#"{"items":[]}"#),
        ("GET", "/api/account/purchases") => json(r#"{"estates":[],"rooms":[],"storage_lines":[],"buy":null}"#),
        ("GET", "/api/account/subscription") => json(renewal(held)),
        ("GET", "/api/account/subscription/history") => json("[]"),
        ("GET", "/api/account/subscription/bundles") => json(&bundles(held)),
        ("GET", "/api/account/room") => json(r#"{"namespace":"alma","used_bytes":3328599654,"cap_bytes":10737418240,"free_bytes":7408818586,"last_push_unix_ms":null,"read_only":false,
            "repos":[{"repo":"site","stored_bytes":2254857830,"share_permille":210},{"repo":"tools","stored_bytes":1073741824,"share_permille":100}]}"#),
        ("GET", "/api/account/keys") => json(r#"{"items":[],"total":0,"truncated":false}"#),
        ("GET", "/api/principals") | ("GET", "/api/keys") | ("GET", "/api/grants") => json(r#"{"items":[],"total":0,"truncated":false}"#),
        ("GET", "/signup") => (200, "text/html; charset=utf-8", SIGNUP.as_bytes().to_vec()),
        ("POST", _) => json("{}"),
        ("GET", p) if p.starts_with("/api/") || p.starts_with("/auth/") => (404, "application/json", br#"{"code":"notfound","message":"not in this stub"}"#.to_vec()),
        ("GET", p) => {
            let file = dist.join(p.trim_start_matches('/'));
            let file = if p != "/" && file.is_file() { file } else { dist.join("index.html") };
            match std::fs::read(&file) {
                Ok(bytes) => (200, mime(&file), bytes),
                Err(_) => (404, "text/plain", b"no".to_vec()),
            }
        }
        _ => (404, "text/plain", b"no".to_vec()),
    };
    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);
}

fn serve(dist: PathBuf, held: Held) -> u16 {
    let l = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
    let port = l.local_addr().unwrap().port();
    std::thread::spawn(move || {
        for conn in l.incoming().flatten() {
            let dist = dist.clone();
            std::thread::spawn(move || answer(conn, &dist, held));
        }
    });
    port
}

/// The browser, the built console at `width`×`height`, at `path`; or NOT RUN.
fn rig(tag: &str, held: Held, width: u32, height: u32, path: &str, ready: &str) -> Option<(Chrome, Cdp, String, String)> {
    let (Some(bin), Some(dist)) = (std::env::var("GUNNAR_UI_CHROME").ok().filter(|v| !v.is_empty()), std::env::var("GUNNAR_UI_DIST").ok().filter(|v| !v.is_empty())) else {
        eprintln!("NOT RUN: set GUNNAR_UI_CHROME (a Chrome binary) and GUNNAR_UI_DIST (the console's built dist)");
        return None;
    };
    assert!(Path::new(&dist).join("index.html").is_file(), "{dist} holds no built console");
    let port = serve(PathBuf::from(dist), held);
    let debug = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
    let profile = std::env::temp_dir().join(format!("accountplan-{tag}-{}", std::process::id()));
    let chrome = Chrome::launch(&bin, &profile, debug).expect("chrome starts");
    let mut cdp = Cdp::attach(debug).expect("cdp");
    let sess = cdp.page().expect("a page");
    cdp.viewport(&sess, width, height).expect("the viewport");
    let origin = format!("http://127.0.0.1:{port}");
    cdp.navigate(&sess, &format!("{origin}{path}")).expect("the console loads");
    if let Err(e) = cdp.wait_for(&sess, ready, 30_000) {
        let text = cdp.eval_str(&sess, "document.body.innerText").unwrap_or_default();
        panic!("{path} never drew ({e}): {}", crate::http::cut(&text, 600));
    }
    Some((chrome, cdp, sess, origin))
}

fn shot(c: &mut Cdp, sess: &str, name: &str) {
    if let Ok(dir) = std::env::var("ACCOUNTPLAN_SHOTS") {
        let _ = std::fs::create_dir_all(&dir);
        std::thread::sleep(Duration::from_millis(400));
        let _ = c.screenshot(sess, &Path::new(&dir).join(name));
    }
}

/// A person's click at the middle of the element's own box.
fn click(c: &mut Cdp, sess: &str, selector: &str) {
    let at = c
        .eval(sess, &format!("(() => {{ const r = document.querySelector({}).getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()", esc(selector)))
        .unwrap_or_else(|e| panic!("no {selector} to press: {e}"));
    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");
    }
}

/// The button's measurements, against a console primary button drawn beside
/// it: `[w, h, ref_w, ref_h, top, bottom, left, right, inner_w, inner_h, contrast, visible]`.
const MEASURE: &str = r#"(() => {
  const b = document.getElementById('account-buy-grow');
  const ref = document.createElement('button');
  ref.className = 'btn primary'; ref.textContent = 'Prolong';
  document.querySelector('main').appendChild(ref);
  const r = b.getBoundingClientRect(), q = ref.getBoundingClientRect();
  ref.remove();
  const rgb = s => (s.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
  const lum = c => { const [r, g, b] = c.map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
  const cs = getComputedStyle(b);
  const a = lum(rgb(cs.color)), z = lum(rgb(cs.backgroundColor));
  const contrast = (Math.max(a, z) + 0.05) / (Math.min(a, z) + 0.05);
  return [r.width, r.height, q.width, q.height, r.top, r.bottom, r.left, r.right, innerWidth, innerHeight, contrast, b.checkVisibility() ? 1 : 0];
})()"#;

fn the_button_is_big_visible_and_legible(c: &mut Cdp, sess: &str, at: &str) {
    let m: Vec<f64> = c.eval(sess, MEASURE).expect("measured").arr().iter().filter_map(Json::as_f64).collect();
    let [w, h, rw, rh, top, bottom, left, right, iw, ih, contrast, visible] = m[..] else { panic!("{at}: {m:?}") };
    assert_eq!(visible, 1.0, "{at}: the button is not drawn");
    assert!(w >= rw && h >= rh, "{at}: the button ({w}×{h}) is smaller than the console's primary button ({rw}×{rh})");
    assert!(h >= 44.0, "{at}: the button is {h} px tall — not a prominent target");
    assert!(top >= 0.0 && bottom <= ih && left >= 0.0 && right <= iw, "{at}: the button [{left},{top} – {right},{bottom}] is not in the {iw}×{ih} viewport on first paint");
    assert!(contrast >= 4.5, "{at}: contrast {contrast:.2} < 4.5");
}

fn walk(held: Held, width: u32, height: u32) {
    let tag = format!("{}-{width}", if held == Held::Free { "free" } else { "plan" });
    let Some((_chrome, mut c, sess, _)) = rig(&tag, held, width, height, "/account", "!!document.getElementById('account-buy-grow')") else { return };
    let (label, name) = match held {
        Held::Free => ("Buy a plan", "Free"),
        Held::Plan => ("Grow your account", "10 GB · 1 month"),
    };
    assert_eq!(c.eval_str(&sess, "document.getElementById('account-buy-grow').innerText.trim()").unwrap(), label, "{tag}");
    // ONE panel, "Plan"; never "Subscription", never "None yet".
    let titles = c.eval_str(&sess, "[...document.querySelectorAll('main .panel > header h2')].map(h => h.innerText.trim()).join('|')").unwrap();
    assert_eq!(titles.matches("Plan").count(), 1, "{tag}: one Plan panel: {titles}");
    let main = c.eval_str(&sess, "document.querySelector('main').innerText").unwrap();
    for never in ["Subscription", "subscription", "None yet"] {
        assert!(!main.contains(never), "{tag}: the page says {never:?}:\n{main}");
    }
    assert_eq!(c.eval_str(&sess, "document.getElementById('plan-name').innerText.trim()").unwrap(), name, "{tag}");
    if held == Held::Plan {
        // textContent: the metric labels are drawn in capitals by CSS.
        let plan = c.eval_str(&sess, "document.getElementById('account-plan').textContent").unwrap();
        assert!(plan.contains("Storage") && plan.contains("10"), "{tag}: no storage: {plan}");
        assert!(plan.contains("Paid until"), "{tag}: no paid-until: {plan}");
        // Every figure on ONE line (a date broken as "2026-11-" / "19" at 390 px).
        let broken = c
            .eval_str(&sess, "[...document.querySelectorAll('#account-plan .metric dd')].filter(d => { const rg = document.createRange(); rg.selectNodeContents(d); return new Set([...rg.getClientRects()].map(r => Math.round(r.top))).size > 1; }).map(d => d.textContent).join('|')")
            .unwrap();
        assert!(broken.is_empty(), "{tag}: a figure is broken over two lines: {broken}");
    }
    // What is used stays on Account, read only, under the Plan: My room and
    // Per repository, and nothing in them sells.
    c.wait_for(&sess, "[...document.querySelectorAll('main .panel > header h2')].some(h => h.innerText.trim() === 'Per repository')", 15_000)
        .unwrap_or_else(|e| panic!("{tag}: no Per repository on Account ({e})"));
    let used = c
        .eval_str(&sess, "[...document.querySelectorAll('main section.panel')].filter(p => ['My room', 'Per repository'].includes(p.querySelector('h2').innerText.trim())).map(p => p.textContent + ' buttons:' + p.querySelectorAll('button, a.btn').length).join(' | ')")
        .unwrap();
    assert!(used.contains("Used"), "{tag}: no Used: {used}");
    assert!(used.contains("site") && used.contains("tools"), "{tag}: no repositories: {used}");
    assert!(used.matches("buttons:0").count() == 2, "{tag}: the room sells something: {used}");
    let order = c.eval_str(&sess, "[...document.querySelectorAll('main .panel > header h2')].map(h => h.innerText.trim()).join('|')").unwrap();
    assert!(order.starts_with("Plan|My room|Per repository"), "{tag}: the room is not under the Plan: {order}");
    the_button_is_big_visible_and_legible(&mut c, &sess, &tag);
    shot(&mut c, &sess, &format!("account-{tag}.png"));
    click(&mut c, &sess, "#account-buy-grow");
    // ★ The word on the button = the heading = the tab (owner 2026-10-17).
    c.wait_for(&sess, &format!("location.pathname === '/signup' && document.getElementById('heading') && document.getElementById('heading').textContent === {} && (document.querySelector('#choice button[aria-pressed=\"true\"]') || {{}}).textContent === {}", esc(label), esc(label)), 15_000)
        .unwrap_or_else(|e| panic!("{tag}: the button did not land on the sign-up page's {label} — heading and tab ({e}): {}", c.eval_str(&sess, "location.href + ' ' + document.getElementById('heading')?.textContent + ' / ' + [...document.querySelectorAll('#choice button')].map(b => b.textContent + ':' + b.getAttribute('aria-pressed')).join(',')").unwrap_or_default()));
    shot(&mut c, &sess, &format!("signup-from-{tag}.png"));
}

#[test]
fn a_free_account_reads_free_and_buy_a_plan_at_desktop_width() {
    walk(Held::Free, 1280, 900);
}

#[test]
fn a_free_account_reads_free_and_buy_a_plan_at_390_px() {
    walk(Held::Free, 390, 844);
}

#[test]
fn an_account_with_a_plan_reads_it_and_grow_your_account_at_desktop_width() {
    walk(Held::Plan, 1280, 900);
}

#[test]
fn an_account_with_a_plan_reads_it_and_grow_your_account_at_390_px() {
    walk(Held::Plan, 390, 844);
}

/// `/account/storage` is no purchase page: it lands on the same sign-up page.
#[test]
fn account_storage_lands_on_the_signup_page() {
    let Some((_chrome, mut c, sess, _)) = rig("storage", Held::Plan, 1280, 900, "/account/storage", "location.pathname === '/signup'") else { return };
    c.wait_for(&sess, "document.getElementById('heading').textContent === 'Grow your account'", 15_000).expect("the sign-up page's Grow your account");
    assert_eq!(c.eval_str(&sess, "location.search").unwrap(), "?plan=10gb");
}

/// ★ The Administration strip: ONE line at desktop width, the link inline and
/// the period right after it.
#[test]
fn the_administration_strip_is_one_line_with_its_link_inline() {
    let Some((_chrome, mut c, sess, _)) = rig("admin", Held::Plan, 1280, 900, "/admin", "!!document.querySelector('.admin-banner')") else { return };
    let line = c.eval_str(&sess, "(document.querySelector('.admin-banner-line') || document.querySelector('.admin-banner')).innerText.replace(/^Administration\\s*/, '').trim()").unwrap();
    assert_eq!(line, "Settings for the whole site. Your own are under Account.", "the words");
    // One line: every text run of the sentence (the link's too) is ONE box,
    // and all of them sit on the same row.
    let rows = c
        .eval(&sess, "(() => { const s = document.querySelector('.admin-banner-line') || document.querySelector('.admin-banner'); const w = document.createTreeWalker(s, NodeFilter.SHOW_TEXT); const rects = []; let n; while ((n = w.nextNode())) { if (!n.textContent.trim() || n.parentElement.closest('details, .admin-banner-tag')) continue; const rg = document.createRange(); rg.selectNodeContents(n); rects.push(...[...rg.getClientRects()].filter(r => r.width > 0)); } const rows = []; for (const r of rects) { const mid = r.top + r.height / 2; if (!rows.some(m => Math.abs(m - mid) < r.height / 2)) rows.push(mid); } return rows.length; })()")
        .unwrap()
        .as_f64()
        .unwrap_or(0.0);
    assert_eq!(rows, 1.0, "the strip's sentence is on {rows} lines");
    let link = c.eval_str(&sess, "(() => { const a = document.querySelector('.admin-banner a'); return a.innerText + '|' + a.getAttribute('href') + '|' + a.nextSibling.textContent; })()").unwrap();
    assert_eq!(link, "Account|/account|.", "the link and the period right after it");
    shot(&mut c, &sess, "admin-strip-1280.png");
}