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