//! `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
}
}
}