//! ★ **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);
}