Gunnar

nordisk/gunnar-ui

uitest/src/clanpage.rs

//! ★ **You → Clans and You → Account, in a real browser** (owner's walk
//! through gunnar.rs, 2026-10-14) — the BUILT console (`trunk build`'s `dist`)
//! behind a loopback stub that answers gunnar's JSON, driven by headless Chrome
//! over the DevTools Protocol with REAL mouse clicks.
//!
//! * Clans: at first only "Create a clan" and, on a clan the person
//!   administers, "Invite someone"; everything else is under "More".
//! * A clan with ONE admin: that admin's "Remove" and "Make a member" are
//!   disabled, with the reason.
//! * "Dissolve" sits under More, apart, and is sent only with the clan's name
//!   typed; "Invite" sends the account typed.
//! * A clan with no room, a person with none to give: plain words, and "Buy a
//!   plan" leads to Storage (which goes on to the sign-up page).
//!
//! Needs `GUNNAR_UI_CHROME` (a Chrome binary) and `GUNNAR_UI_DIST` (the built
//! console's `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::sync::{Arc, Mutex};
use std::time::Duration;

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

type Posted = Arc<Mutex<Vec<(String, String)>>>;

const STATUS: &str = r#"{"authenticated":true,"user":"rickard","role":"admin","passkey_enabled":true,"open":false,"account":"rickard","account_role":"admin","buy_url":"/account/storage","may_buy":true,"shop_here":true}"#;
const HEALTH: &str = r#"{"serving":true,"agent":"gunnar","version":"1.5.0","build_ref":"v1.4.0-1816-gcec5795f","uptime_seconds":60,"grpc_authentication_enabled":true,"grpc_bind":"127.0.0.1:1","ssh_bind":"127.0.0.1:2"}"#;
/// One clan the reader administers ALONE, with no room, and nothing to give.
const CLANS: &str = r#"{"clans":[{"name":"fellows","created_by":"rickard","role":"admin","room_bytes":0,"used_bytes":0,
    "your_contribution_bytes":0,"your_floor_bytes":0,"your_room_left_bytes":0,"room_in_force_bytes":0,
    "members":[{"id":"rickard","admin":true,"contribution_bytes":0,"lapsed":false}],"invites":[],"offers":[]}],"offers":[]}"#;

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",
        _ => "application/octet-stream",
    }
}

fn answer(mut conn: TcpStream, dist: &Path, posted: &Posted) {
    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/clans") => json(CLANS),
        ("GET", "/api/account/plan") => json(r#"{"plan":"","state":"none","paid_until_unix_ms":null}"#),
        ("GET", "/api/principals") | ("GET", "/api/keys") | ("GET", "/api/grants") => json(r#"{"items":[],"total":0,"truncated":false}"#),
        ("GET", "/api/accounts/gates") => json("[]"),
        ("GET", "/api/account/subscription") => json(r#"{"reference":"","can_serve":false,"reason":"","words":"","period":"","nothing_to_renew":true}"#),
        ("POST", p) => {
            posted.lock().unwrap().push((p.to_owned(), String::from_utf8_lossy(&body).into_owned()));
            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) => {
            // A built file, else the app's own page (its router takes the path).
            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, Posted) {
    let l = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
    let port = l.local_addr().unwrap().port();
    let posted: Posted = Default::default();
    let theirs = posted.clone();
    std::thread::spawn(move || {
        for conn in l.incoming().flatten() {
            let (dist, posted) = (dist.clone(), theirs.clone());
            std::thread::spawn(move || answer(conn, &dist, &posted));
        }
    });
    (port, posted)
}

/// The browser and the built console, or NOT RUN.
fn rig(tag: &str, path: &str, ready: &str) -> Option<(Chrome, Cdp, String, Posted)> {
    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, posted) = 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!("clanpage-{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.navigate(&sess, &format!("http://127.0.0.1:{port}{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, posted))
}

/// A person's click: the mouse pressed and released at the middle of the
/// element's own box, scrolled into view first.
fn click(c: &mut Cdp, sess: &str, selector: &str) {
    let at = c
        .eval(sess, &format!("(() => {{ const e = document.querySelector({}); e.scrollIntoView({{block: 'center'}}); const r = e.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");
    }
}

fn type_into(c: &mut Cdp, sess: &str, selector: &str, value: &str) {
    let done = c
        .eval(
            sess,
            &format!(
                "(() => {{ const i = document.querySelector({}); if (!i) return false; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(i, {}); i.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()",
                esc(selector),
                esc(value)
            ),
        )
        .expect("typing");
    assert_eq!(done.as_bool(), Some(true), "no {selector} to type into");
}

fn truthy(c: &mut Cdp, sess: &str, expr: &str) -> bool {
    c.eval(sess, &format!("!!({expr})")).unwrap_or_else(|e| panic!("{expr}: {e}")).as_bool().unwrap_or(false)
}

/// Is the element drawn where a person can see and press it?
fn shown(c: &mut Cdp, sess: &str, selector: &str) -> bool {
    truthy(c, sess, &format!("(() => {{ const e = document.querySelector({}); return e && e.getClientRects().length > 0 && e.checkVisibility(); }})()", esc(selector)))
}

const CARD: &str = "#clan-fellows";

#[test]
fn clans_show_create_and_invite_first_and_the_rest_under_more() {
    let Some((_chrome, mut c, sess, posted)) = rig("clans", "/account/clans", "!!document.querySelector('#clan-fellows')") else { return };
    let text = c.eval_str(&sess, "document.querySelector('main').innerText").unwrap();
    // F: what is shown at first.
    assert!(shown(&mut c, &sess, "#clan-create") && text.contains("Create a group"), "{text}");
    assert!(shown(&mut c, &sess, &format!("{CARD} .clan-invite-send")) && text.contains("Invite someone"), "{text}");
    for hidden in [".clan-give-set", ".clan-leave", ".clan-move-offer", ".clan-remove", ".clan-admin", ".clan-dissolve"] {
        assert!(!shown(&mut c, &sess, &format!("{CARD} {hidden}")), "{hidden} is drawn before More is opened");
    }
    // G: plain words, and where a plan is bought.
    assert!(text.contains("This clan has no room yet.") && !text.contains("Room 0 B") && !text.contains("it may go down"), "{text}");
    assert!(text.contains("You have no room to give until you buy a plan."), "{text}");
    assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{CARD} .clan-nothing-to-give a').getAttribute('href')")).unwrap(), "/account/storage");
    assert_eq!(c.eval_str(&sess, "document.querySelector('#clan-name').placeholder").unwrap(), "Group name");
    assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{CARD} .clan-invite-who').placeholder")).unwrap(), "Account to invite");

    // Invite sends the account typed.
    type_into(&mut c, &sess, &format!("{CARD} .clan-invite-who"), "kim");
    click(&mut c, &sess, &format!("{CARD} .clan-invite-send"));
    c.wait_for(&sess, &format!("!!document.querySelector('{CARD}')"), 10_000).unwrap();
    std::thread::sleep(Duration::from_millis(300));
    assert_eq!(posted.lock().unwrap().clone(), [("/api/account/clans/fellows/invite".to_owned(), r#"{"account":"kim"}"#.to_owned())]);

    // More, opened by a click: the rest is there. No function is gone.
    click(&mut c, &sess, &format!("{CARD} .clan-more > summary"));
    for there in [".clan-give-set", ".clan-leave", ".clan-move-offer", ".clan-remove", ".clan-admin", ".clan-dissolve"] {
        assert!(shown(&mut c, &sess, &format!("{CARD} {there}")), "{there} is not under More");
    }
    assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{CARD} .clan-move-repo').placeholder")).unwrap(), "Repository to move");
    assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{CARD} .clan-move-to').placeholder")).unwrap(), "Account to move it into");

    // H: the clan's ONE admin is offered no working Remove or Make a member.
    assert!(truthy(&mut c, &sess, &format!("document.querySelector('{CARD} .clan-remove').disabled && document.querySelector('{CARD} .clan-admin').disabled")), "the last admin's buttons work");
    let why = c.eval_str(&sess, &format!("document.querySelector('{CARD} .clan-why').innerText")).unwrap();
    assert_eq!(why, "A clan keeps at least one admin: make another member admin first.");
    click(&mut c, &sess, &format!("{CARD} .clan-remove"));
    click(&mut c, &sess, &format!("{CARD} .clan-admin"));

    // I: Dissolve is apart, and sent only with the clan's name typed.
    assert!(truthy(&mut c, &sess, &format!("document.querySelector('{CARD} .clan-dissolve').disabled")), "Dissolve is armed with nothing typed");
    click(&mut c, &sess, &format!("{CARD} .clan-dissolve"));
    std::thread::sleep(Duration::from_millis(300));
    assert_eq!(posted.lock().unwrap().len(), 1, "a disabled press sent something: {:?}", posted.lock().unwrap());
    type_into(&mut c, &sess, &format!("{CARD} .clan-dissolve-confirm"), "fellows");
    c.wait_for(&sess, &format!("!document.querySelector('{CARD} .clan-dissolve').disabled"), 5_000).expect("the name arms Dissolve");
    click(&mut c, &sess, &format!("{CARD} .clan-dissolve"));
    std::thread::sleep(Duration::from_millis(500));
    assert_eq!(posted.lock().unwrap().last().cloned(), Some(("/api/account/clans/fellows/dissolve".to_owned(), "{}".to_owned())));
}

/// ★ Owner 2026-10-14: Identities showed "alice" in its empty fields, and beside "Purge
/// account" that read like a real account filled in. The fields are empty with plain
/// hints, and the purge is armed only by the account typed twice — never by a hint.
#[test]
fn identities_fields_are_empty_with_hints_and_purge_needs_the_name_typed_twice() {
    let Some((_chrome, mut c, sess, posted)) = rig("identities", "/access", "!!document.getElementById('purge-confirm')") else { return };
    let hint = |c: &mut Cdp, id: &str| c.eval_str(&sess, &format!("document.getElementById('{id}').placeholder")).unwrap();
    assert_eq!(hint(&mut c, "principal-name"), "New principal's name");
    assert_eq!(hint(&mut c, "gate-account"), "Account to lock out");
    assert_eq!(hint(&mut c, "purge-account"), "Account to purge");
    assert_eq!(hint(&mut c, "purge-confirm"), "Type the account name to confirm");
    let page = c.eval_str(&sess, "document.querySelector('main').innerText + [...document.querySelectorAll('input')].map(i => i.value + i.placeholder).join(' ')").unwrap();
    assert!(!page.contains("alice"), "an example name is still on the page");
    let purge = "[...document.querySelectorAll('button')].find(b => b.innerText.trim() === 'Purge account')";
    let armed = |c: &mut Cdp| !truthy(c, &sess, &format!("{purge}.disabled"));
    assert!(!armed(&mut c), "Purge is armed with both fields empty (their hints showing)");
    type_into(&mut c, &sess, "#purge-account", "kim");
    assert!(!armed(&mut c), "Purge is armed with the confirmation empty");
    type_into(&mut c, &sess, "#purge-confirm", "Type the account name to confirm");
    assert!(!armed(&mut c), "the confirmation's hint, typed, arms Purge");
    type_into(&mut c, &sess, "#purge-confirm", "kim");
    c.wait_for(&sess, &format!("!{purge}.disabled"), 5_000).expect("the account typed again arms Purge");
    assert!(posted.lock().unwrap().is_empty(), "nothing was sent");
}