Gunnar

nordisk/gunnar-ui

uitest/src/main.rs

//! `gunnar-uitest` — the console's functional matrix.
//!
//! See `Cargo.toml` for what it is and why it is split in two layers.

mod cdp;
mod githttp;
mod http;
mod json;
/// Clans (2026-10-05): You → Clans, held against gunnar's answer.
mod clans;
mod matrix;
/// Members never buy (2026-10-07): the members walkthrough, as rows.
mod members;
/// The ONE sign-up page (owner, 2026-10-07): every way in, one page.
mod signup;
/// One shop (owner, 2026-10-07): a SOLD Gunnar offers no purchase.
mod oneshop;
mod addusers;
mod enterprise;
mod firstlogin;
mod kitenrol;
mod places;
mod roomplans;
mod sealed;
mod repodelete;
mod rig;
mod sso;
/// The rig's loopback DNS-over-HTTPS resolver: a company domain's proof (A34-5).
mod doh;
mod ws;
mod clanpage;
/// You → Account: ONE Plan panel, ONE button (owner 2026-10-17).
mod accountplan;
/// You → Account as the site admin sees it: Actions, separate buttons, Repair (ACCT-PAGE, 2026-10-29).
mod accountpage;
mod sitewelcome;
mod steppers;
mod signuppages;
mod repodefault;
mod lfsfileview;

use std::path::{Path, PathBuf};

const USAGE: &str = "\
gunnar-uitest — one functional walk of a gunnar console

usage:
  gunnar-uitest [options]

options:
  --cli <path>       gunnar-cli            (default ../../gunnar/target/release/gunnar-cli),
                     built --features server,control,http,gunnar-server/sso-egress,
                     gunnar-server/mirror-http (the import rows mirror over http)
  --ui <path>        gunnar-ui-server      (default ../target/release/gunnar-ui-server),
                     built --features sso
  --dist <path>      the built console     (default ../crates/gunnar-ui-web/dist)
  --chrome <path>    the browser           (default google-chrome)
  --work <dir>       scratch               (default ./uitest-work)
  --probe            bring the rig up, print every endpoint's real shape, stop
  --live <origin>    NO rig: a read-only browser check of a real deployment
  --page <url>       NO rig, NO assertions: load ONE url in the browser and
                     print every page error, the document's own readyState,
                     and the state of an #enter/#msg pair if the page has one
  --ask <js>         with --page: one extra expression, evaluated and printed
  --shot <path>      with --live: write a PNG of the rendered login page there
  --keep             leave the rig's data behind
  --bench-monetize <path>
                     run a bench monetize (a binary that serves the bench
                     protocol with the FAKE Stripe, never Stripe) and sell
                     through it — the buy rows need a metered console (--ui)
                     and a metered gunnar (--cli). monetize-server-vetra's
                     bench_monetize example, the one such binary, went in
                     monetize v2.0 P2: without one these rows are NOT RUN
                     (the buy relay moves onto monetize-site-buy in T16)
  --fake-idp <path>  run gunnar-server's `fake_idp` example (a FAKE OpenID
                     provider and SAML IdP on loopback, never a real one)
                     and sign a company's people in through it
  --full             print each row's measurement whole, not cut to a width
  --only <glob>[,<glob>…]
                     run only the situations whose names match (`*` any run,
                     `?` one character; repeatable). The rows every browser
                     row stands on (the passkeys and sessions) run as `setup`;
                     every other row is skipped. A situation that needs a
                     skipped row's state fails by name: select that row too.
                     The default is the full matrix — the only run that may
                     call the console green.
  --no-browser       run the API layer only
  --no-authenticator install no virtual authenticator — the suite's own red
  --first-login <file>
                     NO rig: the BUYER of a private Gunnar (the UpCloud mockup
                     purchase). <file> holds the ready mail (or its link,
                     https://<company>.gunnar.rs/login?invite=…&user=…; never
                     printed). A browser with its own authenticator opens it,
                     registers a passkey, logs in as the order's account (an
                     admin), and a second browser finds the link spent.
  --resolve <host>=<ip>:<port>
                     with --first-login: where a name answers (repeatable)
  --spki <base64>    with --first-login: the ONE certificate trusted (the
                     SHA-256 of its SubjectPublicKeyInfo, base64)
  --via <host>       with --first-login: open the link on the buyer's own
                     domain; the front must send it to the relying party
";

fn arg(args: &[String], name: &str) -> Option<String> {
    args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.iter().any(|a| a == "--full") {
        matrix::FULL.store(true, std::sync::atomic::Ordering::Relaxed);
    }
    if args.iter().any(|a| a == "-h" || a == "--help") {
        print!("{USAGE}");
        return;
    }
    let only: Vec<String> = args
        .iter()
        .enumerate()
        .filter(|(_, a)| *a == "--only")
        .filter_map(|(i, _)| args.get(i + 1))
        .flat_map(|v| v.split(',').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect::<Vec<_>>())
        .collect();
    if args.iter().any(|a| a == "--only") {
        if only.is_empty() {
            eprintln!("--only needs a name or a glob (`--only 'a-twin-*'`)");
            std::process::exit(2);
        }
        let _ = matrix::ONLY.set(only);
    }
    if let Some(file) = arg(&args, "--first-login") {
        let chrome = arg(&args, "--chrome").unwrap_or_else(|| "google-chrome".to_owned());
        let mut extra: Vec<String> = Vec::new();
        let rules: Vec<String> = args
            .iter()
            .enumerate()
            .filter(|(_, a)| *a == "--resolve")
            .filter_map(|(i, _)| args.get(i + 1))
            .filter_map(|v| v.split_once('=').map(|(h, at)| format!("MAP {h} {at}")))
            .collect();
        if !rules.is_empty() {
            extra.push(format!("--host-resolver-rules={}", rules.join(",")));
        }
        if let Some(spki) = arg(&args, "--spki") {
            extra.push(format!("--ignore-certificate-errors-spki-list={spki}"));
        }
        let rows = firstlogin::run(&chrome, Path::new(&file), arg(&args, "--via").as_deref(), extra);
        let failed = rows.iter().filter(|x| !x.ok).count();
        println!("\n{} first-login situations, {} PASS, {} FAIL", rows.len(), rows.len() - failed, failed);
        std::process::exit(if failed > 0 { 1 } else { 0 });
    }
    if let Some(url) = arg(&args, "--page") {
        std::process::exit(page_report(&args, &url));
    }
    if let Some(origin) = arg(&args, "--live") {
        let chrome = arg(&args, "--chrome").unwrap_or_else(|| "google-chrome".to_owned());
        eprintln!("read-only live check against {origin}");
        let shot = arg(&args, "--shot").map(PathBuf::from);
        // The bundle this run BUILT, so the live check can compare the bytes a
        // stranger is handed against the bytes that were meant to be shipped.
        // A length is the only thing that tells a release serving last week's
        // assets from one serving this week's; a 200 does not, and on a console
        // that falls back to `index.html` for an unknown path a 200 is what a
        // missing asset answers.
        let here = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        let dist = PathBuf::from(
            arg(&args, "--dist").unwrap_or_else(|| here.join("../crates/gunnar-ui-web/dist").display().to_string()),
        );
        let dist = if dist.is_dir() {
            Some(dist)
        } else {
            eprintln!("note: {} is not a directory — asset LENGTHS will not be checked", dist.display());
            None
        };
        let rows = matrix::live(&chrome, origin.trim_end_matches('/'), shot.as_deref(), dist.as_deref());
        let failed = rows.iter().filter(|x| !x.ok).count();
        println!("\n{} live situations, {} PASS, {} FAIL", rows.len(), rows.len() - failed, failed);
        if failed > 0 {
            std::process::exit(1);
        }
        return;
    }
    let here = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let cli = arg(&args, "--cli")
        .unwrap_or_else(|| here.join("../../gunnar/target/release/gunnar-cli").display().to_string());
    let ui = arg(&args, "--ui")
        .unwrap_or_else(|| here.join("../target/release/gunnar-ui-server").display().to_string());
    let dist = arg(&args, "--dist")
        .unwrap_or_else(|| here.join("../crates/gunnar-ui-web/dist").display().to_string());
    let work = PathBuf::from(arg(&args, "--work").unwrap_or_else(|| here.join("uitest-work").display().to_string()));
    let keep = args.iter().any(|a| a == "--keep");

    // Absolute, before anything is spawned. Every child now runs with its
    // current directory set to the work directory (so nothing it writes lands
    // in this crate), which means a RELATIVE binary path silently stops
    // resolving — `gunnar-ui-server enroll: No such file or directory` with no
    // hint that the path was the problem. Resolve here, once, and say which
    // path was wrong if it cannot be.
    let cli = match std::fs::canonicalize(&cli) {
        Ok(p) => p.display().to_string(),
        Err(e) => {
            eprintln!("--cli {cli}: {e}");
            std::process::exit(2);
        }
    };
    let ui = match std::fs::canonicalize(&ui) {
        Ok(p) => p.display().to_string(),
        Err(e) => {
            eprintln!("--ui {ui}: {e}");
            std::process::exit(2);
        }
    };
    let dist = std::fs::canonicalize(&dist).map(|p| p.display().to_string()).unwrap_or(dist);
    let dist_path = Path::new(&dist);
    let dist_opt = if dist_path.is_dir() { Some(dist_path) } else { None };
    if dist_opt.is_none() {
        eprintln!("note: {dist} is not a directory — the console will serve its API but no page");
    }

    eprintln!("bringing the rig up in {} …", work.display());
    let bench = arg(&args, "--bench-monetize");
    let mut r = match rig::Rig::up(&cli, &ui, dist_opt, &work, keep, bench.as_deref()) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("rig: {e}");
            std::process::exit(2);
        }
    };
    eprintln!("rig up: ssh {} grpc {} console {}", r.ssh, r.grpc, r.origin());
    if let Some(fake) = arg(&args, "--fake-idp") {
        if let Err(e) = r.start_fake_idp(&fake) {
            eprintln!("fake IdP: {e}");
            drop(r);
            std::process::exit(2);
        }
        eprintln!("fake IdP up: {}", r.fake_idp.as_ref().map(|f| f.oidc_issuer.as_str()).unwrap_or(""));
    }
    // ★ One shop (2026-10-07): a SOLD Gunnar's console beside the first, over
    // the same monetize; its rows say NOT RUN without one.
    if bench.is_some() {
        match r.start_provided("https://gunnar.rs") {
            Ok(()) => eprintln!("a sold Gunnar's console up: {}", r.provided_origin().unwrap_or_default()),
            Err(e) => eprintln!("a sold Gunnar's console: {e} (the one-shop rows will say NOT RUN)"),
        }
        // The sign-up page (owner 2026-10-08): a console whose monetize sells
        // no private Gunnar, as test2's; its row says NOT RUN without one.
        match r.start_unsold() {
            Ok(()) => eprintln!("a console that sells no private Gunnar up: {}", r.unsold_origin().unwrap_or_default()),
            Err(e) => eprintln!("a console that sells no private Gunnar: {e} (its sign-up row will say NOT RUN)"),
        }
        // Round 5.3 (test2 2026-10-08): a console whose monetize HAS
        // `[estate_orders]` over a cloud that lays no estate — every step of
        // the ladder unavailable — as test2's today; its row says NOT RUN without one.
        match r.start_unlaid() {
            Ok(()) => eprintln!("a console whose monetize lays no private Gunnar up: {}", r.unlaid_origin().unwrap_or_default()),
            Err(e) => eprintln!("a console whose monetize lays no private Gunnar: {e} (its sign-up row will say NOT RUN)"),
        }
    }

    if args.iter().any(|a| a == "--probe") {
        probe(&r);
        drop(r);
        return;
    }
    let chrome = arg(&args, "--chrome").unwrap_or_else(|| "google-chrome".to_owned());
    let want_browser = !args.iter().any(|a| a == "--no-browser");
    let authenticator = !args.iter().any(|a| a == "--no-authenticator");
    let rows = matrix::run(
        &r,
        if want_browser { Some(chrome.as_str()) } else { None },
        authenticator,
    );
    let mut failed = rows.iter().filter(|x| !x.ok).count();
    let ms: u128 = rows.iter().map(|x| x.ms).sum();
    println!(
        "\n{} situations, {} PASS, {} FAIL, {} ms",
        rows.len(),
        rows.len() - failed,
        failed,
        ms
    );
    if failed > 0 {
        for x in rows.iter().filter(|x| !x.ok) {
            println!("  FAIL {}: {}", x.name, x.detail);
        }
    }
    if let Some(pats) = matrix::ONLY.get() {
        let ordering = std::sync::atomic::Ordering::Relaxed;
        println!(
            "--only {}: {} situation(s) run, {} setup row(s) under them, {} row(s) skipped — NOT the matrix; a verdict on the console is the full run's",
            pats.join(","),
            rows.iter().filter(|x| !matrix::SETUP.contains(&x.name) || matrix::selected(x.name)).count(),
            matrix::SETUP_RAN.load(ordering),
            matrix::SKIPPED.load(ordering)
        );
        if rows.iter().all(|x| !matrix::selected(x.name)) {
            println!("--only {}: no situation of this run matches", pats.join(","));
            failed += 1;
        }
    }
    // **Drop the rig BEFORE exiting, and never `exit` while it is alive.**
    //
    // `std::process::exit` does not run destructors. This function used to call
    // it right here, on the failing path, so every red run orphaned a
    // `gunnar-cli serve` — and the appliance server is a THREAD-HEAVY process.
    // Measured on a build host 2026-08-31: 17 orphans holding ~9 500 threads, the user
    // slice at 15 245 of its 16 000 pid ceiling, and every `cargo test` on the
    // box that tried to start a server dying with
    // `spawning a pipeline worker at boot: WouldBlock`. A test harness that
    // fails is the case that leaks, which is the worst case to leak on.
    drop(r);
    if failed > 0 {
        std::process::exit(1);
    }
}

/// Ask every endpoint for its real answer and print it. This exists so the
/// matrix's assertions can be written against what the console ACTUALLY sends
/// rather than against what a route table suggests it sends: measured, applied
/// before the first green rather than after it.
fn probe(r: &rig::Rig) {
    let addr = r.ui_addr();
    let bearer = ("Authorization", format!("Bearer {}", r.token));

    // A repository with real history, pushed by stock git over the rig's SSH.
    let wt = r.work.join("wt");
    let _ = std::fs::remove_dir_all(&wt);
    std::fs::create_dir_all(&wt).unwrap();
    let steps: Vec<(&str, Vec<&str>)> = vec![
        ("init", vec!["init", "-q", "--object-format=sha256", "-b", "main", "."]),
        ("cfg-name", vec!["config", "user.name", "uitest"]),
        ("cfg-mail", vec!["config", "user.email", "info@example.invalid"]),
    ];
    for (what, a) in steps {
        if let Err(e) = r.git(&wt, &a) {
            eprintln!("probe: {what}: {e}");
            return;
        }
    }
    std::fs::write(wt.join("README"), "gunnar uitest\n").unwrap();
    std::fs::create_dir_all(wt.join("src")).unwrap();
    std::fs::write(wt.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
    for a in [
        vec!["add", "README", "src/lib.rs"],
        vec!["commit", "-qm", "first commit"],
    ] {
        if let Err(e) = r.git(&wt, &a) {
            eprintln!("probe: git {}: {e}", a.join(" "));
            return;
        }
    }
    let remote = r.remote("alice/uitest");
    match r.git(&wt, &["push", "-q", &remote, "main"]) {
        Ok(_) => eprintln!("probe: pushed to {remote}"),
        Err(e) => eprintln!("probe: push: {e}"),
    }
    let head = r.git(&wt, &["rev-parse", "HEAD"]).unwrap_or_default().trim().to_owned();
    println!("### git HEAD = {head}");

    let gets = [
        "/health",
        "/api/version",
        "/api/health",
        "/api/bootstrap",
        "/api/stats",
        "/api/storage",
        "/api/repos",
        "/api/namespaces",
        "/api/principals",
        "/api/keys",
        "/api/grants",
        "/api/repos/alice/uitest",
        "/api/repos/alice/uitest/refs",
        "/api/repos/alice/uitest/head",
        "/api/repos/alice/uitest/log",
        "/api/repos/alice/uitest/browse",
        "/api/repos/alice/uitest/browse?path=src",
        "/api/repos/alice/uitest/pulls",
        "/auth/status",
        "/auth/passkey/invites",
        "/auth/passkey/credentials",
        "/auth/passkey/register/start?user=bob",
        "/login",
    ];
    for path in gets {
        match http::req(&addr, "GET", path, &[bearer.clone()], None) {
            Ok(resp) => println!("GET  {path}\n  {} {}", resp.status, http::cut(&resp.body, 420)),
            Err(e) => println!("GET  {path}\n  ERR {e}"),
        }
    }
    // The one that has to be red before anything is green: no credential at all.
    for path in ["/api/repos", "/api/storage", "/auth/passkey/invites"] {
        match http::req(&addr, "GET", path, &[], None) {
            Ok(resp) => println!("GET  {path} (no token)\n  {} {}", resp.status, http::cut(&resp.body, 200)),
            Err(e) => println!("GET  {path} (no token)\n  ERR {e}"),
        }
    }
    let posts = [
        ("/auth/passkey/invites", r#"{"user":"bob","role":"admin"}"#),
        ("/api/repos", r#"{"namespace":"alice","name":"made-by-api"}"#),
    ];
    for (path, body) in posts {
        let hdrs = [bearer.clone(), ("Content-Type", "application/json".to_owned())];
        match http::req(&addr, "POST", path, &hdrs, Some(body)) {
            Ok(resp) => println!("POST {path} {body}\n  {} {}", resp.status, http::cut(&resp.body, 420)),
            Err(e) => println!("POST {path}\n  ERR {e}"),
        }
    }
    println!("### console log tail:");
    if let Ok(s) = std::fs::read_to_string(r.work.join("ui.err")) {
        for l in s.lines().rev().take(12).collect::<Vec<_>>().into_iter().rev() {
            println!("  {}", http::cut(l, 200));
        }
    }
}

/// ★ **One page, one report, no assertions.**
///
/// The matrix answers "is this console right?". This answers a different and
/// much smaller question: **what does a browser say about this document?** It
/// exists because a page can be served perfectly — 200, the right length, the
/// right content type, every fetch it makes answering 200 to `curl` — and
/// still be dead in a browser, and nothing outside a browser can tell you why.
///
/// MEASURED 2026-09-20, holger.rs: this printed
/// `log: Uncaught SyntaxError: Invalid regular expression: … range out of
/// order in character class`, which is the whole diagnosis of a front page
/// that hung on `reading the server …`. The inline script never parsed.
///
/// It prints rather than asserts on purpose: a diagnosis tool that decides in
/// advance what it is looking for finds only what someone already expected.
fn page_report(args: &[String], url: &str) -> i32 {
    let chrome_bin = arg(args, "--chrome").unwrap_or_else(|| "google-chrome".to_owned());
    let port = match std::net::TcpListener::bind("127.0.0.1:0").and_then(|l| l.local_addr()) {
        Ok(a) => a.port(),
        Err(e) => {
            eprintln!("no free port: {e}");
            return 2;
        }
    };
    let profile = std::env::temp_dir().join(format!("uitest-page-{}", std::process::id()));
    let mut chrome = match cdp::Chrome::launch(&chrome_bin, Path::new(&profile), port) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("chrome: {e}");
            return 2;
        }
    };
    let code = (|| -> Result<i32, String> {
        let mut c = cdp::Cdp::attach(port)?;
        let sess = c.page()?;
        c.viewport(&sess, 1440, 900)?;
        c.navigate(&sess, url)?;
        // The page's own script is given a second to settle a fetch. A
        // diagnosis that reads the DOM at `load` cannot tell "never ran" from
        // "has not answered yet", and those are different faults.
        let _ = c.wait_for(&sess, "document.readyState === 'complete'", 15_000);
        std::thread::sleep(std::time::Duration::from_millis(1500));

        println!("url          {url}");
        for (name, expr) in [
            ("readyState  ", "document.readyState"),
            ("title       ", "document.title"),
            ("#msg        ", "document.getElementById('msg')?.textContent ?? '(no #msg)'"),
            ("#enter text ", "document.getElementById('enter')?.textContent?.trim() ?? '(no #enter)'"),
            ("#enter off  ", "String(document.getElementById('enter')?.disabled ?? '(no #enter)')"),
            ("scripts     ", "String(document.scripts.length)"),
            ("marks       ", "[...document.images].map(i=>i.currentSrc.split('/').pop()+':'+(i.complete&&i.naturalWidth>0)).join(' ')"),
        ] {
            match c.eval_str(&sess, expr) {
                Ok(v) => println!("{name} {v}"),
                Err(e) => println!("{name} ERR {e}"),
            }
        }
        if let Some(ask) = arg(args, "--ask") {
            match c.eval_str(&sess, &ask) {
                Ok(v) => println!("ask          {v}"),
                Err(e) => println!("ask          ERR {e}"),
            }
        }
        let errs = c.drain_page_errors();
        if errs.is_empty() {
            println!("page errors  none");
        } else {
            println!("page errors  {}", errs.len());
            for e in &errs {
                println!("  {e}");
            }
        }
        Ok(if errs.is_empty() { 0 } else { 1 })
    })();
    chrome.kill();
    match code {
        Ok(c) => c,
        Err(e) => {
            eprintln!("cdp: {e}");
            2
        }
    }
}