Gunnar

nordisk/gunnar-ui

uitest/src/accountpage.rs

//! ★ **You → Account, as gunnar.rs's own admin sees it** (lane ACCT-PAGE, owner 2026-10-29:
//! "fix all"). The page read a Swedish heading ("Åtgärder") on an English page, buttons that
//! ran together ("GrowManage test2gunnarrsOpen"), and an everyday "Restore front" beside them.
//!
//! * ACCT-1: the queue's panel is "Actions"; no Swedish word on the page.
//! * ACCT-5: a purchase's buttons are separate buttons — a gap between every two, and the
//!   copied text names each on its own.
//! * ACCT-6: "Restore front" is a repair: drawn only when monetize says the front needs one
//!   (the row's `repair_words`), inside a small "Repair" section with those words, never among
//!   the row's everyday buttons; a row whose front stands has no such button at all.
//!
//! The BUILT console (`trunk build`'s dist) behind a loopback stub that answers the console's
//! JSON; headless Chrome over the DevTools Protocol. Pictures go to `$ACCOUNTPAGE_SHOTS`.
//! Needs `GUNNAR_UI_CHROME` and `GUNNAR_UI_DIST`; without both each test says NOT RUN.

#![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::Json;

const STATUS: &str = r#"{"authenticated":true,"user":"rickard","role":"admin","passkey_enabled":true,"open":false,"account":"rickard","account_role":"individual","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/2.0.0","version":"1.5.1","build_ref":"v1.5.1-12-gabcdef0","uptime_seconds":60,"grpc_authentication_enabled":true,"grpc_bind":"127.0.0.1:1","ssh_bind":"127.0.0.1:2"}"#;
const ACTIONS: &str = r#"{"items":[{"id":"no-ssh-key","urgency":"urgent","title":"Add your SSH key so you can push","detail":"Git signs in with an SSH key, not with your account name.","href":"/account#keys","cta":"Add a key"}],"headline":"Add your SSH key so you can push"}"#;

/// Two instances: `test2gunnarrs`, whose front stands, and `lostfront`, whose front monetize
/// says needs a repair.
const PURCHASES: &str = r#"{"estates":[
  {"company":"test2gunnarrs","host":"test2.gunnar.rs","url":"https://test2.gunnar.rs","state":"ready","words":"Your Gunnar is ready.","step":"Mini","resources_words":"7 cores · 16 GB RAM · 40 GB data","monthly_words":"0.50 EUR a day","paid_until":"","grow_url":"/signup?plan=grow&instance=test2gunnarrs","renew_url":"/signup?plan=private&renew=test2gunnarrs","repair_words":""},
  {"company":"lostfront","host":"lostfront.gunnar.rs","url":"https://lostfront.gunnar.rs","state":"ready","words":"Your Gunnar is ready.","step":"Mini","resources_words":"7 cores · 16 GB RAM · 40 GB data","monthly_words":"92.36 EUR a month","paid_until":"2027-04-29","grow_url":"/signup?plan=grow&instance=lostfront","renew_url":"/signup?plan=private&renew=lostfront","repair_words":"lostfront.gunnar.rs's front has not answered for 4 hours. Restore it from its daily backup, with its keys."}
],"rooms":[],"storage_lines":[],"buy":null}"#;

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) {
    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(ACTIONS),
        ("GET", "/api/account/purchases") => json(PURCHASES),
        ("GET", "/api/account/subscription") => json(r#"{"reference":"","can_serve":false,"reason":"","words":"","period":"","nothing_to_renew":true}"#),
        ("GET", "/api/account/subscription/history") => json("[]"),
        ("GET", "/api/account/subscription/bundles") => json(r#"{"offers":[],"withheld":0,"plan":{"state":"none"},"market":"SE"}"#),
        ("GET", "/api/account/room") => json(r#"{"namespace":"rickard","used_bytes":0,"cap_bytes":0,"free_bytes":0,"last_push_unix_ms":null,"read_only":false,"repos":[]}"#),
        ("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}"#),
        ("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) -> 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));
        }
    });
    port
}

/// The browser on the built console's `/account` at `width`×`height`, once Your purchases and
/// the queue are drawn; or NOT RUN.
fn rig(tag: &str, width: u32, height: u32) -> Option<(Chrome, Cdp, 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));
    let debug = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
    let profile = std::env::temp_dir().join(format!("accountpage-{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");
    cdp.navigate(&sess, &format!("http://127.0.0.1:{port}/account")).expect("the console loads");
    let ready = "document.querySelectorAll('#your-purchases .purchase').length === 2 && [...document.querySelectorAll('main .panel > header h2')].some(h => h.closest('.panel').querySelector('.todo'))";
    if let Err(e) = cdp.wait_for(&sess, ready, 30_000) {
        let text = cdp.eval_str(&sess, "document.body.innerText").unwrap_or_default();
        panic!("/account never drew its purchases and its queue ({e}): {}", crate::http::cut(&text, 900));
    }
    Some((chrome, cdp, sess))
}

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

/// Every two neighbouring buttons of a row, on the same line: the gap between them, px.
const GAPS: &str = r#"(sel => [...document.querySelectorAll(sel)].flatMap(row => {
  const bs = [...row.querySelectorAll(':scope > a.btn, :scope > button.btn, :scope > * > a.btn, :scope > * > button.btn')].map(b => b.getBoundingClientRect());
  const gaps = [];
  for (let i = 1; i < bs.length; i++) { if (Math.abs(bs[i].top - bs[i - 1].top) < 4) gaps.push(bs[i].left - bs[i - 1].right); }
  return gaps;
}))('#your-purchases .row-actions')"#;

fn the_page(width: u32, height: u32) {
    let tag = format!("admin-{width}");
    let Some((_chrome, mut c, sess)) = rig(&tag, width, height) else { return };

    // ACCT-1: "Actions", and no Swedish on the page.
    let titles = c.eval_str(&sess, "[...document.querySelectorAll('main .panel > header h2')].map(h => h.innerText.trim()).join('|')").unwrap();
    let queue = c.eval_str(&sess, "[...document.querySelectorAll('main .panel')].filter(p => p.querySelector('.todo')).map(p => p.querySelector('header h2').innerText.trim()).join('|')").unwrap();
    assert_eq!(queue, "Actions", "{tag}: the queue's panel: {titles}");
    let text = c.eval_str(&sess, "document.body.innerText").unwrap();
    for swedish in ["Åtgärd", "åtgärd", "Köp", "Avbryt", "Spara"] {
        assert!(!text.contains(swedish), "{tag}: the page says {swedish:?}:\n{text}");
    }

    // ACCT-5: separate buttons, apart on screen and in the copied text.
    let gaps: Vec<f64> = c.eval(&sess, GAPS).unwrap().arr().iter().filter_map(Json::as_f64).collect();
    assert!(!gaps.is_empty(), "{tag}: no two buttons on one line to measure");
    assert!(gaps.iter().all(|g| *g >= 6.0), "{tag}: buttons touch or nearly touch: gaps {gaps:?} px");
    let copied = c.eval_str(&sess, "document.querySelector('#your-purchases .purchase .row-actions').innerText").unwrap();
    for runs_together in ["GrowManage", "test2gunnarrsOpen", "OpenSend"] {
        assert!(!copied.contains(runs_together), "{tag}: the buttons' copied text runs together: {copied:?}");
    }

    // ACCT-6: no repair on the row whose front stands; a Repair section on the row whose front
    // monetize says is lost, with monetize's words, and "Restore front" only there.
    let row = |c: &mut Cdp, company: &str, js: &str| c.eval_str(&sess, &format!("(r => {js})(document.querySelector('#your-purchases .purchase[data-company=\"{company}\"]'))")).unwrap();
    let everyday = c.eval_str(&sess, "[...document.querySelectorAll('#your-purchases .row-actions')].map(r => r.innerText).join('|')").unwrap();
    assert!(!everyday.contains("Restore front"), "{tag}: Restore front among the everyday buttons: {everyday:?}");
    assert_eq!(row(&mut c, "test2gunnarrs", "String([...r.querySelectorAll('button, a')].filter(b => b.innerText.includes('Restore front')).length + r.querySelectorAll('.repair').length)"), "0", "{tag}: a front that stands is offered no repair");
    let repair = row(&mut c, "lostfront", "(s => s ? s.querySelector('h4, h3, strong, summary').innerText.trim() + '|' + s.innerText + '|' + s.querySelectorAll('button').length : 'none')(r.querySelector('.repair'))");
    let mut parts = repair.splitn(3, '|');
    let (heading, words, buttons) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
    assert_eq!(heading, "Repair", "{tag}: the Repair section: {repair:?}");
    assert!(words.contains("front has not answered for 4 hours"), "{tag}: monetize's words in the Repair section: {repair:?}");
    assert!(words.contains("Restore front") && buttons == "1", "{tag}: Restore front inside the Repair section: {repair:?}");
    shot(&mut c, &sess, &format!("account-{tag}.png"));
}

#[test]
fn the_account_page_reads_actions_spaced_buttons_and_repair_only_when_needed_at_desktop_width() {
    the_page(1280, 1400);
}

#[test]
fn the_account_page_reads_actions_spaced_buttons_and_repair_only_when_needed_at_390_px() {
    the_page(390, 2400);
}