nordisk/gunnar-ui
uitest/src/matrix.rs
//! The situations.
//!
//! ## The order is load-bearing
//!
//! Every API row runs BEFORE the first passkey exists, because the console
//! retires the break-glass admin token the moment an admin passkey is
//! registered ([`auth::Webauthn::admin_token_usable`]). That retirement is not
//! an obstacle to work around — it is situation `admin-token-retires`, and the
//! rows above it are what prove the token worked right up until then. Reordering
//! them turns a real guarantee into a red row.
//!
//! ## Every row is checked against the server, not against the page
//!
//! A console that renders a repository it invented is a bug the console itself
//! cannot see, so the truth for a repository is `gunnar-cli control`, the truth
//! for a commit is `git rev-parse` in the working tree that pushed it, and the
//! truth for a passkey is `WebAuthn.getCredentials` — what the authenticator is
//! actually holding, not what the page said happened.
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use crate::cdp::{Cdp, Chrome};
use crate::http;
use crate::json::{esc, Json};
use crate::rig::Rig;
/// "The login ceremony has finished, one way or the other."
///
/// It has to be asked in this order. A successful login NAVIGATES, so the
/// login page's `#msg` element is gone by the time the answer is known — and
/// `document.getElementById('msg').classList` then throws before a trailing
/// `|| location.pathname !== '/login'` is ever reached. Asking the server
/// first, and reaching for the element only with `?.`, is what makes the
/// success case and the failure case both answerable.
const SETTLED: &str = "fetch('/auth/status').then(r => r.json()).then(j => \
j.authenticated === true || !!document.getElementById('msg')?.classList.contains('err'))";
pub struct Row {
pub name: &'static str,
pub ok: bool,
pub ms: u128,
pub detail: String,
}
/// Print each row's detail WHOLE rather than cut to a terminal width.
///
/// The rows here carry measurements — five pairs of pixels, four computed
/// colours, a length beside the length it was compared against — and a
/// hundred-and-fifty-character cut drops the end of exactly the ones worth
/// reading. The default stays narrow because twenty rows of that is a wall;
/// `--full` is for when a row is the answer to the question being asked.
pub static FULL: AtomicBool = AtomicBool::new(false);
/// `--only <glob>[,<glob>…]`: the situations to run. Unset: every one (the
/// default, and the only run that may call the console green).
pub static ONLY: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
/// Rows run as another row's SETUP under `--only`, and rows skipped by it.
pub static SETUP_RAN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub static SKIPPED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// **The rows other rows stand on.** Under `--only` these run whether or not they
/// are selected — each makes state that every later browser row reads, inside its
/// own closure: the operator `bob` and his first invite, the authenticator, bob's
/// admin passkey and his logged-in session (`sess`), the invite and kim's isolated
/// browser, passkey and session (`sess2`, the `person` of places/addusers). Every OTHER unselected row is skipped. A
/// selected row that reads what a skipped row made (an API row's pull request, a
/// paid month before its prolong) fails by name: select that row too (a glob or a
/// list), and confirm any verdict with the full matrix.
pub const SETUP: &[&str] = &[
// the operator `bob`: his namespace conjured by a first push, then claimed by
// the installer's word with the break-glass token as his first invite — the
// invite his passkey registers with
"a-first-push-conjures-the-operators-namespace-first",
"the-installer-claims-the-operator-and-his-first-invite",
"chrome-starts-headless",
"devtools-protocol-attaches",
"a-virtual-authenticator-is-installed",
// it opens /login, the page the registration row fills in
"the-login-page-offers-a-passkey",
"a-passkey-registers-with-the-admin-token",
"the-passkey-logs-in",
"an-admin-mints-an-invite-link",
"the-invited-user-gets-their-own-browser",
"the-invite-link-registers-a-second-passkey",
"the-second-passkey-logs-in-as-a-viewer",
];
/// `*` any run of characters, `?` one character, everything else itself. Pure.
pub fn glob(pat: &str, name: &str) -> bool {
let (p, n): (Vec<char>, Vec<char>) = (pat.chars().collect(), name.chars().collect());
let (mut pi, mut ni, mut star, mut mark) = (0usize, 0usize, None::<usize>, 0usize);
while ni < n.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
pi += 1;
ni += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
mark = ni;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
mark += 1;
ni = mark;
} else {
return false;
}
}
p[pi..].iter().all(|c| *c == '*')
}
/// Is `name` selected by `--only` (always, without it)?
pub fn selected(name: &str) -> bool {
ONLY.get().is_none_or(|pats| pats.iter().any(|p| glob(p, name)))
}
pub(crate) fn go(rows: &mut Vec<Row>, name: &'static str, f: impl FnOnce() -> Result<String, String>) -> bool {
let setup = !selected(name);
if setup && !SETUP.contains(&name) {
// Skipped by --only: the rows after it go on as if it had passed; one that
// needed its state says so by failing.
SKIPPED.fetch_add(1, Ordering::Relaxed);
return true;
}
let t = Instant::now();
let (ok, detail) = match f() {
Ok(d) => (true, d),
Err(e) => (false, e),
};
let ms = t.elapsed().as_millis();
let shown = if FULL.load(Ordering::Relaxed) { detail.clone() } else { http::cut(&detail, 150) };
println!(
"{:<4} {:<44} {:>6} ms {}",
match (setup, ok) {
(false, true) => "PASS",
(false, false) => "FAIL",
(true, true) => "setup",
(true, false) => "SETUP-FAIL",
},
name,
ms,
shown
);
if setup {
SETUP_RAN.fetch_add(1, Ordering::Relaxed);
// A setup row that passed is not a situation of this run; one that failed
// makes the run red (nothing it stands under can be judged).
if !ok {
rows.push(Row { name, ok, ms, detail });
}
} else {
rows.push(Row { name, ok, ms, detail });
}
ok
}
pub(crate) fn want(cond: bool, msg: impl FnOnce() -> String) -> Result<(), String> {
if cond {
Ok(())
} else {
Err(msg())
}
}
/// The fixture: a repository with two commits, pushed by stock git over the
/// rig's SSH, which is what every read-side row is compared against.
pub struct Fixture {
pub head: String,
pub prev: String,
pub lib_oid: String,
}
fn fixture(r: &Rig) -> Result<Fixture, String> {
let wt = r.work.join("wt");
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
std::fs::create_dir_all(wt.join("src")).map_err(|e| e.to_string())?;
std::fs::write(wt.join("README"), "gunnar uitest\n").map_err(|e| e.to_string())?;
std::fs::write(wt.join("src/lib.rs"), "pub fn hello() {}\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "README", "src/lib.rs"])?;
r.git(&wt, &["commit", "-qm", "first commit"])?;
std::fs::write(wt.join("README"), "gunnar uitest\nsecond line\n").map_err(|e| e.to_string())?;
std::fs::write(wt.join("src/second.rs"), "pub fn second() {}\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "README", "src/second.rs"])?;
r.git(&wt, &["commit", "-qm", "second commit"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/uitest"), "main"])?;
Ok(Fixture {
head: r.git(&wt, &["rev-parse", "HEAD"])?.trim().to_owned(),
prev: r.git(&wt, &["rev-parse", "HEAD~1"])?.trim().to_owned(),
lib_oid: String::new(),
})
}
pub struct Api<'a> {
r: &'a Rig,
}
impl Api<'_> {
fn get(&self, path: &str) -> Result<(u16, Json), String> {
let h = [("Authorization", format!("Bearer {}", self.r.token))];
let resp = http::req(&self.r.ui_addr(), "GET", path, &h, None)?;
let body = resp.json().unwrap_or(Json::Null);
Ok((resp.status, body))
}
fn get_raw(&self, path: &str) -> Result<(u16, String), String> {
let h = [("Authorization", format!("Bearer {}", self.r.token))];
let resp = http::req(&self.r.ui_addr(), "GET", path, &h, None)?;
Ok((resp.status, resp.body))
}
fn anon(&self, path: &str) -> Result<(u16, String), String> {
let resp = http::req(&self.r.ui_addr(), "GET", path, &[], None)?;
Ok((resp.status, resp.body))
}
fn post(&self, path: &str, body: &str) -> Result<(u16, Json, String), String> {
let h = [
("Authorization", format!("Bearer {}", self.r.token)),
("Content-Type", "application/json".to_owned()),
];
let resp = http::req(&self.r.ui_addr(), "POST", path, &h, Some(body))?;
let j = resp.json().unwrap_or(Json::Null);
Ok((resp.status, j, resp.body))
}
fn delete(&self, path: &str) -> Result<(u16, String), String> {
let h = [("Authorization", format!("Bearer {}", self.r.token))];
let resp = http::req(&self.r.ui_addr(), "DELETE", path, &h, None)?;
Ok((resp.status, resp.body))
}
}
/// The oid at `refs/heads/main`, read off the server's own ref advertisement
/// over git — the same witness the force-push rows trust, and a stronger one
/// than the console's own answer that a merge did or did not move the branch.
/// **A POST the way the web's `api::call` sends it** (GHUNT-19): a 403
/// `"code":"stepup"` is answered with a fresh touch of the session's own key
/// (`/auth/passkey/step-up`, the virtual authenticator), then the same call once
/// more. Evaluates to the final HTTP status. `body` is a JS expression.
fn stepped_post(url: &str, body: &str) -> String {
format!(
"(async () => {{
const b2u = s => {{ s = s.replace(/-/g,'+').replace(/_/g,'/'); while (s.length % 4) s += '=';
return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; }};
const u2b = b => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
const send = () => fetch({url}, {{method:'POST', headers:{{'Content-Type':'application/json'}}, body: {body}}});
let r = await send();
if (r.status === 403) {{
const j = await r.clone().json().catch(() => ({{}}));
if (j.code === 'stepup') {{
const o = await (await fetch('/auth/passkey/step-up')).json();
const pk = o.publicKey;
pk.challenge = b2u(pk.challenge);
pk.allowCredentials = (pk.allowCredentials || []).map(c => ({{ ...c, id: b2u(c.id) }}));
const a = await navigator.credentials.get({{ publicKey: pk }});
const s = await fetch('/auth/passkey/step-up', {{method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{ ceremony_id: o.ceremony_id, id: u2b(a.rawId), response: {{
clientDataJSON: u2b(a.response.clientDataJSON), authenticatorData: u2b(a.response.authenticatorData),
signature: u2b(a.response.signature) }} }})}});
if (!s.ok) return s.status;
r = await send();
}}
}}
return r.status;
}})()",
url = esc(url),
)
}
fn server_main(r: &Rig, repo: &str) -> Result<String, String> {
let out = r.git(&r.work, &["ls-remote", &r.remote(repo), "refs/heads/main"])?;
Ok(out.split_whitespace().next().unwrap_or("").to_owned())
}
/// The byte string a number renders to on the page, for an EXACT cross-check
/// against what the console draws. It mirrors the console's own `fmt::bytes`
/// (crates/gunnar-ui-web/src/fmt.rs) — a dozen lines the test cannot call
/// across the wasm boundary, kept identical on purpose so a row that asserts a
/// rendered byte figure is checking the server's number, not the page's claim.
fn fmt_bytes(count: u64) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
if count < 1024 {
return format!("{count} B");
}
let mut value = count as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
format!("{value:.1} {}", UNITS[unit])
}
/// [`fmt_bytes`] read back: `714.6 GiB` → bytes (to the rounding of its one decimal).
fn parse_bytes(shown: &str) -> Option<u64> {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
let (value, unit) = shown.trim().split_once(' ')?;
let power = UNITS.iter().position(|u| *u == unit.trim())?;
let value: f64 = value.parse().ok()?;
Some((value * 1024f64.powi(power as i32)) as u64)
}
/// One `name <number>` field out of a `gunnar-cli control usage` line.
///
/// The line is tab separated and each byte figure carries the raw count first
/// and the rounded form in brackets — `pack 7340032 (7.0 MiB)` — because the
/// raw number is the one a later reader can diff or compare. That is the one
/// taken here: comparing rounded strings would let a console that lost a field
/// agree by accident whenever two meters rounded the same way.
/// **The usage page has no money in it, and this is the check.** Every word a
/// bill would use, as a substring of the lower-cased page text, so a tooltip
/// or a footnote is caught as surely as a heading. Extended 2026-09-05 with
/// `price`, `pay`, `credit` and `top up` when the page gained the allowance —
/// the one place a limit could have grown a sales pitch.
fn sweep_for_money(seen: &str) -> Result<(), String> {
let lower = seen.to_lowercase();
for word in [
"plan", "billing", "invoice", "quota", "upgrade", "subscription", "price", "pay", "credit", "top up",
] {
want(!lower.contains(word), || {
format!("the usage page says {word:?}, which it must not: {}", http::cut(seen, 300))
})?;
}
Ok(())
}
fn field(line: &str, name: &str) -> Option<u64> {
line.split('\t')
.map(str::trim)
.find_map(|piece| piece.strip_prefix(name)?.trim_start().split_whitespace().next()?.parse().ok())
}
/// Does an array of `{items:[…]}` hold an object whose `field` equals `value`?
fn items_have(j: &Json, field: &str, value: &str) -> bool {
j.path("items")
.map(|i| i.arr().iter().any(|e| e.text(field) == value))
.unwrap_or(false)
}
pub fn run(r: &Rig, chrome_bin: Option<&str>, authenticator: bool) -> Vec<Row> {
let mut rows: Vec<Row> = Vec::new();
let a = Api { r };
// The inline-comment fixture: a pull request and the blob its comment
// anchors to, set by the API rows and read by the browser rows below.
let inline_pull = std::cell::RefCell::new(String::new());
let inline_blob = std::cell::RefCell::new(String::new());
let fx = match fixture(r) {
Ok(f) => f,
Err(e) => {
go(&mut rows, "fixture-pushes-two-commits", || Err(e));
return rows;
}
};
go(&mut rows, "fixture-pushes-two-commits", || {
Ok(format!("head {}", &fx.head[..12]))
});
// ── the console answers for itself ────────────────────────────────────
go(&mut rows, "health-is-plain-ok", || {
let (s, b) = a.get_raw("/health")?;
want(s == 200 && b.trim() == "ok", || format!("{s} {b:?}"))?;
Ok("200 ok".into())
});
go(&mut rows, "api-refuses-without-a-credential", || {
// The red that makes every green below mean something. `/api/repos`
// is deliberately NOT in this list: since the rig has a guest
// identity (2026-09-10) a stranger is answered there with the public
// subset, as on gunnar.rs, and what that answer must not contain is
// asserted where it matters (`a-public-repository-opens-the-anonymous-door`,
// `logging-out-ends-the-session`). These three are on no allow-list
// and must be 401 to a stranger whatever the guest can see.
let mut seen = Vec::new();
for p in ["/api/storage", "/auth/passkey/invites", "/api/principals"] {
let (s, b) = a.anon(p)?;
want(s == 401, || format!("{p} answered {s}, not 401: {}", http::cut(&b, 80)))?;
seen.push(p);
}
Ok(format!("401 on {}", seen.len()))
});
go(&mut rows, "api-accepts-the-admin-token", || {
let (s, j) = a.get("/api/repos")?;
want(s == 200, || format!("status {s}"))?;
want(j.path("items").is_some(), || format!("no items in {}", j.dump()))?;
Ok(format!("{} repos", j.path("total").and_then(|t| t.as_f64()).unwrap_or(-1.0)))
});
go(&mut rows, "version-names-a-real-build", || {
let (s, j) = a.get("/api/version")?;
want(s == 200, || format!("status {s}"))?;
let r_ref = j.text("build_ref");
want(!r_ref.is_empty(), || format!("no build_ref in {}", j.dump()))?;
want(j.text("version").contains(&r_ref), || {
format!("the version line {:?} does not carry the git hash {r_ref}", j.text("version"))
})?;
Ok(j.text("version"))
});
go(&mut rows, "health-names-the-server-it-talks-to", || {
let (s, j) = a.get("/api/health")?;
want(s == 200, || format!("status {s}"))?;
want(j.path("serving").and_then(|v| v.as_bool()) == Some(true), || "not serving".into())?;
want(j.text("grpc_bind") == r.at(), || {
format!("console reports grpc_bind {:?}, the rig started {}", j.text("grpc_bind"), r.at())
})?;
Ok(format!("{} on {}", j.text("agent"), j.text("grpc_bind")))
});
go(&mut rows, "bootstrap-is-closed-and-names-its-claimer", || {
let (s, j) = a.get("/api/bootstrap")?;
want(s == 200, || format!("status {s}"))?;
want(j.path("bootstrapped").and_then(|v| v.as_bool()) == Some(true), || "not bootstrapped".into())?;
want(j.path("window_open").and_then(|v| v.as_bool()) == Some(false), || {
"the enrolment window is still open".into()
})?;
want(j.text("bootstrapped_principal") == "alice", || {
format!("claimed by {:?}", j.text("bootstrapped_principal"))
})?;
Ok(j.text("bootstrapped_principal"))
});
// ── what was pushed is what is shown ──────────────────────────────────
go(&mut rows, "a-pushed-repo-appears-in-the-console", || {
let (_, j) = a.get("/api/repos")?;
want(items_have(&j, "path", "alice/uitest"), || {
format!("alice/uitest is not in {}", http::cut(&j.dump(), 200))
})?;
// and the console is not inventing it: the server says so too.
let listed = r.control(&["repo", "list"])?;
want(listed.contains("uitest"), || format!("gunnar itself does not list it: {}", http::cut(&listed, 200)))?;
Ok("in the console and in gunnar".into())
});
go(&mut rows, "refs-carry-the-oid-git-pushed", || {
let (s, j) = a.get("/api/repos/alice/uitest/refs")?;
want(s == 200, || format!("status {s}"))?;
let main = j
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("ref_name") == "refs/heads/main").cloned())
.ok_or_else(|| format!("no refs/heads/main in {}", http::cut(&j.dump(), 200)))?;
want(main.text("oid") == fx.head, || {
format!("console says {}, git says {}", main.text("oid"), fx.head)
})?;
Ok(format!("main = {}", &fx.head[..12]))
});
go(&mut rows, "head-is-symbolic-to-main", || {
let (_, j) = a.get("/api/repos/alice/uitest/head")?;
want(j.text("symbolic_target") == "refs/heads/main", || format!("{}", j.dump()))?;
want(j.text("oid") == fx.head, || format!("oid {}", j.text("oid")))?;
Ok("HEAD → refs/heads/main".into())
});
go(&mut rows, "the-log-is-the-history-git-made", || {
let (_, j) = a.get("/api/repos/alice/uitest/log")?;
let items = j.path("items").map(|i| i.arr().to_vec()).unwrap_or_default();
want(items.len() >= 2, || format!("{} commits", items.len()))?;
want(items[0].text("oid") == fx.head, || format!("first is {}", items[0].text("oid")))?;
want(items[0].text("message").starts_with("second commit"), || {
format!("subject {:?}", items[0].text("message"))
})?;
want(items[1].text("oid") == fx.prev, || format!("second is {}", items[1].text("oid")))?;
want(items[0].path("parent_oids").map(|p| p.arr().iter().any(|o| o.as_str() == Some(fx.prev.as_str()))) == Some(true), || {
"the head commit does not name its parent".into()
})?;
Ok(format!("{} commits, newest first", items.len()))
});
go(&mut rows, "browsing-the-root-lists-what-was-pushed", || {
let (_, j) = a.get("/api/repos/alice/uitest/browse")?;
let names: Vec<String> = j.path("entries").map(|e| e.arr().iter().map(|x| x.text("name")).collect()).unwrap_or_default();
want(names.iter().any(|n| n == "README"), || format!("entries {names:?}"))?;
want(names.iter().any(|n| n == "src"), || format!("entries {names:?}"))?;
want(j.text("revision.subject") == "second commit", || {
format!("revision subject {:?}", j.text("revision.subject"))
})?;
Ok(format!("{names:?}"))
});
let lib_oid = std::cell::RefCell::new(String::new());
go(&mut rows, "browsing-a-subdirectory-descends", || {
let (_, j) = a.get("/api/repos/alice/uitest/browse?path=src")?;
let entries = j.path("entries").map(|e| e.arr().to_vec()).unwrap_or_default();
let lib = entries
.iter()
.find(|e| e.text("name") == "lib.rs")
.ok_or_else(|| format!("no lib.rs in {:?}", entries.iter().map(|e| e.text("name")).collect::<Vec<_>>()))?;
want(lib.text("kind") == "file", || format!("lib.rs is a {}", lib.text("kind")))?;
*lib_oid.borrow_mut() = lib.text("oid");
want(entries.iter().any(|e| e.text("name") == "second.rs"), || "no second.rs".into())?;
Ok(format!("{} entries", entries.len()))
});
go(&mut rows, "an-object-serves-the-bytes-that-were-committed", || {
let oid = lib_oid.borrow().clone();
want(!oid.is_empty(), || "the browse row did not find lib.rs".into())?;
let (s, j) = a.get(&format!("/api/repos/alice/uitest/object?oid={oid}"))?;
want(s == 200, || format!("status {s}"))?;
let text = j.text("text");
let text = if text.is_empty() { j.text("content") } else { text };
want(text.contains("pub fn hello()"), || {
format!("the blob does not hold what git committed: {}", http::cut(&j.dump(), 200))
})?;
Ok(format!("{} bytes", j.path("size").and_then(|s| s.as_f64()).unwrap_or(-1.0)))
});
// ── a README's relative link, and the bytes at the end of it ──────────
go(&mut rows, "a-readmes-relative-link-opens-the-file-it-names", || {
// The defect measured on gunnar.rs 2026-09-11: the repository's README
// links to its own PDF, the console drew the words and no link, and
// there was no path-addressed route for a file to be handed to a
// browser at all. `readme::links` rewrites the target and
// `api::browse::raw` serves the bytes, and both have unit tests — but
// nothing joined them to a README that a git client actually pushed.
// This is the join: one repository, one relative link, and the file at
// the other end of the href the server itself wrote.
let wt = r.work.join("wt-readme");
std::fs::create_dir_all(wt.join("docs")).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@vetra.se"])?;
// A PDF that is a real one as far as anything here is concerned: the
// route decides the content type from the NAME and never sniffs, and
// the assertion below is on the bytes, so a header is all it needs.
let pdf = "%PDF-1.4\n% uitest\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n";
std::fs::write(wt.join("docs/guide.pdf"), pdf).map_err(|e| e.to_string())?;
std::fs::write(
wt.join("README.md"),
"# links\n\nRead [the guide](docs/guide.pdf), or the [notes](docs/notes.txt).\n",
)
.map_err(|e| e.to_string())?;
std::fs::write(wt.join("docs/notes.txt"), "notes\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "README.md", "docs/guide.pdf", "docs/notes.txt"])?;
r.git(&wt, &["commit", "-qm", "a readme with relative links"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/readme-links"), "main"])?;
let (s, j) = a.get("/api/repos/alice/readme-links/readme")?;
want(s == 200, || format!("the readme route answered {s}: {}", http::cut(&j.dump(), 200)))?;
let body = j.text("body");
want(!body.is_empty(), || format!("the readme rendered nothing: {}", http::cut(&j.dump(), 250)))?;
// The PDF goes to the BYTES, and the text file to the console's own
// file view — the two halves of the table in `readme::links`, so a
// rewrite that sent everything to one of them could not pass.
let raw = "/api/repos/alice/readme-links/raw?path=docs/guide.pdf";
want(body.contains(raw), || {
format!("the README's link to its PDF is not {raw:?}: {}", http::cut(&body, 300))
})?;
// GUN-GUEST (2026-10-30): the console's one address scheme, `/{ns}/{name}/tree/{path}`.
want(body.contains("/alice/readme-links/tree/docs/notes.txt"), || {
format!("the link to a text file does not open the file view: {}", http::cut(&body, 300))
})?;
// And the href the server wrote actually serves the file, inline, as a
// PDF. Asserted on the applied output — the bytes and the header a
// browser decides with — because a route that answered 200 with the
// console's index.html would satisfy a status check.
let h = [("Authorization", format!("Bearer {}", r.token))];
let resp = http::req(&r.ui_addr(), "GET", raw, &h, None)?;
want(resp.status == 200, || {
format!("the raw route answered {}: {}", resp.status, http::cut(&resp.body, 200))
})?;
want(resp.header("content-type") == Some("application/pdf"), || {
format!("a PDF is served as {:?}", resp.header("content-type"))
})?;
want(resp.body.starts_with("%PDF-1.4"), || {
format!("the bytes are not the file that was pushed: {}", http::cut(&resp.body, 120))
})?;
want(resp.body.len() == pdf.len(), || {
format!("{} bytes came back for a {}-byte file", resp.body.len(), pdf.len())
})?;
Ok(format!(
"the README's relative link resolves to {raw} and serves {} bytes of application/pdf",
resp.body.len()
))
});
go(&mut rows, "a-diff-between-two-commits-is-the-edit", || {
let (s, j) = a.get(&format!(
"/api/repos/alice/uitest/diff?from={}&to={}",
fx.prev, fx.head
))?;
want(s == 200, || format!("status {s}: {}", http::cut(&j.dump(), 150)))?;
let d = j.dump();
want(d.contains("second.rs"), || format!("the added file is not in the diff: {}", http::cut(&d, 250)))?;
want(d.contains("README"), || format!("the edited file is not in the diff: {}", http::cut(&d, 250)))?;
Ok("README edited, second.rs added".into())
});
go(&mut rows, "storage-accounts-for-the-repository", || {
let (_, j) = a.get("/api/storage")?;
let repo = j
.path("repos")
.and_then(|r| r.arr().iter().find(|e| e.text("repo") == "alice/uitest").cloned())
.ok_or_else(|| format!("alice/uitest is not accounted for: {}", http::cut(&j.dump(), 200)))?;
want(repo.text("hash_kind") == "sha256", || format!("hash {}", repo.text("hash_kind")))?;
want(repo.path("ref_count").and_then(|c| c.as_f64()).unwrap_or(0.0) >= 1.0, || "no refs counted".into())?;
Ok(format!("{} backend, {} refs", http::cut(&j.text("object_backend"), 40), repo.path("ref_count").and_then(|c| c.as_f64()).unwrap_or(0.0)))
});
go(&mut rows, "stats-count-what-exists", || {
let (_, j) = a.get("/api/stats")?;
let repos = j.path("repos").and_then(|v| v.as_f64()).unwrap_or(0.0);
want(repos >= 1.0, || format!("repos {repos}"))?;
want(j.path("principals").and_then(|v| v.as_f64()).unwrap_or(0.0) >= 2.0, || "principals".into())?;
Ok(format!("{repos} repos"))
});
go(&mut rows, "stats-carry-the-index-progress-rows", || {
// The field the console's Indexing panel is drawn from. It is `[]` on
// an idle box, and the ASSERTION IS THAT IT IS AN ARRAY AT ALL: the
// console tolerates its absence (`serde(default)`, for appliances older
// than 2026-08-30), which means a server that stopped sending it would
// cost the operator the panel and nothing would say so.
let (_, j) = a.get("/api/stats")?;
let index = j.path("index").ok_or_else(|| {
format!("/api/stats has no `index` field at all: {}", http::cut(&j.dump(), 200))
})?;
want(matches!(index, Json::Arr(_)), || {
format!("`index` is not an array: {}", index.dump())
})?;
Ok(format!("{} repositories indexing", index.arr().len()))
});
go(&mut rows, "stats-carry-the-replication-standbys", || {
let (_, j) = a.get("/api/stats")?;
want(j.path("replication_configured").and_then(|v| v.as_bool()).is_some(), || {
format!("/api/stats does not report whether replication is configured: {}", http::cut(&j.dump(), 200))
})?;
let standbys = j.path("standbys").ok_or("no standbys field")?;
want(matches!(standbys, Json::Arr(_)), || "standbys is not an array".into())?;
// Empty with `configured` false is nothing set up. Empty with it TRUE
// would be a bug, and gunnar's own proto says so — assert the pair,
// not either half.
let configured = j.path("replication_configured").and_then(|v| v.as_bool()) == Some(true);
want(!(configured && standbys.arr().is_empty()), || {
"replication reports itself configured with no standby at all".into()
})?;
Ok(format!("configured={configured}, {} standby(s)", standbys.arr().len()))
});
go(&mut rows, "principals-keys-and-grants-agree-with-gunnar", || {
let (_, p) = a.get("/api/principals")?;
want(items_have(&p, "id", "alice"), || format!("no alice: {}", http::cut(&p.dump(), 150)))?;
let (_, g) = a.get("/api/grants")?;
let admin = g
.path("items")
.map(|i| i.arr().iter().any(|e| e.text("principal_id") == "alice" && e.text("level") == "admin"))
.unwrap_or(false);
want(admin, || format!("alice is not a server admin: {}", http::cut(&g.dump(), 200)))?;
let (_, k) = a.get("/api/keys")?;
want(k.path("items").map(|i| !i.arr().is_empty()).unwrap_or(false), || "no keys".into())?;
Ok("alice is admin, keys listed".into())
});
// ── the console can change things, and gunnar sees the change ─────────
go(&mut rows, "the-console-creates-a-repository-gunnar-then-has", || {
let (s, j, body) = a.post("/api/repos", r#"{"namespace":"alice","name":"made-by-console"}"#)?;
want(s == 200, || format!("status {s}: {}", http::cut(&body, 150)))?;
want(j.text("path") == "alice/made-by-console", || format!("{}", j.dump()))?;
let listed = r.control(&["repo", "list"])?;
want(listed.contains("made-by-console"), || {
format!("gunnar does not have it: {}", http::cut(&listed, 200))
})?;
Ok("created and confirmed by gunnar".into())
});
go(&mut rows, "creating-the-same-repository-twice-is-refused", || {
let (s, _, body) = a.post("/api/repos", r#"{"namespace":"alice","name":"made-by-console"}"#)?;
want(s != 200, || "the second create succeeded".into())?;
Ok(format!("{s} {}", http::cut(&body, 90)))
});
go(&mut rows, "an-unknown-repository-does-not-confirm-it-is-unknown", || {
// The account model (owner, 2026-10-03): a repository the reader
// cannot see — absent, or private and not theirs — is ONE answer, the
// uniform 404 "this page does not exist", byte for byte what a
// namespace that does not exist answers. A 403 here would confirm the
// name; so would a body that names it.
let (s, b) = a.get_raw("/api/repos/alice/no-such-repo")?;
want(s == 404, || format!("status {s}, not the uniform 404: {}", http::cut(&b, 120)))?;
let (s2, b2) = a.get_raw("/api/repos/no-such-namespace/no-such-repo")?;
want((s, b.trim()) == (s2, b2.trim()), || {
format!("an absent repository ({s} {}) and an absent namespace ({s2} {}) answer differently", http::cut(&b, 100), http::cut(&b2, 100))
})?;
want(!b.contains("no-such-repo") && !b.contains("no such") && !b.contains("not found"), || {
format!("the refusal says what is missing: {}", http::cut(&b, 120))
})?;
Ok(format!("404 {}, the same as for an absent namespace", http::cut(&b, 80)))
});
go(&mut rows, "the-console-deletes-a-repository-gunnar-then-lacks", || {
let (s, b) = a.delete("/api/repos/alice/made-by-console")?;
want((200..300).contains(&s), || format!("status {s}: {}", http::cut(&b, 120)))?;
let (_, j) = a.get("/api/repos")?;
want(!items_have(&j, "path", "alice/made-by-console"), || "still listed by the console".into())?;
let listed = r.control(&["repo", "list"])?;
want(!listed.contains("made-by-console"), || format!("gunnar still has it: {}", http::cut(&listed, 200)))?;
Ok("gone from both".into())
});
go(&mut rows, "a-deleted-repository-is-still-charged-for", || {
// The console's delete is SOFT (purge: false, the server's retention
// window), so the bytes do not go away — and a customer who deletes a
// repository and sees no change in their usage is looking at that
// window, not at a bug. This is the situation that makes the window
// visible rather than a surprise.
let listed = r.control(&["repo", "list-deleted"])?;
want(listed.contains("made-by-console"), || {
format!("gunnar does not have it tombstoned: {}", http::cut(&listed, 200))
})?;
let (_, j) = a.get("/api/storage")?;
let held = j.path("total_tombstoned_bytes").and_then(|v| v.as_f64()).ok_or_else(|| {
format!("/api/storage does not report tombstoned bytes at all: {}", http::cut(&j.dump(), 200))
})?;
want(held > 0.0, || "a tombstoned repository is holding zero bytes".into())?;
let named = j
.path("tombstoned")
.map(|t| t.arr().iter().any(|e| e.text("repo") == "alice/made-by-console"))
.unwrap_or(false);
want(named, || {
format!("the bytes are reported but not which repository holds them: {}", http::cut(&j.dump(), 250))
})?;
// And they are NOT double-counted into the live totals.
let live = j
.path("repos")
.map(|rs| rs.arr().iter().any(|e| e.text("repo") == "alice/made-by-console"))
.unwrap_or(false);
want(!live, || "a tombstoned repository is still listed as a live one".into())?;
Ok(format!("{held:.0} bytes held, named, and not double-counted"))
});
// ── the bytes nobody could see, and the undo nobody could reach ──────
//
// Three RPCs the console listed as "not surfaced" until 2026-08-31, and
// every one of them is about storage an account is being charged for.
go(&mut rows, "the-deleted-list-names-what-was-deleted", || {
// `made-by-console` was soft-deleted by the situation above.
let (s, j) = a.get("/api/repos/deleted")?;
want(s == 200, || format!("status {s}"))?;
let row = j
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("path") == "alice/made-by-console").cloned())
.ok_or_else(|| {
format!("the deleted list does not name it: {}", http::cut(&j.dump(), 250))
})?;
want(row.path("bytes").and_then(|b| b.as_f64()).unwrap_or(0.0) > 0.0, || {
"a tombstoned repository is listed as holding zero bytes".into()
})?;
want(row.path("deleted_at_unix_ms").and_then(|d| d.as_f64()).unwrap_or(0.0) > 0.0, || {
"the row carries no delete time".into()
})?;
// The red half: a LIVE repository must not be in this list, or the
// list is just the catalogue with a different name.
want(!items_have(&j, "path", "alice/uitest"), || {
"a live repository is in the deleted list".into()
})?;
Ok(format!("{} deleted, named, with bytes", j.path("items").map(|i| i.arr().len()).unwrap_or(0)))
});
go(&mut rows, "undo-restores-a-deleted-repository-and-its-objects", || {
// A repository with REAL CONTENT, because the thing worth proving is
// not that a 200 came back: it is that the objects are readable again
// afterwards. A metadata row can be restored over a store that was
// reclaimed, and that is exactly the failure this asserts against.
let wt = r.work.join("undo-wt");
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(wt.join("KEEP"), "bytes worth an undo\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "KEEP"])?;
r.git(&wt, &["commit", "-qm", "the commit the undo has to bring back"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/undo-me"), "main"])?;
let head = r.git(&wt, &["rev-parse", "HEAD"])?.trim().to_owned();
let (_, before) = a.get("/api/repos/alice/undo-me/refs")?;
let listed = before
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("ref_name") == "refs/heads/main").cloned())
.ok_or("the console cannot read the repository before it is deleted")?;
want(listed.text("oid") == head, || format!("console {} git {head}", listed.text("oid")))?;
let (s, body) = a.delete("/api/repos/alice/undo-me")?;
want((200..300).contains(&s), || format!("delete answered {s}: {}", http::cut(&body, 120)))?;
// RED: gone, and gone in the way that matters — the console cannot
// read a ref out of it any more. Without this the undo below could
// "restore" something that was never away.
let (gone, refused) = a.get_raw("/api/repos/alice/undo-me/refs")?;
want(gone != 200, || {
format!("a deleted repository still serves its refs: {}", http::cut(&refused, 150))
})?;
let (_, deleted) = a.get("/api/repos/deleted")?;
want(items_have(&deleted, "path", "alice/undo-me"), || {
format!("not in the deleted list: {}", http::cut(&deleted.dump(), 200))
})?;
let (s, _, body) = a.post("/api/repos/alice/undo-me/undelete", "{}")?;
want((200..300).contains(&s), || format!("undelete answered {s}: {}", http::cut(&body, 150)))?;
// …and the proof: the SAME commit git pushed is readable again, not
// merely a catalogue row with the right name.
let (s, after) = a.get("/api/repos/alice/undo-me/refs")?;
want(s == 200, || format!("refs answered {s} after the undo"))?;
let main = after
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("ref_name") == "refs/heads/main").cloned())
.ok_or_else(|| format!("no refs/heads/main after the undo: {}", http::cut(&after.dump(), 200)))?;
want(main.text("oid") == head, || {
format!("the restored branch is {} and git pushed {head}", main.text("oid"))
})?;
// The objects, not just the ref: read the commit back through the
// console the way a person browsing it would.
let (s, obj) = a.get(&format!("/api/repos/alice/undo-me/object?oid={head}"))?;
want(s == 200, || format!("the restored commit object answered {s}"))?;
want(obj.dump().contains("the commit the undo has to bring back"), || {
format!("the restored object is not the commit that was pushed: {}", http::cut(&obj.dump(), 200))
})?;
// …and gunnar itself agrees, on both sides of the ledger.
let live = r.control(&["repo", "list"])?;
want(live.contains("undo-me"), || format!("gunnar does not have it back: {}", http::cut(&live, 200)))?;
let (_, still) = a.get("/api/repos/deleted")?;
want(!items_have(&still, "path", "alice/undo-me"), || {
"restored, and still listed as deleted".into()
})?;
Ok(format!("deleted and restored at {}", &head[..12]))
});
go(&mut rows, "an-orphan-store-is-listed-once-one-exists", || {
// RED FIRST, and it is the half that makes the green mean anything: on
// a server where every directory is accounted for, the orphan list is
// EMPTY. A listing that always answers with something would pass the
// green below while reporting nothing real.
let (s, before) = a.get("/api/orphans")?;
want(s == 200, || format!("status {s}"))?;
let items = before.path("items").ok_or_else(|| {
format!("/api/orphans has no items array at all: {}", http::cut(&before.dump(), 200))
})?;
want(items.arr().is_empty(), || {
format!("a clean server reports orphans: {}", http::cut(&before.dump(), 250))
})?;
// An orphan is exactly this: a `<name>.git` directory under the
// repository root with no catalog row and no tombstone. Made by hand
// here for the same reason the appliance's was made by accident — no
// gunnar verb can produce one, which is the whole problem.
let stray = r.work.join("data/repos/alice/stray.git");
std::fs::create_dir_all(&stray).map_err(|e| e.to_string())?;
std::fs::write(stray.join("OBJECT_FORMAT"), "sha256\n").map_err(|e| e.to_string())?;
std::fs::write(stray.join("stranded.pack"), vec![7u8; 4096]).map_err(|e| e.to_string())?;
let (_, after) = a.get("/api/orphans")?;
let row = after
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("path") == "alice/stray").cloned())
.ok_or_else(|| {
format!("the orphan is invisible to the console: {}", http::cut(&after.dump(), 250))
})?;
want(row.path("bytes").and_then(|b| b.as_f64()).unwrap_or(0.0) >= 4096.0, || {
format!("the row does not carry the stranded bytes: {}", row.dump())
})?;
want(row.text("hash_marker") == "sha256", || {
format!("the row does not carry the marker `--adopt` needs: {}", row.dump())
})?;
want(row.text("dir").contains("stray.git"), || {
format!("the row does not say where it is: {}", row.dump())
})?;
// …and it is in NEITHER of the other two listings, which is what makes
// it an orphan rather than a repository or a tombstone.
let (_, repos) = a.get("/api/repos")?;
want(!items_have(&repos, "path", "alice/stray"), || "an orphan is in the catalogue".into())?;
let (_, deleted) = a.get("/api/repos/deleted")?;
want(!items_have(&deleted, "path", "alice/stray"), || "an orphan is in the deleted list".into())?;
// …and gunnar's own listing says the same thing.
let theirs = r.control(&["repo", "list-orphans"])?;
want(theirs.contains("stray"), || format!("gunnar does not list it: {}", http::cut(&theirs, 200)))?;
Ok(format!("{} bytes stranded, in no other listing", row.path("bytes").and_then(|b| b.as_f64()).unwrap_or(0.0)))
});
go(&mut rows, "the-standby-verdict-is-the-word-gunnar-decided", || {
// A standby at a port nothing is listening on: it has never taken a
// byte, and NEVER is its own verdict — never "stale", which is what an
// idle appliance was told it was all weekend.
//
// The lie itself (a HEALTHY standby with an old success reading STALE)
// needs fifty minutes of wall clock and a real second gunnar; it is
// pinned in gunnar's own suite instead
// (`replication_view::an_idle_standby_is_not_stale_and_a_stuck_one_still_is`,
// seen red there). What this row is for is the other half of that fix:
// the console and the CLI must print the SAME word, because two
// surfaces deciding it separately is how they came to disagree.
//
// A salvage ref is pushed first, so the ref-coverage numbers below are
// taken on a server that really has one. gunnar walks the refs once and
// reuses the count for a minute, so it has to exist before the
// replicator does.
let wt = r.work.join("wt");
r.git(&wt, &["push", "-q", &r.remote("alice/uitest"), "main:refs/superseded/wip-pre-reset"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/uitest"), "main:refs/salvage/main"])?;
r.control(&["replication", "add", "uitest-standby", "ssh://git@127.0.0.1:1"])?;
let (s, j) = a.get("/api/stats")?;
want(s == 200, || format!("status {s}"))?;
let row = j
.path("standbys")
.and_then(|s| s.arr().first().cloned())
.ok_or_else(|| format!("the console reports no standby: {}", http::cut(&j.dump(), 250)))?;
let verdict = row.text("verdict");
want(verdict == "NEVER", || {
format!("a standby that has never taken a byte reads {verdict:?}, not NEVER: {}", row.dump())
})?;
// …and gunnar's own CLI says the same word about the same standby.
let listed = r.control(&["replication", "list"])?;
want(listed.contains("NEVER"), || {
format!("the CLI does not say NEVER: {}", http::cut(&listed, 300))
})?;
want(!listed.contains("STALE"), || {
format!("the CLI calls a never-synced standby STALE as well: {}", http::cut(&listed, 300))
})?;
// ★ The refs no standby carries. Two were pushed above, both outside
// refs/heads and refs/tags, and the count must be EXACTLY two — a
// counter that answers "some" is what let 567 unmirrored refs sit
// behind a green row.
//
// Since the ref-coverage work this count is OPT-IN: walking it opens every
// repository's ref database, so a plain `replication list` /
// `Admin.Stats` peeks the last on-demand walk and reads ZERO until one
// is asked for. That zero is not "nothing unmirrored" — the
// console renders it "Gunnar did not report a ref count". So the honest
// shape is not-computed first, then exactly two once the walk is
// requested.
want(
j.path("replication_refs_total").and_then(|v| v.as_f64()) == Some(0.0),
|| {
format!(
"a plain /api/stats walked the refs instead of leaving the count \
opt-in: {}",
http::cut(&j.dump(), 200)
)
},
)?;
// The opt-in walk: `--refs` is the one flag that opens every ref
// database, and it warms the cache the polled surfaces peek. Its own
// summary carries the shortfall.
let refs = r.control(&["replication", "list", "--refs"])?;
want(refs.contains("NOT mirrored"), || {
format!("the CLI summary says nothing about unmirrored refs: {}", http::cut(&refs, 400))
})?;
// Now the console's card, fed by that same cached walk via `Admin.Stats`.
// Polled: the BFF holds `/api/stats` for a few seconds, so the real
// number appears once its cache turns over.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let (total, unmirrored) = loop {
let (_, s) = a.get("/api/stats")?;
let total = s.path("replication_refs_total").and_then(|v| v.as_f64()).unwrap_or(0.0);
let unmirrored =
s.path("replication_refs_unmirrored").and_then(|v| v.as_f64()).unwrap_or(0.0);
if unmirrored > 0.0 || std::time::Instant::now() >= deadline {
break (total, unmirrored);
}
std::thread::sleep(std::time::Duration::from_millis(250));
};
want(unmirrored == 2.0, || {
format!(
"{unmirrored} refs reported as unmirrored on the console card; exactly 2 were \
pushed outside refs/heads"
)
})?;
want(total > unmirrored, || {
format!("the total ({total}) does not exceed the shortfall ({unmirrored})")
})?;
r.control(&["replication", "remove", "uitest-standby"])?;
Ok(format!(
"NEVER on both surfaces; {unmirrored} of {total} refs unmirrored after the on-demand walk"
))
});
go(&mut rows, "storage-reports-the-read-cache-and-its-budget", || {
let (_, j) = a.get("/api/storage")?;
for key in ["total_explode_bytes", "total_explode_budget_bytes"] {
want(j.path(key).and_then(|v| v.as_f64()).is_some(), || {
format!("/api/storage has no {key}: {}", http::cut(&j.dump(), 200))
})?;
}
let repo = j
.path("repos")
.and_then(|rs| rs.arr().iter().find(|e| e.text("repo") == "alice/uitest").cloned())
.ok_or("alice/uitest is not in the storage rows")?;
// The cache is a KNOB, and the budget is the whole reason the disk
// plateau is a choice rather than a fact — so the row must carry it.
want(repo.path("explode_bytes").and_then(|v| v.as_f64()).is_some(), || {
format!("no per-repository explode_bytes: {}", http::cut(&repo.dump(), 200))
})?;
want(repo.path("explode_budget_bytes").and_then(|v| v.as_f64()).is_some(), || {
"no per-repository explode_budget_bytes".into()
})?;
Ok(format!(
"cache {} of {} server-wide",
j.path("total_explode_bytes").and_then(|v| v.as_f64()).unwrap_or(-1.0),
j.path("total_explode_budget_bytes").and_then(|v| v.as_f64()).unwrap_or(-1.0)
))
});
// ── the Machine card: the estate and the disks, as gunnar says ────────
//
// Owner, 2026-10-04: the console is a THIN layer. The card's picture is
// `Admin.Topology` drawn by the suite's one estate renderer, its lines are
// `StorageReality.glance` — so each row holds the console's answer to the
// SERVER's own (`gunnar control topology` / `control storage`), never to
// the page's claim.
go(&mut rows, "the-machine-card-reads-the-estate-from-gunnar", || {
let (s, j) = a.get("/api/machine/topology")?;
want(s == 200, || format!("status {s}: {}", http::cut(&j.dump(), 150)))?;
let boxes = j.path("boxes").map(|b| b.arr().to_vec()).unwrap_or_default();
want(!boxes.is_empty(), || format!("no boxes: {}", http::cut(&j.dump(), 200)))?;
let first = &boxes[0];
want(first.path("this_box").and_then(|v| v.as_bool()) == Some(true) && first.text("role") == "primary" && first.text("state") == "up", || {
format!("the first box is not this primary, up: {}", http::cut(&first.dump(), 200))
})?;
// The console asked, with its own machine key: it is on the picture it got.
want(boxes.iter().any(|b| b.text("name") == "uitest-console" && b.text("role") == "console" && b.text("state") == "up"), || {
format!("the console that asked is not a box: {}", http::cut(&j.dump(), 300))
})?;
// gunnar's own rows, asked by the CLI: the same names, roles and states.
let cli = r.control(&["topology"])?;
for b in &boxes {
// A box's own line starts `* ` (this box) or two blanks, then the
// name (which may hold a blank: "this box"); the sentence under it
// is indented four.
let name = b.text("name");
let line = cli.lines().find(|l| {
l.strip_prefix("* ")
.or_else(|| l.strip_prefix(" ").filter(|rest| !rest.starts_with(' ')))
.is_some_and(|rest| rest.starts_with(&format!("{name} ")))
});
let line = line.ok_or_else(|| format!("gunnar's own topology has no {:?}: {}", b.text("name"), http::cut(&cli, 300)))?;
want(line.contains(&b.text("role")) && line.contains(&b.text("state").to_uppercase()), || {
format!("the console says {} {} {}, gunnar says {line:?}", b.text("name"), b.text("role"), b.text("state"))
})?;
}
// A rig is a solo gunnar nobody laid: no installer record, and gunnar
// says so on both doors (an installed estate says `yes`, P10 in the kit).
let recorded = j.path("recorded").and_then(|v| v.as_bool()).unwrap_or(true);
want(!recorded && cli.lines().any(|l| l.starts_with("recorded no")), || {
format!("a rig nobody laid reads recorded={recorded}; gunnar says {:?}", cli.lines().find(|l| l.starts_with("recorded")))
})?;
let count = cli.lines().find_map(|l| l.strip_prefix("boxes")).map(str::trim).unwrap_or("");
want(count == boxes.len().to_string(), || format!("gunnar counts {count:?} boxes, the console {}", boxes.len()))?;
let names: Vec<String> = boxes.iter().map(|b| format!("{} ({}, {})", b.text("name"), b.text("role"), b.text("state"))).collect();
Ok(names.join(", "))
});
go(&mut rows, "the-estate-picture-is-the-shared-renderers-drawing-of-those-boxes", || {
let (_, j) = a.get("/api/machine/topology")?;
let svg = j.text("svg");
want(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"), || format!("not one SVG document: {}", http::cut(&svg, 80)))?;
let boxes = j.path("boxes").map(|b| b.arr().to_vec()).unwrap_or_default();
for b in &boxes {
want(svg.contains(&format!(">{}<", b.text("name"))), || format!("{:?} is not on the picture", b.text("name")))?;
}
want(svg.contains("this box"), || "this box is not marked".into())?;
// The open console never says anything about buying, and a topology
// knows no plan or growth: the shared renderer's disk-only board.
for word in ["buyer", "plan", "iron comes from", "grow"] {
want(!svg.contains(word), || format!("`{word}` on the console's picture"))?;
}
let (anon, _) = a.anon("/api/machine/topology")?;
want(anon == 401, || format!("a stranger got the picture: {anon}"))?;
Ok(format!("{} bytes, {} chip(s), a stranger 401", svg.len(), boxes.len()))
});
go(&mut rows, "the-glance-lines-are-gunnars-own", || {
let (s, j) = a.get("/api/machine")?;
want(s == 200, || format!("status {s}"))?;
let glance = j.path("glance").map(|g| g.arr().to_vec()).unwrap_or_default();
let keys: Vec<String> = glance.iter().map(|g| g.text("key")).collect();
want(keys == ["data", "serialdat", "free", "speed"], || format!("the glance keys are {keys:?}: {}", http::cut(&j.dump(), 300)))?;
for g in &glance {
want(["ok", "warn", "bad", "unknown"].contains(&g.text("state").as_str()), || format!("a state gunnar does not use: {}", g.dump()))?;
}
// The CLI prints gunnar's same lines: `STATE line`. The free line is measured ON EACH CALL
// (gunnar glance.rs `free_line`), and on a busy box its GiB move between the console's read and the
// CLI's (round 5.3: "OK /data: 212.4 GiB free" vs 212.3): its figures are masked, its words are not.
let cli = r.control(&["storage"])?;
let figures = |s: &str| s.chars().map(|c| if c.is_ascii_digit() { '#' } else { c }).collect::<String>();
for g in &glance {
let want_line = format!("{:<8} {}", g.text("state").to_uppercase(), g.text("line"));
let said = if g.text("key") == "free" { cli.lines().any(|l| figures(l) == figures(&want_line)) } else { cli.contains(&want_line) };
want(said, || format!("gunnar's CLI does not say {want_line:?}: {}", http::cut(cli.split("── at a glance").nth(1).unwrap_or(&cli), 400)))?;
}
// And the mislabel stays fixed: /serialdat's disk is said as /serialdat's.
want(!cli.lines().any(|l| l.starts_with("backing ")), || "the CLI still says `backing`".into())?;
want(cli.lines().any(|l| l.starts_with("serialdat on ")), || format!("the CLI does not say `serialdat on`: {}", http::cut(&cli, 300)))?;
let shown: Vec<String> = glance.iter().map(|g| format!("{}={}", g.text("key"), g.text("state"))).collect();
Ok(shown.join(" "))
});
// ── identities: the console grants access, and git proves it ─────────
//
// Every row here ends at stock git over SSH rather than at a 200 from the
// console. A grant that lists correctly and opens nothing is the failure
// that matters, and only a real clone can tell the two apart.
go(&mut rows, "a-new-principal-with-a-key-can-do-nothing-yet", || {
let (s, _, body) = a.post("/api/principals", r#"{"name":"tenant-bot","kind":"machine"}"#)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 150)))?;
let pubkey = std::fs::read_to_string(r.tenant_key.with_extension("pub"))
.map_err(|e| format!("reading the tenant key: {e}"))?
.trim()
.to_owned();
let (s, _, body) = a.post(
"/api/keys",
&format!(
r#"{{"principal_id":"tenant-bot","openssh_public_key":{},"comment":"uitest"}}"#,
esc(&pubkey)
),
)?;
want((200..300).contains(&s), || format!("add-key answered {s}: {}", http::cut(&body, 150)))?;
// Registered, and holding nothing: the clone must be refused.
let dir = r.work.join("as-tenant");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let e = r
.git_as(&r.tenant_key, &dir, &["clone", "-q", &r.remote_as("tenant-bot", "alice/uitest"), "no-grant"])
.err()
.ok_or("a principal with no grant cloned the repository")?;
Ok(format!("registered, and refused: {}", http::cut(&e, 80)))
});
go(&mut rows, "a-grant-from-the-console-lets-git-clone", || {
let (s, _, body) = a.post(
"/api/grants",
r#"{"principal_id":"tenant-bot","namespace":"alice","repo":"","level":"read"}"#,
)?;
want((200..300).contains(&s), || format!("grant answered {s}: {}", http::cut(&body, 150)))?;
let (_, g) = a.get("/api/grants")?;
want(
g.path("items")
.map(|i| i.arr().iter().any(|e| e.text("principal_id") == "tenant-bot"))
.unwrap_or(false),
|| format!("not listed: {}", http::cut(&g.dump(), 200)),
)?;
let dir = r.work.join("as-tenant");
let out = r.git_as(&r.tenant_key, &dir, &["clone", "-q", &r.remote_as("tenant-bot", "alice/uitest"), "granted"])
.map_err(|e| format!("the grant listed but git was still refused: {}", http::cut(&e, 150)))?;
let readme = dir.join("granted/README");
want(readme.exists(), || format!("cloned but empty: {}", http::cut(&out, 100)))?;
Ok("read granted, and git cloned it".into())
});
// ── usage: the meters, and whose they are ────────────────────────────
//
// `Usage` is open source and always on — gunnar counts what a namespace
// holds whether or not anything ever bills from it — and `Usage.Read`
// takes `read` on the ONE namespace it names. The console's usage page is
// built on that split, so these rows ask gunnar itself whether the split
// is real, with the credential that decides it.
//
// `tenant-bot` holds `read` on `alice` and nothing else at this point in
// the walk, which is exactly the credential the question needs.
go(&mut rows, "a-tenant-reads-its-own-namespaces-meters-and-not-anothers", || {
// A namespace the tenant has no grant on. Created here rather than
// reused, so the refusal below cannot be about a name that is missing.
let (s, _, body) = a.post(
"/api/namespaces",
r#"{"name":"strangers","description":"not the tenant's"}"#,
)?;
want((200..300).contains(&s), || format!("creating strangers answered {s}: {}", http::cut(&body, 150)))?;
// The tenant's own namespace: answered, with numbers in it.
let mine = r
.control_as(&r.tenant_key, &["usage", "alice"])
.map_err(|e| format!("a read grant on `alice` did not open its own meters: {}", http::cut(&e, 200)))?;
want(mine.contains("alice\t") || mine.starts_with("alice"), || {
format!("the answer is not about `alice`: {}", http::cut(&mine, 200))
})?;
// Somebody else's: refused. And the control that makes the refusal mean
// something — the SAME call, the same server, the same namespace, with
// only the credential changed. The admin key reads it fine.
let refused = r
.control_as(&r.tenant_key, &["usage", "strangers"])
.err()
.ok_or("a key with read on `alice` alone read `strangers`\'s meters")?;
r.control(&["usage", "strangers"])
.map_err(|e| format!("the admin key could not read `strangers` either, so the refusal above proves nothing: {}", http::cut(&e, 200)))?;
Ok(format!("own namespace read, another refused: {}", http::cut(&refused, 90)))
});
go(&mut rows, "the-consoles-usage-route-carries-gunnars-own-numbers", || {
// The console must not have a second opinion about a meter. `gunnar-cli
// control usage` prints the raw byte count beside the rounded one; the
// raw number is what is compared, so a rounding that agrees by accident
// cannot pass for a wire-up.
let printed = r.control(&["usage", "alice"])?;
let pack = field(&printed, "pack").ok_or_else(|| format!("no `pack` in {}", http::cut(&printed, 200)))?;
let objects = field(&printed, "objects").ok_or_else(|| format!("no `objects` in {}", http::cut(&printed, 200)))?;
// A namespace that measures 0 could not tell a working route from a
// dead one — and `lfs` really IS 0 on every gunnar, so the meter to
// stand on has to be one that is not.
want(pack > 0, || format!("`alice` holds no packs, so this would compare two zeroes: {}", http::cut(&printed, 200)))?;
let (s, u) = a.get("/api/namespaces/alice/usage")?;
want(s == 200, || format!("the console answered {s}: {}", http::cut(&u.dump(), 200)))?;
want(u.text("namespace") == "alice", || format!("about the wrong namespace: {}", http::cut(&u.dump(), 200)))?;
let seen_pack = u.path("pack_bytes").and_then(|v| v.as_f64()).unwrap_or(-1.0) as i64;
want(seen_pack == pack as i64, || {
format!("the console says pack_bytes {seen_pack} and gunnar says {pack}: {}", http::cut(&u.dump(), 200))
})?;
let seen_objects = u.path("objects").and_then(|v| v.as_f64()).unwrap_or(-1.0) as i64;
want(seen_objects == objects as i64, || {
format!("the console says objects {seen_objects} and gunnar says {objects}")
})?;
Ok(format!("pack {pack} B and {objects} objects, matching gunnar exactly"))
});
go(&mut rows, "the-usage-route-refuses-a-namespace-gunnar-does-not-have", || {
// The friendly failure this rules out: an unknown namespace rendered as
// one that exists and holds nothing.
let (s, body) = a.get_raw("/api/namespaces/no-such-namespace/usage")?;
want(s == 404 || s == 403, || format!("answered {s}: {}", http::cut(&body, 200)))?;
want(!body.contains("pack_bytes"), || format!("a refusal carried meters: {}", http::cut(&body, 200)))?;
Ok(format!("{s}, and no meters in it"))
});
go(&mut rows, "a-read-grant-refuses-the-very-push-a-write-grant-accepts", || {
// "The push was refused" is worth nothing on its own — a push fails for
// a dozen reasons that have nothing to do with authorisation. So the
// control is the SAME push, from the same clone, with the same key, to
// the same ref, with only the grant changed. Refused under read,
// accepted under write: now the refusal is about the grant and can be
// about nothing else.
let dir = r.work.join("as-tenant/granted");
std::fs::write(dir.join("README"), "a reader must not be able to write this\n")
.map_err(|e| e.to_string())?;
r.git_as(&r.tenant_key, &dir, &["config", "user.name", "tenant"])?;
r.git_as(&r.tenant_key, &dir, &["config", "user.email", "tenant@example.invalid"])?;
r.git_as(&r.tenant_key, &dir, &["add", "README"])?;
r.git_as(&r.tenant_key, &dir, &["commit", "-qm", "a reader's edit"])?;
let refused = r
.git_as(&r.tenant_key, &dir, &["push", "-q", "origin", "HEAD:refs/heads/tenant-edit"])
.err()
.ok_or("a read grant accepted a push")?;
// Now the only thing that changes is the grant.
let (s, _, body) = a.post(
"/api/grants",
r#"{"principal_id":"tenant-bot","namespace":"alice","repo":"","level":"write"}"#,
)?;
want((200..300).contains(&s), || format!("the write grant answered {s}: {}", http::cut(&body, 150)))?;
r.git_as(&r.tenant_key, &dir, &["push", "-q", "origin", "HEAD:refs/heads/tenant-edit"])
.map_err(|e| {
format!("write was granted and the same push still failed, so the first refusal proved nothing: {}", http::cut(&e, 150))
})?;
// And the ref is really there, on the server.
let (_, refs) = a.get("/api/repos/alice/uitest/refs")?;
want(
refs.path("items")
.map(|i| i.arr().iter().any(|e| e.text("ref_name") == "refs/heads/tenant-edit"))
.unwrap_or(false),
|| format!("the push was accepted but the ref is absent: {}", http::cut(&refs.dump(), 200)),
)?;
// Put the grant back where the next situation expects it.
a.post(
"/api/grants",
r#"{"principal_id":"tenant-bot","namespace":"alice","repo":"","level":"read"}"#,
)?;
Ok(format!("read refused it ({}), write accepted it", http::cut(&refused, 60)))
});
go(&mut rows, "revoking-the-grant-closes-git-again", || {
let (s, _, body) = a.post(
"/api/grants/revoke",
r#"{"principal_id":"tenant-bot","namespace":"alice","repo":""}"#,
)?;
want((200..300).contains(&s), || format!("revoke answered {s}: {}", http::cut(&body, 150)))?;
let dir = r.work.join("as-tenant");
let e = r
.git_as(&r.tenant_key, &dir, &["clone", "-q", &r.remote_as("tenant-bot", "alice/uitest"), "after-revoke"])
.err()
.ok_or("the grant was revoked and git cloned anyway")?;
Ok(format!("refused again: {}", http::cut(&e, 80)))
});
go(&mut rows, "the-console-unmakes-the-key-and-the-principal", || {
let (_, k) = a.get("/api/keys")?;
let fp = k
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("principal_id") == "tenant-bot").cloned())
.map(|e| e.text("fingerprint"))
.ok_or("the tenant key is not listed")?;
let (s, body) = a.delete(&format!("/api/keys/{}", fp.replace(':', "%3A").replace('+', "%2B").replace('/', "%2F")))?;
want((200..300).contains(&s), || format!("key delete answered {s}: {}", http::cut(&body, 150)))?;
let (s, body) = a.delete("/api/principals/tenant-bot")?;
want((200..300).contains(&s), || format!("principal delete answered {s}: {}", http::cut(&body, 150)))?;
let (_, p) = a.get("/api/principals")?;
want(!items_have(&p, "id", "tenant-bot"), || "still listed".into())?;
Ok("key and principal both gone".into())
});
go(&mut rows, "the-console-makes-and-unmakes-a-namespace", || {
let (s, _, body) = a.post("/api/namespaces", r#"{"name":"tenant-kim"}"#)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 150)))?;
let (_, j) = a.get("/api/namespaces")?;
want(items_have(&j, "name", "tenant-kim"), || {
format!("not listed after create: {}", http::cut(&j.dump(), 200))
})?;
let (s, body) = a.delete("/api/namespaces/tenant-kim")?;
want((200..300).contains(&s), || format!("delete answered {s}: {}", http::cut(&body, 150)))?;
let (_, j) = a.get("/api/namespaces")?;
want(!items_have(&j, "name", "tenant-kim"), || "still listed after delete".into())?;
Ok("created, listed, deleted, gone".into())
});
go(&mut rows, "a-public-repository-opens-the-anonymous-door", || {
// The anonymous door means offering NO key at all — not offering
// an unknown one, which ssh refuses at publickey auth before any
// repository ACL is consulted. The control is the same anonymous clone
// three times, with only the visibility changing: shut, open, shut.
let dir = r.work.join("as-stranger");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let refused = r
.git_anon(&dir, &["clone", "-q", &r.remote_as("anonymous", "alice/uitest"), "while-private"])
.err()
.ok_or("a private repository answered an anonymous clone")?;
let h = [
("Authorization", format!("Bearer {}", r.token)),
("Content-Type", "application/json".to_owned()),
];
let patch = |v: &str| -> Result<u16, String> {
let resp = http::req(
&r.ui_addr(),
"PATCH",
"/api/repos/alice/uitest",
&h,
Some(&format!(r#"{{"visibility":{}}}"#, esc(v))),
)?;
Ok(resp.status)
};
let s1 = patch("public")?;
want((200..300).contains(&s1), || format!("making it public answered {s1}"))?;
want(a.get("/api/repos/alice/uitest")?.1.text("visibility") == "public", || {
"the console did not record it as public".into()
})?;
r.git_anon(&dir, &["clone", "-q", &r.remote_as("anonymous", "alice/uitest"), "while-public"])
.map_err(|e| format!("public, and the anonymous door stayed shut: {}", http::cut(&e, 200)))?;
want(dir.join("while-public/README").exists(), || "cloned but empty".into())?;
let s2 = patch("private")?;
want((200..300).contains(&s2), || format!("making it private answered {s2}"))?;
r.git_anon(&dir, &["clone", "-q", &r.remote_as("anonymous", "alice/uitest"), "private-again"])
.err()
.ok_or("the door stayed open after it was made private again")?;
Ok(format!("shut ({}), open, shut again", http::cut(&refused, 45)))
});
// ── pull requests ─────────────────────────────────────────────────────
//
// The branch these propose is the one the identities rows pushed, so this
// reviews a change that a different principal really made.
let pull = std::cell::RefCell::new(String::new());
go(&mut rows, "a-pull-request-is-opened-and-listed", || {
let (s, j, body) = a.post(
"/api/repos/alice/uitest/pulls",
r#"{"title":"a reader's edit, reviewed","description":"opened by uitest",
"source_ref":"refs/heads/tenant-edit","target_ref":"refs/heads/main"}"#,
)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 200)))?;
let number = j
.path("number")
.and_then(|n| n.as_f64())
.map(|n| format!("{}", n as i64))
.ok_or_else(|| format!("no number in {}", http::cut(&j.dump(), 200)))?;
*pull.borrow_mut() = number.clone();
let (_, list) = a.get("/api/repos/alice/uitest/pulls")?;
let found = list.arr().iter().any(|p| {
p.path("number").and_then(|n| n.as_f64()).map(|n| format!("{}", n as i64)) == Some(number.clone())
}) || list
.path("items")
.map(|i| i.arr().iter().any(|p| p.text("title").contains("reviewed")))
.unwrap_or(false);
want(found, || format!("not listed: {}", http::cut(&list.dump(), 200)))?;
Ok(format!("pull #{number}"))
});
// ── THE REVIEW GATE, through the console ──────────────────────────────
//
// gunnar refuses a merge unless a VERIFIED approval names the commit the
// source branch is at RIGHT NOW. The three rows below are the same three
// arms `gunnar/crates/gunnar-cli/tests/forge_merge.rs` asserts against the
// control plane, driven end-to-end through the console instead — because
// the console had a Merge button and no way to approve at all, and that
// combination is exactly what a happy-path-only row cannot see.
go(&mut rows, "merging-without-an-approval-is-refused", || {
let n = pull.borrow().clone();
want(!n.is_empty(), || "no pull request was opened".into())?;
let (s, rev) = a.get(&format!("/api/repos/alice/uitest/pulls/{n}/review"))?;
want(s == 200, || format!("review answered {s}: {}", http::cut(&rev.dump(), 200)))?;
let head = rev.text("head");
want(!head.is_empty(), || "the review does not say where the branch is".into())?;
want(
rev.path("approvals").map(|v| v.arr().is_empty()).unwrap_or(false),
|| format!("something has already approved it: {}", http::cut(&rev.dump(), 200)),
)?;
want(rev.path("head_is_approved").and_then(|v| v.as_bool()) == Some(false), || {
"the console claims an unapproved commit is approved".into()
})?;
let before = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
let (s, _, body) = a.post(&format!("/api/repos/alice/uitest/pulls/{n}/merge"), "{}")?;
want(s == 400, || format!("an unapproved merge answered {s}: {}", http::cut(&body, 200)))?;
want(body.contains("no verified approval names"), || {
format!("refused, but not by the review gate: {}", http::cut(&body, 200))
})?;
want(body.contains("Nobody has approved this pull request"), || {
format!("refused without saying nobody had approved it: {}", http::cut(&body, 200))
})?;
// The whole point: a refusal that moved the branch anyway is a refusal
// that did nothing, and it reads identically from the caller's side.
let after = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
want(after == before, || "the merge was refused and moved main anyway".into())?;
let ls = r.git(&r.work.join("wt"), &["ls-remote", &r.remote("alice/uitest"), "refs/heads/main"])?;
want(ls.contains(&before), || {
format!("the console says main is unmoved but the server advertises {}", http::cut(&ls, 80))
})?;
Ok(format!("refused at {}", &head[..12.min(head.len())]))
});
go(&mut rows, "an-approval-does-not-follow-a-branch-that-moved", || {
// THE ARM THAT MATTERS. Approve the tip, then push one more commit onto
// the same branch — the ordinary, innocent-looking case. The approval
// still names the commit that was reviewed, so it is not an approval of
// what would now ship, and the merge has to refuse. A mutable
// `approved: bool` cannot express this, and a matrix that only proved
// the happy path would let it back in.
let n = pull.borrow().clone();
want(!n.is_empty(), || "no pull request was opened".into())?;
let reviewed = a.get(&format!("/api/repos/alice/uitest/pulls/{n}/review"))?.1.text("head");
want(!reviewed.is_empty(), || "the review does not say where the branch is".into())?;
let (s, rev, body) = a.post(
&format!("/api/repos/alice/uitest/pulls/{n}/approve"),
&format!(r#"{{"commit":{}}}"#, esc(&reviewed)),
)?;
want((200..300).contains(&s), || format!("approve answered {s}: {}", http::cut(&body, 250)))?;
want(rev.path("head_is_approved").and_then(|v| v.as_bool()) == Some(true), || {
format!("approved, and the console still says not approved: {}", http::cut(&rev.dump(), 250))
})?;
// Verified against gunnar's key table, not merely present. An approval by
// a key gunnar cannot check is a claim, not evidence, and the two look
// the same in a count.
want(
rev.path("approvals")
.map(|v| v.arr().iter().any(|e| {
e.path("verified").and_then(|x| x.as_bool()) == Some(true)
&& e.text("commit") == reviewed
}))
.unwrap_or(false),
|| format!("no verified approval names the reviewed commit: {}", http::cut(&rev.dump(), 250)),
)?;
// Now the branch grows a commit nobody looked at.
let wt = r.work.join("wt");
r.git(&wt, &["fetch", "-q", &r.remote("alice/uitest"), "refs/heads/tenant-edit"])?;
r.git(&wt, &["checkout", "-q", "-B", "after-the-review", "FETCH_HEAD"])?;
std::fs::write(wt.join("src/unreviewed.rs"), "pub fn nobody_looked_at_this() {}\n")
.map_err(|e| e.to_string())?;
r.git(&wt, &["add", "src/unreviewed.rs"])?;
r.git(&wt, &["commit", "-qm", "a commit that arrived after the review"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/uitest"), "HEAD:refs/heads/tenant-edit"])?;
r.git(&wt, &["checkout", "-q", "main"])?;
let (_, rev) = a.get(&format!("/api/repos/alice/uitest/pulls/{n}/review"))?;
let moved = rev.text("head");
want(moved != reviewed, || "the branch did not move, so this proves nothing".into())?;
want(rev.path("head_is_approved").and_then(|v| v.as_bool()) == Some(false), || {
format!("the approval followed the branch: {}", http::cut(&rev.dump(), 250))
})?;
// And the earlier approval is still THERE and still verified — it is
// about another commit, which is a different thing from being gone.
want(
rev.path("approvals")
.map(|v| v.arr().iter().any(|e| {
e.text("commit") == reviewed
&& e.path("verified").and_then(|x| x.as_bool()) == Some(true)
&& e.path("covers_head").and_then(|x| x.as_bool()) == Some(false)
}))
.unwrap_or(false),
|| format!("the approval of the reviewed commit vanished: {}", http::cut(&rev.dump(), 250)),
)?;
let before = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
let (s, _, body) = a.post(&format!("/api/repos/alice/uitest/pulls/{n}/merge"), "{}")?;
want(s == 400, || format!("the merge answered {s}: {}", http::cut(&body, 250)))?;
want(body.contains("no verified approval names") && body.contains(&moved[..12]), || {
format!("refused without naming the commit that would ship: {}", http::cut(&body, 250))
})?;
want(body.contains("What is approved:") && body.contains(&reviewed[..12]), || {
format!("refused without saying what IS approved: {}", http::cut(&body, 250))
})?;
let after = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
want(after == before, || "the merge was refused and moved main anyway".into())?;
Ok(format!("{} approved, {} would ship", &reviewed[..8], &moved[..8]))
});
go(&mut rows, "merging-a-pull-request-moves-the-branch", || {
let n = pull.borrow().clone();
want(!n.is_empty(), || "no pull request was opened".into())?;
// The reviewer looks at the NEW commit and approves that one — the
// remedy the refusal above points at, and the only way past the gate.
let head = a.get(&format!("/api/repos/alice/uitest/pulls/{n}/review"))?.1.text("head");
want(!head.is_empty(), || "the review does not say where the branch is".into())?;
let (s, rev, body) = a.post(
&format!("/api/repos/alice/uitest/pulls/{n}/approve"),
&format!(r#"{{"commit":{}}}"#, esc(&head)),
)?;
want((200..300).contains(&s), || format!("approve answered {s}: {}", http::cut(&body, 250)))?;
want(rev.path("head_is_approved").and_then(|v| v.as_bool()) == Some(true), || {
format!("approved and still not approved: {}", http::cut(&rev.dump(), 250))
})?;
let before = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
let (s, _, body) = a.post(&format!("/api/repos/alice/uitest/pulls/{n}/merge"), "{}")?;
want((200..300).contains(&s), || format!("merge answered {s}: {}", http::cut(&body, 200)))?;
let after = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
want(after != before, || format!("main did not move: still {}", &before[..12.min(before.len())]))?;
// And the move is real on the wire, not just in the console's answer.
let wt = r.work.join("wt");
let ls = r.git(&wt, &["ls-remote", &r.remote("alice/uitest"), "refs/heads/main"])?;
want(ls.contains(&after), || {
format!("the console says main is {} but the server advertises {}", &after[..12.min(after.len())], http::cut(&ls, 80))
})?;
Ok(format!("main {} → {}", &before[..8.min(before.len())], &after[..8.min(after.len())]))
});
go(&mut rows, "a-pull-request-can-be-closed-without-merging", || {
let wt = r.work.join("wt");
std::fs::write(wt.join("src/third.rs"), "pub fn third() {}\n").map_err(|e| e.to_string())?;
r.git(&wt, &["checkout", "-q", "-b", "not-wanted"])?;
r.git(&wt, &["add", "src/third.rs"])?;
r.git(&wt, &["commit", "-qm", "a change nobody wants"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/uitest"), "not-wanted"])?;
r.git(&wt, &["checkout", "-q", "main"])?;
let (s, j, body) = a.post(
"/api/repos/alice/uitest/pulls",
r#"{"title":"not wanted","description":"","source_ref":"refs/heads/not-wanted","target_ref":"refs/heads/main"}"#,
)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 200)))?;
let n = j.path("number").and_then(|x| x.as_f64()).map(|x| x as i64).ok_or("no number")?;
let before = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
let (s, _, body) = a.post(&format!("/api/repos/alice/uitest/pulls/{n}/close"), "{}")?;
want((200..300).contains(&s), || format!("close answered {s}: {}", http::cut(&body, 200)))?;
let (_, got) = a.get(&format!("/api/repos/alice/uitest/pulls/{n}"))?;
let state = got.text("state");
want(state != "open" && !state.is_empty(), || format!("state is {state:?} after closing"))?;
// Closing must not merge: the branch has to be exactly where it was.
let after = a.get("/api/repos/alice/uitest/head")?.1.text("oid");
want(after == before, || "closing the pull request moved main".into())?;
Ok(format!("#{n} is {state}, and main did not move"))
});
// ── issues ────────────────────────────────────────────────────────────
//
// The OTHER half of the forge, driven end-to-end through the console the
// same way the review gate above is. Every row asserts the FOLD gunnar
// returns — the state the page renders is nothing but this fold, so
// asserting the fold IS asserting what the browser draws. Open → comment →
// close, and then the red-first arm: a comment on a closed issue is refused
// and the fold is left untouched.
let issue = std::cell::RefCell::new(String::new());
go(&mut rows, "an-issue-is-opened-and-listed", || {
let (s, j, body) = a.post(
"/api/repos/alice/uitest/issues",
r#"{"title":"the boot is dark","body":"PID 1 prints to a dead ttyS0"}"#,
)?;
want((200..300).contains(&s), || format!("open answered {s}: {}", http::cut(&body, 200)))?;
let number = j
.path("number")
.and_then(|n| n.as_f64())
.map(|n| format!("{}", n as i64))
.ok_or_else(|| format!("no number in {}", http::cut(&j.dump(), 200)))?;
want(j.path("open").and_then(|v| v.as_bool()) == Some(true), || {
"a fresh issue is not open".into()
})?;
*issue.borrow_mut() = number.clone();
// Listed, and the fold carries its title and its signer as author.
let (_, list) = a.get("/api/repos/alice/uitest/issues")?;
let found = list.arr().iter().any(|i| {
i.path("number").and_then(|n| n.as_f64()).map(|n| format!("{}", n as i64))
== Some(number.clone())
&& i.text("title") == "the boot is dark"
});
want(found, || format!("not listed: {}", http::cut(&list.dump(), 250)))?;
Ok(format!("issue #{number}, open"))
});
go(&mut rows, "an-issue-takes-a-comment", || {
let n = issue.borrow().clone();
want(!n.is_empty(), || "no issue was opened".into())?;
let (s, j, body) = a.post(
&format!("/api/repos/alice/uitest/issues/{n}/comment"),
r#"{"body":"seen on the serial console too"}"#,
)?;
want((200..300).contains(&s), || format!("comment answered {s}: {}", http::cut(&body, 200)))?;
// The fold read back shows one comment on an issue still open.
want(j.path("comments").and_then(|v| v.as_f64()) == Some(1.0), || {
format!("the comment did not land in the fold: {}", http::cut(&j.dump(), 200))
})?;
want(j.path("open").and_then(|v| v.as_bool()) == Some(true), || {
"commenting closed the issue".into()
})?;
Ok(format!("#{n} has 1 comment"))
});
go(&mut rows, "an-issue-closes-and-the-fold-shows-it-shut", || {
let n = issue.borrow().clone();
want(!n.is_empty(), || "no issue was opened".into())?;
let (s, j, body) = a.post(&format!("/api/repos/alice/uitest/issues/{n}/close"), "{}")?;
want((200..300).contains(&s), || format!("close answered {s}: {}", http::cut(&body, 200)))?;
want(j.path("open").and_then(|v| v.as_bool()) == Some(false), || {
format!("closed, and the fold still says open: {}", http::cut(&j.dump(), 200))
})?;
// And GET agrees — the state is the fold, read fresh, not the POST's
// own optimistic answer.
let (_, got) = a.get(&format!("/api/repos/alice/uitest/issues/{n}"))?;
want(got.path("open").and_then(|v| v.as_bool()) == Some(false), || {
format!("GET disagrees with the close: {}", http::cut(&got.dump(), 200))
})?;
Ok(format!("#{n} is closed"))
});
go(&mut rows, "a-comment-on-a-closed-issue-is-refused", || {
// The red-first arm: an event on a closed issue is evidence about
// nothing, so gunnar refuses it — and the fold is left exactly as the
// close left it. A console that let this through would show a comment
// count nobody signed a still-open issue for.
let n = issue.borrow().clone();
want(!n.is_empty(), || "no issue was opened".into())?;
let before = a
.get(&format!("/api/repos/alice/uitest/issues/{n}"))?
.1
.path("comments")
.and_then(|v| v.as_f64())
.unwrap_or(-1.0);
let (s, _, body) = a.post(
&format!("/api/repos/alice/uitest/issues/{n}/comment"),
r#"{"body":"reopening by comment?"}"#,
)?;
want(s == 400, || format!("a comment on a closed issue answered {s}: {}", http::cut(&body, 200)))?;
want(body.to_lowercase().contains("closed"), || {
format!("refused, but not for being closed: {}", http::cut(&body, 200))
})?;
let after = a
.get(&format!("/api/repos/alice/uitest/issues/{n}"))?
.1
.path("comments")
.and_then(|v| v.as_f64())
.unwrap_or(-1.0);
want(after == before, || {
format!("the refused comment landed in the fold anyway: {before} → {after}")
})?;
Ok(format!("refused, still {after:.0} comment(s)"))
});
// ── invites ───────────────────────────────────────────────────────────
// Thin layer L: invites are gunnar's, and gunnar refuses an ADMIN one
// ("an invite never makes an admin"); the account made for it is taken
// back. A name of its own, so no later row meets a principal it made.
go(&mut rows, "an-invite-cannot-grant-admin", || {
let (s, _, body) = a.post("/auth/passkey/invites", r#"{"user":"zed","role":"admin"}"#)?;
want(s == 400, || format!("status {s}: {}", http::cut(&body, 120)))?;
want(body.contains("never makes an admin"), || format!("refused, but for another reason: {}", http::cut(&body, 120)))?;
Ok(http::cut(&body, 90))
});
// IDENTITY-HOST: the open invites are the identity core's, listed and withdrawn AS a
// passkey session (an admin's: every one). The break-glass token holds no session, so
// it mints (gunnar's `Access.EnrolmentLink`) and the admin's session lists and withdraws
// what it minted: the browser rows `an-invite-is-listed-open-to-the-admin` and
// `a-revoked-invite-stops-being-open`.
go(&mut rows, "the-break-glass-token-mints-an-invite-link", || {
let (s, j, body) = a.post("/auth/passkey/invites", r#"{"user":"kim","role":"viewer"}"#)?;
want(s == 200, || format!("status {s}: {}", http::cut(&body, 150)))?;
let id = j.text("id");
let url = j.text("url");
want(!id.is_empty() && url.contains("/login?invite="), || format!("{}", j.dump()))?;
want(j.text("role") == "viewer" && j.text("user") == "kim", || format!("{}", http::cut(&j.dump(), 200)))?;
Ok(format!("invite {} for kim, viewer", &id[..8.min(id.len())]))
});
// ── branch protection: the gate, and the push it has to reject ────────
//
// The asymmetry these close, measured on this repo: a merge has required a
// verified signed approval naming the exact commit since `7f605beb`, and a
// DIRECT push to the same branch was refused by nothing at all. Not because
// gunnar had no gate — it has served all six `Policy.*` verbs out of
// `refs/gunnar/policy` since `gunnar_core::PolicyStore` landed — but because
// the surface that turns the gate on was reachable from neither the CLI nor
// the console, and `docs/coverage.md` said the whole service was refused.
//
// They run on their own repository rather than on `alice/uitest`, so a
// rule left behind by a failing row cannot silently change what a later row
// is allowed to push.
let guard = r.work.join("guarded");
let guarded_remote = r.remote("alice/guarded");
let made = go(&mut rows, "a-repository-to-protect-exists-and-takes-a-push", || {
let (s, _, body) = a.post("/api/repos", r#"{"namespace":"alice","name":"guarded"}"#)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 150)))?;
std::fs::create_dir_all(&guard).map_err(|e| e.to_string())?;
r.git(&guard, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&guard, &["config", "user.name", "uitest"])?;
r.git(&guard, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(guard.join("README"), "one\n").map_err(|e| e.to_string())?;
r.git(&guard, &["add", "README"])?;
r.git(&guard, &["commit", "-qm", "one"])?;
r.git(&guard, &["push", "-q", &guarded_remote, "main"])?;
Ok("alice/guarded, one commit on main".into())
});
// **The control, and it comes first.** "Refused after the rule" is satisfied
// by a repository that refuses this push anyway — a wrong hash, a missing
// grant, a broken remote. Unless the very same command is seen to SUCCEED
// with no rule in place, the red leg below proves nothing at all.
let force_push_landed = made
&& go(&mut rows, "with-no-rule-a-force-push-to-main-lands", || {
std::fs::write(guard.join("README"), "two\n").map_err(|e| e.to_string())?;
r.git(&guard, &["add", "README"])?;
r.git(&guard, &["commit", "-q", "--amend", "-m", "rewritten"])?;
r.git(&guard, &["push", "--force", "-q", &guarded_remote, "main"])
.map_err(|e| format!("a force-push must be SERVABLE before a rule forbidding it \
means anything: {}", http::cut(&e, 200)))?;
Ok("history rewritten on main, unguarded".into())
});
let protected = force_push_landed
&& go(&mut rows, "the-console-sets-branch-protection-and-reads-it-back", || {
let (s, _, body) = a.post(
"/api/repos/alice/guarded/protection",
r#"{"ref_pattern":"refs/heads/main","require_signature":false}"#,
)?;
want((200..300).contains(&s), || {
format!("SetProtection answered {s}: {}", http::cut(&body, 250))
})?;
// Read it back over the console's own GET, not out of the POST's
// echo: an endpoint that returns what it was handed and stores
// nothing passes an echo assertion perfectly.
let (s, j) = a.get("/api/repos/alice/guarded/protection")?;
want(s == 200, || format!("GetProtection answered {s}"))?;
want(items_have(&j, "ref_pattern", "refs/heads/main")
|| j.arr().iter().any(|e| e.text("ref_pattern") == "refs/heads/main"), || {
format!("the rule did not read back: {}", http::cut(&j.dump(), 250))
})?;
// And the CLI agrees, off the same document. Two surfaces onto one
// store is the whole point; two stores that agree today is not.
let listed = r.control(&["policy", "list", "alice/guarded"])?;
want(listed.contains("refs/heads/main"), || {
format!("`gunnar control policy list` does not see the console's rule: {}",
http::cut(&listed, 200))
})?;
Ok("refs/heads/main protected, visible from console and CLI".into())
});
// **THE ROW.** A protection nobody has watched reject a push is not a
// protection; it is a row in a database. Everything above is shape.
if protected {
go(&mut rows, "a-protected-branch-refuses-a-direct-force-push", || {
std::fs::write(guard.join("README"), "three\n").map_err(|e| e.to_string())?;
r.git(&guard, &["add", "README"])?;
r.git(&guard, &["commit", "-q", "--amend", "-m", "rewritten again"])?;
let refused = r
.git(&guard, &["push", "--force", &guarded_remote, "main"])
.err()
.ok_or("THE FORCE-PUSH LANDED. A rule was written over the console, read back \
from the console and from the CLI, and the pre-receive gate did not \
consult it — so branch protection in this build is a value that goes \
somewhere to be displayed")?;
// The server must still be holding the old tip. A push that failed
// AFTER applying the ref would report an error and have rewritten
// main anyway, and only the ref advertisement can tell the two
// apart.
let (_, refs) = a.get("/api/repos/alice/guarded/refs")?;
let head = r.git(&guard, &["rev-parse", "HEAD"])?.trim().to_owned();
let on_server = refs
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("ref_name") == "refs/heads/main").cloned())
.ok_or("refs/heads/main is gone from the server entirely")?;
// `oid` is the field this endpoint serves. This line used to read
// `target` and `object_id` — neither exists on a RefTarget — so
// both came back empty and the inequality could NEVER fail
// (found 2026-08-31 when a sibling row copied the same mistake
// and its equality check could never pass).
let on_main = on_server.text("oid");
want(!on_main.is_empty(), || {
format!("refs/heads/main has no oid to compare: {}", http::cut(&on_server.dump(), 200))
})?;
want(on_main != head, || {
format!("the push was reported refused and main moved to the rewritten commit \
anyway: {}", http::cut(&on_server.dump(), 200))
})?;
Ok(format!("refused: {}", http::cut(&refused, 90)))
});
go(&mut rows, "a-protected-branch-refuses-a-deletion", || {
let refused = r
.git(&guard, &["push", &guarded_remote, "--delete", "main"])
.err()
.ok_or("main was DELETED through a rule whose allow_delete is false")?;
let (_, refs) = a.get("/api/repos/alice/guarded/refs")?;
want(items_have(&refs, "ref_name", "refs/heads/main"), || {
"the deletion was reported refused and the branch is gone anyway".into()
})?;
Ok(format!("refused: {}", http::cut(&refused, 90)))
});
// The other direction, and the one that keeps the two rows above from
// passing against a repository that had simply stopped accepting
// pushes: with the rule REMOVED, the identical command lands again.
go(&mut rows, "removing-the-protection-lets-the-same-push-through-again", || {
let (s, body) = a.delete(
"/api/repos/alice/guarded/protection?ref_pattern=refs%2Fheads%2Fmain",
)?;
want((200..300).contains(&s), || {
format!("DeleteProtection answered {s}: {}", http::cut(&body, 200))
})?;
r.git(&guard, &["push", "--force", "-q", &guarded_remote, "main"])
.map_err(|e| format!("the rule was removed and the push is STILL refused, so \
the two rows above were not measuring the rule: {}",
http::cut(&e, 200)))?;
Ok("rule gone, the identical force-push lands".into())
});
}
// ── self-approval, the merge-side half of the same document ───────────
//
// The console's approvals are all the CONSOLE's: one key, one principal,
// and the console principal also opens every pull request here — so a
// `forbid_self_approval` rule on main refuses the console's own
// approve-then-merge, which is exactly the posture an administrator asked
// for by writing the rule. The rule is the only variable: the SAME merge
// is driven once with it (refused, naming the rule) and once without it
// (lands). The rule-off control also already ran above as
// `merging-a-pull-request-moves-the-branch`.
//
// Its own repository, for the same reason the force-push rows have one —
// measured on the first run of this row: it failed mid-way (the red this
// row exists for), its cleanup never ran, and the rule it left on
// `alice/guarded` flipped `the-gate-list-reports-what-is-actually-
// configured` to FAIL. A rule a failing row leaks must land where no
// other row looks.
go(&mut rows, "a-rule-forbidding-self-approval-refuses-the-consoles-own-merge", || {
let (s, _, body) = a.post("/api/repos", r#"{"namespace":"alice","name":"reviewed"}"#)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 150)))?;
let dir = r.work.join("reviewed");
let remote = r.remote("alice/reviewed");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
r.git(&dir, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&dir, &["config", "user.name", "uitest"])?;
r.git(&dir, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(dir.join("README"), "one\n").map_err(|e| e.to_string())?;
r.git(&dir, &["add", "README"])?;
r.git(&dir, &["commit", "-qm", "one"])?;
r.git(&dir, &["push", "-q", &remote, "main"])?;
// Where main is, read off the server's own ref advertisement — the
// same source the force-push rows trust.
let main_tip = || -> Result<String, String> {
let (_, refs) = a.get("/api/repos/alice/reviewed/refs")?;
let row = refs
.path("items")
.and_then(|i| i.arr().iter().find(|e| e.text("ref_name") == "refs/heads/main").cloned())
.ok_or("refs/heads/main is gone from the server")?;
let oid = row.text("oid");
// Empty must be an error, not a value: every comparison against
// an empty tip is a comparison that can never fail.
want(!oid.is_empty(), || format!("refs/heads/main has no oid: {}", http::cut(&row.dump(), 120)))?;
Ok(oid)
};
r.git(&dir, &["checkout", "-q", "-b", "review-me"])?;
std::fs::write(dir.join("CHANGE"), "please review\n").map_err(|e| e.to_string())?;
r.git(&dir, &["add", "CHANGE"])?;
r.git(&dir, &["commit", "-qm", "a change that needs another pair of eyes"])?;
r.git(&dir, &["push", "-q", &remote, "review-me"])?;
r.git(&dir, &["checkout", "-q", "main"])?;
let (s, j, body) = a.post(
"/api/repos/alice/reviewed/pulls",
r#"{"title":"needs another pair of eyes","description":"",
"source_ref":"refs/heads/review-me","target_ref":"refs/heads/main"}"#,
)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 200)))?;
let n = j.path("number").and_then(|x| x.as_f64()).map(|x| x as i64).ok_or("no number")?;
let head = a.get(&format!("/api/repos/alice/reviewed/pulls/{n}/review"))?.1.text("head");
want(!head.is_empty(), || "the review does not say where the branch is".into())?;
let (s, rev, body) = a.post(
&format!("/api/repos/alice/reviewed/pulls/{n}/approve"),
&format!(r#"{{"commit":{}}}"#, esc(&head)),
)?;
want((200..300).contains(&s), || format!("approve answered {s}: {}", http::cut(&body, 250)))?;
want(rev.path("head_is_approved").and_then(|v| v.as_bool()) == Some(true), || {
format!("approved and still not approved: {}", http::cut(&rev.dump(), 250))
})?;
// The rule, written AFTER the approval so the approval itself is
// beyond suspicion, and the merge is what changes.
let (s, _, body) = a.post(
"/api/repos/alice/reviewed/protection",
r#"{"ref_pattern":"refs/heads/main","forbid_self_approval":true}"#,
)?;
want((200..300).contains(&s), || format!("SetProtection answered {s}: {}", http::cut(&body, 250)))?;
let before = main_tip()?;
let (s, _, body) = a.post(&format!("/api/repos/alice/reviewed/pulls/{n}/merge"), "{}")?;
want(s == 400, || format!("a self-approved merge answered {s} under forbid_self_approval: {}", http::cut(&body, 250)))?;
want(body.contains("forbids self-approval"), || {
format!("refused, but not by the self-approval rule: {}", http::cut(&body, 250))
})?;
want(main_tip()? == before, || "the merge was refused and moved main anyway".into())?;
// The other direction: rule gone, the IDENTICAL merge lands — so the
// refusal above was the rule and nothing else.
let (s, body) = a.delete(
"/api/repos/alice/reviewed/protection?ref_pattern=refs%2Fheads%2Fmain",
)?;
want((200..300).contains(&s), || format!("DeleteProtection answered {s}: {}", http::cut(&body, 200)))?;
let (s, _, body) = a.post(&format!("/api/repos/alice/reviewed/pulls/{n}/merge"), "{}")?;
want((200..300).contains(&s), || {
format!("the rule was removed and the identical merge is STILL refused, so the \
row above was not measuring the rule: {s} {}", http::cut(&body, 250))
})?;
want(main_tip()? == head, || "the merge landed and main is not at the approved tip".into())?;
Ok(format!("refused under the rule, merged without it, main at {}", &head[..8.min(head.len())]))
});
go(&mut rows, "the-gate-list-reports-what-is-actually-configured", || {
let (s, j) = a.get("/api/repos/alice/guarded/gates")?;
want(s == 200, || format!("ListGates answered {s}"))?;
let gates = j.path("items").cloned().unwrap_or(j.clone());
let branch = gates
.arr()
.iter()
.find(|g| g.text("name") == "branch-protection")
.cloned()
.ok_or_else(|| format!("no branch-protection gate: {}", http::cut(&gates.dump(), 250)))?;
// The rule was just removed, so the gate must report itself OFF. A
// gate list that says "on" because the code is compiled in tells an
// operator a guard exists where none does.
want(branch.path("enabled").and_then(|v| v.as_bool()) == Some(false), || {
format!("branch-protection reports enabled with no rule configured: {}",
http::cut(&branch.dump(), 200))
})?;
Ok(format!("{} gates, branch-protection off with no rule", gates.arr().len()))
});
// ── the chain: `gunnar verify`, served, with the holes kept honest ────
//
// Three rows, three answers. The signed fixture proves the happy path with
// a REAL `git commit -S` (ssh signing, stock git, the rig's registered
// admin key); the unsigned fixture proves the hole renders as a named hole
// and not as a pass; and the tree head between them is what turns the log
// link from "arithmetic the server did about itself" into evidence.
go(&mut rows, "a-signed-push-and-a-tree-head-set-the-chain-up", || {
let wt = r.work.join("wt-signed");
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
r.git(&wt, &["config", "gpg.format", "ssh"])?;
let key = r.work.join("admin");
r.git(&wt, &["config", "user.signingkey", key.to_str().ok_or("key path")?])?;
std::fs::write(wt.join("src.txt"), "the provable line\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "src.txt"])?;
r.git(&wt, &["commit", "-qm", "a signed commit", "-S"])?;
let head = r.git(&wt, &["rev-parse", "HEAD"])?.trim().to_owned();
r.git(&wt, &["push", "-q", &r.remote("alice/chain-signed"), "main"])?;
r.attest_head()?;
Ok(format!("signed head over the log; head commit {}", &head[..12]))
});
go(&mut rows, "the-chain-over-a-signed-commit-is-unbroken", || {
let (s, j) = a.get("/api/repos/alice/chain-signed/chain?rev=refs%2Fheads%2Fmain&line=src.txt%3A1")?;
want(s == 200, || format!("the chain endpoint answered {s}: {}", http::cut(&j.dump(), 250)))?;
want(j.text("summary") == "unbroken", || {
format!("a fully proven chain reported {:?}: {}", j.text("summary"), http::cut(&j.dump(), 400))
})?;
let links = j.path("links").cloned().unwrap_or(Json::Null);
want(links.arr().len() == 4, || format!("{} links", links.arr().len()))?;
for link in links.arr() {
want(link.text("verdict") == "proven", || {
format!("link {} is {}: {}", link.text("id"), link.text("verdict"), link.text("detail"))
})?;
}
let sig = links.arr().iter().find(|l| l.text("id") == "signature").cloned()
.ok_or("no signature link")?;
// "signed" without "by whom" is not an answer. The rig's admin key is
// registered to the bootstrapped principal, and the verdict names it.
want(sig.text("detail").contains("is signed by"), || {
format!("the signature verdict does not say who signed: {}", sig.text("detail"))
})?;
Ok(format!("4 proven links; {}", http::cut(&sig.text("detail"), 100)))
});
go(&mut rows, "the-chain-over-an-unsigned-commit-names-the-hole", || {
let (s, j) = a.get("/api/repos/alice/uitest/chain?rev=refs%2Fheads%2Fmain&line=README%3A1")?;
want(s == 200, || format!("the chain endpoint answered {s}"))?;
// Not "broken": nothing failed. Not "unbroken": a hole is not a pass.
want(j.text("summary") == "incomplete", || {
format!("an unsigned commit's chain reported {:?}: {}", j.text("summary"), http::cut(&j.dump(), 400))
})?;
let links = j.path("links").cloned().unwrap_or(Json::Null);
let sig = links.arr().iter().find(|l| l.text("id") == "signature").cloned()
.ok_or("no signature link")?;
want(sig.text("verdict") == "unproven", || {
format!("the unsigned commit's signature link is {:?}", sig.text("verdict"))
})?;
want(sig.text("detail").contains("not signed"), || {
format!("the hole is not named in words: {}", sig.text("detail"))
})?;
// …and the rest of the chain still proves, so the ONE hole is the
// signature and this row cannot pass on a chain that never got walked.
for id in ["line", "push", "log"] {
let link = links.arr().iter().find(|l| l.text("id") == id).cloned()
.ok_or_else(|| format!("no {id} link"))?;
want(link.text("verdict") == "proven", || {
format!("link {id} is {}: {}", link.text("verdict"), link.text("detail"))
})?;
}
Ok(format!("incomplete, with the hole named: {}", http::cut(&sig.text("detail"), 100)))
});
// ── blame: which commit wrote each line, and is it signed ─────────────
//
// Built on the same two walks the chain view uses — `Refs.Blame` for the
// commit, `Admin.VerifyChain` for the signature — so the signed fixture's
// line names its commit AND its signer, and the unsigned fixture's line is a
// named hole: the oid is real, the verdict is not proven. Each row asserts
// the ACTUAL attributed oid, never that a row merely came back.
go(&mut rows, "blame-names-the-commit-and-signer-of-a-signed-line", || {
let head = a.get("/api/repos/alice/chain-signed/head")?.1.text("oid");
want(head.len() == 64, || format!("no head oid: {head:?}"))?;
let (s, j) = a.get("/api/repos/alice/chain-signed/blame?rev=refs%2Fheads%2Fmain&path=src.txt")?;
want(s == 200, || format!("blame answered {s}: {}", http::cut(&j.dump(), 200)))?;
let lines = j.path("lines").cloned().unwrap_or(Json::Null);
want(!lines.arr().is_empty(), || "blame returned no lines".into())?;
let first = lines.arr()[0].clone();
// The exact commit, not "some row exists": the line is attributed to the
// head this fixture just signed and pushed.
want(first.text("commit") == head, || {
format!("line 1 is attributed to {:?}, not the head {head}", first.text("commit"))
})?;
want(first.text("signature") == "proven", || {
format!("a signed line is not proven: {}", http::cut(&first.dump(), 200))
})?;
want(first.text("signer").contains("is signed by"), || {
format!("the signer is not named: {:?}", first.text("signer"))
})?;
Ok(format!("line 1 → {} · signed", &head[..12]))
});
go(&mut rows, "blame-over-an-unsigned-line-is-a-named-hole", || {
// The commit that last touched README, from the log, so the hole is
// asserted against the ACTUAL oid and not just "unproven, whatever it is".
let log = a.get("/api/repos/alice/uitest/log?path=README&limit=1")?.1;
let items = log.path("items").or_else(|| log.path("commits")).cloned().unwrap_or(Json::Null);
let readme_commit = items.arr().first().map(|c| c.text("oid")).unwrap_or_default();
let (s, j) = a.get("/api/repos/alice/uitest/blame?rev=refs%2Fheads%2Fmain&path=README")?;
want(s == 200, || format!("blame answered {s}: {}", http::cut(&j.dump(), 200)))?;
let first = j.path("lines").and_then(|l| l.arr().first().cloned())
.ok_or("blame returned no lines")?;
want(first.text("commit").len() == 64, || "no commit oid on the line".into())?;
if readme_commit.len() == 64 {
want(first.text("commit") == readme_commit, || {
format!("line 1 is attributed to {:?}, not README's commit {readme_commit}", first.text("commit"))
})?;
}
// A hole: not "broken" (nothing failed), and not "proven" (no signature).
want(first.text("signature") == "unproven", || {
format!("an unsigned line did not read unproven: {}", http::cut(&first.dump(), 200))
})?;
Ok(format!("line 1 → {} · a named hole", &first.text("commit")[..12]))
});
// ── search: a literal string across the latest revision ───────────────
//
// Not a hollow ">= 0 results". The seeded line is "the provable line" in
// src.txt, so the query for "provable" must come back with THAT file at
// THAT line — the specific hit is the whole assertion.
go(&mut rows, "search-returns-the-file-and-line-of-a-known-string", || {
let (s, j) = a.get("/api/repos/alice/chain-signed/search?q=provable")?;
want(s == 200, || format!("search answered {s}: {}", http::cut(&j.dump(), 200)))?;
let matches = j.path("matches").cloned().unwrap_or(Json::Null);
let hit = matches.arr().iter().find(|m| m.text("path") == "src.txt").cloned()
.ok_or_else(|| format!("no hit in src.txt: {}", http::cut(&matches.dump(), 250)))?;
want(hit.path("line").and_then(|v| v.as_f64()) == Some(1.0), || {
format!("the hit is not on line 1: {}", http::cut(&hit.dump(), 200))
})?;
want(hit.text("text").contains("provable"), || {
format!("the matching line does not contain the string: {:?}", hit.text("text"))
})?;
Ok(format!("src.txt:1 — {}", http::cut(&hit.text("text"), 60)))
});
go(&mut rows, "search-is-empty-for-a-string-that-is-not-there", || {
// The other half: a string nobody wrote comes back with no matches, so
// the row above cannot be passing on a search that matches everything.
let (s, j) = a.get("/api/repos/alice/chain-signed/search?q=zzz_not_present_anywhere_zzz")?;
want(s == 200, || format!("search answered {s}"))?;
want(j.path("matches").map(|m| m.arr().is_empty()).unwrap_or(false), || {
format!("a string nobody wrote still matched: {}", http::cut(&j.dump(), 200))
})?;
Ok("no matches, as there should be none".into())
});
// ── inline review comments: a comment that follows the code ───────────
//
// A comment anchored to (path, line, blob_oid). The blob is the point: the
// comment is filed against the exact file version the reviewer saw, so it
// follows the code across a rebase that leaves the file alone. Here it is
// filed through the console, read back with its anchor intact, and (in the
// browser rows below) rendered on its line in the diff.
go(&mut rows, "an-inline-topic-and-a-pull-to-review", || {
let dir = r.work.join("inline");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
r.git(&dir, &["clone", "-q", &r.remote("alice/uitest"), "wt"])?;
let wt = dir.join("wt");
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
r.git(&wt, &["checkout", "-q", "-b", "inline-topic"])?;
std::fs::write(wt.join("src/lib.rs"), "pub fn hello() {}\npub fn added_line() {}\n")
.map_err(|e| e.to_string())?;
r.git(&wt, &["add", "src/lib.rs"])?;
r.git(&wt, &["commit", "-qm", "add a line to comment on"])?;
r.git(&wt, &["push", "-q", &r.remote("alice/uitest"), "inline-topic"])?;
// Re-attest after this push. The transparency-log link of the chain
// view proves a push is covered by a SIGNED head; a push that lands
// after the last attest leaves the current head unsigned, which would
// turn `chain-signed`'s already-proven chain INCOMPLETE for the browser
// rows that walk it later. The signed-chain fixture re-attests after its
// own push for exactly this reason — so must this one.
r.attest_head()?;
let (s, j, body) = a.post(
"/api/repos/alice/uitest/pulls",
r#"{"title":"inline comment demo","description":"for the inline-comment rows",
"source_ref":"refs/heads/inline-topic","target_ref":"refs/heads/main"}"#,
)?;
want((200..300).contains(&s), || format!("create answered {s}: {}", http::cut(&body, 200)))?;
let number = j.path("number").and_then(|n| n.as_f64()).map(|n| format!("{}", n as i64))
.ok_or_else(|| format!("no number: {}", http::cut(&j.dump(), 200)))?;
*inline_pull.borrow_mut() = number.clone();
// The blob of src/lib.rs on the NEW side, from the diff — exactly what
// the diff page will render the comment against.
let (_, diff) = a.get(
"/api/repos/alice/uitest/diff?from=refs%2Fheads%2Fmain&to=refs%2Fheads%2Finline-topic",
)?;
let change = diff.path("changes").and_then(|c| {
c.arr().iter().find(|ch| ch.text("path") == "src/lib.rs").cloned()
}).ok_or_else(|| format!("no change for src/lib.rs: {}", http::cut(&diff.dump(), 250)))?;
let blob = change.text("new_oid");
want(blob.len() == 64, || format!("no new_oid on the change: {}", http::cut(&change.dump(), 200)))?;
*inline_blob.borrow_mut() = blob.clone();
Ok(format!("pull #{number}, src/lib.rs @ {}", &blob[..12]))
});
go(&mut rows, "an-inline-comment-anchors-to-the-blob-and-line", || {
let n = inline_pull.borrow().clone();
let blob = inline_blob.borrow().clone();
want(!n.is_empty() && !blob.is_empty(), || "the inline pull was not set up".into())?;
let (s, _, body) = a.post(
&format!("/api/repos/alice/uitest/pulls/{n}/comments"),
&format!(r#"{{"path":"src/lib.rs","line":2,"blob_oid":{},"body":"this new fn needs a test"}}"#, esc(&blob)),
)?;
want((200..300).contains(&s), || format!("comment answered {s}: {}", http::cut(&body, 250)))?;
// Read back: the anchor is intact — the SAME blob and line, not the pull
// number standing in for them. That is what lets it follow the code.
let (_, list) = a.get(&format!("/api/repos/alice/uitest/pulls/{n}/comments"))?;
let mine = list.arr().iter().find(|c| c.text("body").contains("needs a test")).cloned()
.ok_or_else(|| format!("the comment did not read back: {}", http::cut(&list.dump(), 250)))?;
want(mine.text("path") == "src/lib.rs", || format!("wrong path: {:?}", mine.text("path")))?;
want(mine.path("line").and_then(|v| v.as_f64()) == Some(2.0), || {
format!("wrong line: {}", http::cut(&mine.dump(), 200))
})?;
want(mine.text("blob_oid") == blob, || {
format!("the comment is not anchored to the blob it was made on: {}", http::cut(&mine.dump(), 200))
})?;
Ok(format!("#{n} src/lib.rs:2 @ {}", &blob[..12]))
});
// ── the first admin is the operator's account (2026-10-04) ─────────────
//
// test2.gunnar.rs, the fresh install of 2026-10-04: at 07:37 a first push
// with the installer's admin key into `rickard/t2-scratch` conjured the
// namespace `rickard`, with no owner. At 08:15 the owner registered his
// YubiKey with the break-glass token as `rickard`; the console asked gunnar
// `AddPrincipal rickard`, gunnar refused it ("namespace already exists"), and
// the passkey bound to nothing — "gunnar has no principal bound to this
// session", no Account page, no Add SSH key. Here the same morning, with
// this rig's names: alice (the admin key) pushes `bob/t2-scratch` first, and
// the browser below registers `bob` with the token.
go(&mut rows, "a-first-push-conjures-the-operators-namespace-first", || {
let wt = r.work.join("conjure");
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
// SHA-1 on purpose: gunnar's first-push door infers SHA-1, as stock
// `git init` makes it, and it is the door that conjures a namespace.
r.git(&wt, &["init", "-q", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(wt.join("SCRATCH"), "pushed before anybody registered\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "SCRATCH"])?;
r.git(&wt, &["commit", "-qm", "scratch"])?;
r.git(&wt, &["push", "-q", &r.remote("bob/t2-scratch"), "main"])?;
// Re-attest after this push, as every fixture push before a browser
// chain row does: a push after the last signed head leaves the log's
// head unsigned, and `chain-signed`'s proven chain reads INCOMPLETE in
// the browser rows that walk it later.
r.attest_head()?;
let spaces = r.control(&["namespace", "list"])?;
want(spaces.lines().any(|l| l.trim() == "bob"), || {
format!("the first push did not conjure `bob`: {}", http::cut(&spaces, 200))
})?;
let users = r.control(&["user", "list"])?;
want(!users.lines().any(|l| l.split('\t').next() == Some("bob")), || {
format!("a principal `bob` already exists, so this is not test2's morning: {}", http::cut(&users, 200))
})?;
Ok("bob/t2-scratch pushed by the admin key: namespace `bob` exists, no principal `bob`".into())
});
// ── the installer's word: the operator, and the break-glass token as his
// first invite (Stage L) ────────────────────────────────────────────────
//
// On an estate the installer feeds gunnar `operator_name` and the token's
// hash (`operator_invite_sha256`); this rig is a box no installer fed, so
// it says the same through the control plane — the token from a file,
// hashed by gunnar-cli, never on a command line. gunnar claims `bob` over
// the namespace the push above conjured (ADOPTED, never refused) and keeps
// the hash as the invite for his first passkey; the browser's
// registration below is `Access.WebRegister` with the token, decided by
// gunnar. A gunnar from before Stage L has no such verb: the row says so,
// and the console's old ceremony runs.
go(&mut rows, "the-installer-claims-the-operator-and-his-first-invite", || {
let file = r.work.join("break-glass.token");
std::fs::write(&file, format!("{}\n", r.token)).map_err(|e| e.to_string())?;
let said = r.control(&["account", "claim-operator", "bob", "--bootstrap-file", &file.display().to_string()])?;
let _ = std::fs::remove_file(&file);
want(said.contains("operator bob") && said.contains("adopted"), || {
format!("gunnar did not claim `bob` over the conjured namespace: {}", http::cut(&said, 200))
})?;
want(said.contains("first invite"), || format!("no first invite was recorded: {}", http::cut(&said, 200)))?;
let users = r.control(&["user", "list"])?;
want(users.lines().any(|l| l.split('\t').next() == Some("bob")), || {
format!("gunnar holds no principal `bob`: {}", http::cut(&users, 200))
})?;
Ok(format!("{} — gunnar holds `bob` and the token's hash as his first invite", said.trim()))
});
go(&mut rows, "no-passkey-exists-yet", || {
let (_, j) = a.get("/auth/passkey/credentials")?;
want(j.path("credentials").map(|c| c.arr().is_empty()).unwrap_or(false), || {
format!("{}", http::cut(&j.dump(), 200))
})?;
want(j.path("enabled_admins").and_then(|v| v.as_f64()) == Some(0.0), || "admins already exist".into())?;
Ok("0 credentials, 0 admins".into())
});
let Some(chrome_bin) = chrome_bin else {
return rows;
};
browser(
r,
&mut rows,
chrome_bin,
authenticator,
&inline_pull.borrow(),
&inline_blob.borrow(),
);
rows
}
/// The browser half: the ceremonies a bearer token can never reach.
/// **A stranger clicks "Log in" and must land on the server's login page.**
///
/// A stranger on a public page (the repository list, or a public repository's
/// graph) is offered exactly one door: the "Log in" button in the top bar. It
/// is a plain anchor to `/login`, which the SERVER renders — the console has no
/// `/login` route of its own. A same-origin anchor inside the console is the
/// router's to intercept, and an intercepted click on a route the console does
/// not have moves the URL bar and nothing else. Measured on gunnar.rs
/// 2026-09-10 from /repo/alice/linux/graph: "Log in" did nothing. So this
/// clicks the button the way a person does and asks the BROWSER where it ended
/// up and whether the login page's own element is there — not the URL alone,
/// which the router changes either way.
/// **The login page's one button is the passkey sign-in** (LOGIN-1, owner 2026-10-27: `/login`
/// IS the login, its one button says "Log in" and starts the passkey ceremony). A console from
/// before LOGIN-1 named the passkey on it, which a `--live` deployment may still serve.
fn is_the_login_button(label: &str) -> bool {
label.trim() == "Log in" || label.to_lowercase().contains("passkey")
}
/// **A passkey registered through an invite link** in `sess`, named `label`. Since the owner's
/// 2026-10-21 order a link's registration signs the person in at once while the browser still
/// holds the press (the page then leaves `/login`, so its `#msg` is gone), and otherwise says
/// it registered and leaves "Log in" as the way on. Either way the page's word is checked, and
/// the answer says which happened: `true` when the person is signed in already.
pub(crate) fn register_through_link(c: &mut Cdp, sess: &str, url: &str, label: &str) -> Result<bool, String> {
c.navigate(sess, url)?;
c.wait_for(sess, "!!document.getElementById('register')", 30_000)?;
c.eval(
sess,
&format!(
"(() => {{ document.getElementById('label').value = {};
document.getElementById('register').click(); return 'clicked'; }})()",
esc(label)
),
)?;
const DONE: &str = "fetch('/auth/status').then(r => r.json()).then(j => {
const m = document.getElementById('msg');
return j.authenticated === true || !!m?.classList.contains('err')
|| (!!m?.classList.contains('ok') && m.textContent.includes('registered') && !m.textContent.includes('Signing you in'));
})";
c.wait_for(sess, DONE, 30_000).map_err(|e| {
let at = c.eval_str(sess, "location.pathname + location.search.replace(/invite=[^&]*/, 'invite=…')").unwrap_or_default();
let said = c.eval_str(sess, "document.getElementById('msg')?.textContent ?? '(no #msg)'").unwrap_or_default();
format!("the registration never settled at {at}, the page said {said:?} ({e})")
})?;
let st = c.eval(sess, "fetch('/auth/status').then(r => r.json())")?;
if st.path("authenticated").and_then(|v| v.as_bool()) == Some(true) {
return Ok(true);
}
let said = c.eval_str(sess, "document.getElementById('msg')?.textContent ?? ''")?;
want(said.contains("registered"), || format!("the page said {said:?}"))?;
Ok(false)
}
fn a_stranger_clicks_log_in(c: &mut Cdp, sess: &str, origin: &str) -> Result<String, String> {
c.navigate(sess, &format!("{origin}/repos"))?;
let _ = c.drain_page_errors();
// A `public-site` console with its switch off shows a guest only `/` (GUN-GUEST,
// `auth::middleware::GuestsOff`): the server sends the stranger to the login page at once,
// with `next`. That is the same arrival without the click (GUN-FIELD-TEST, 2026-10-31).
c.wait_for(
sess,
"!!document.querySelector('a.btn.primary[href^=\"/login\"]') || (location.pathname === '/login' && !!document.getElementById('login'))",
30_000,
)
.map_err(|e| format!("neither a Log in button nor the login page for a stranger on /repos ({e})"))?;
if c.eval_str(sess, "location.pathname")? == "/login" {
let label = c.eval_str(sess, "document.getElementById('login')?.textContent ?? ''")?;
want(is_the_login_button(&label), || format!("the login page shows no passkey button: {}", http::cut(&label, 100)))?;
let next = c.eval_str(sess, "new URLSearchParams(location.search).get('next') ?? ''")?;
want(next == "/repos", || format!("the login page was not told where to return to: next={next:?}"))?;
return Ok("guests see only / here: sent to the server's login page at once, next=/repos".into());
}
c.wait_for(sess, "!!document.querySelector('a.btn.primary[href^=\"/login\"]')", 30_000)
.map_err(|e| {
let at = c.eval_str(sess, "location.pathname + location.search").unwrap_or_default();
let header = c.eval_str(sess, "document.querySelector('header')?.innerText ?? document.body.innerText").unwrap_or_default();
format!("no Log in button for a stranger on /repos: at {at}, the page says {} ({e})", http::cut(&header.replace('\n', " "), 300))
})?;
c.eval(sess, "document.querySelector('a.btn.primary[href^=\"/login\"]').click(); true")?;
c.wait_for(sess, "location.pathname === '/login' && !!document.getElementById('login')", 15_000)
.map_err(|e| {
let path = c.eval_str(sess, "location.pathname").unwrap_or_default();
let login = c.eval(sess, "!!document.getElementById('login')").map(|v| v.dump()).unwrap_or_default();
format!("after clicking Log in the browser is at {path} with a login element: {login} ({e})")
})?;
let label = c.eval_str(sess, "document.getElementById('login')?.textContent ?? ''")?;
want(is_the_login_button(&label), || format!("the login page shows no passkey button: {}", http::cut(&label, 100)))?;
let next = c.eval_str(sess, "new URLSearchParams(location.search).get('next') ?? ''")?;
want(next == "/repos", || format!("the login page was not told where to return to: next={next:?}"))?;
Ok("clicked Log in, landed on the server's login page, next=/repos".into())
}
/// GUN-MERGE-ENGINE (2026-11-01): make `branch` off the server's main of
/// `alice/pr-web` with `file`, then move the server's main with `main_file`, open
/// the pull request in the console, approve its head in the browser, and answer
/// its number.
fn pr_behind_main(
r: &Rig,
c: &mut Cdp,
sess: &str,
branch: &str,
file: (&str, &str),
main_file: (&str, &str),
) -> Result<String, String> {
let dir = r.work.join("pr-web");
let remote = r.remote("alice/pr-web");
r.git(&dir, &["fetch", "-q", &remote, "main"])?;
r.git(&dir, &["checkout", "-q", "-B", "main", "FETCH_HEAD"])?;
r.git(&dir, &["checkout", "-q", "-b", branch])?;
std::fs::write(dir.join(file.0), file.1).map_err(|e| e.to_string())?;
r.git(&dir, &["add", file.0])?;
r.git(&dir, &["commit", "-qm", &format!("{branch}: a change made while main moved on")])?;
r.git(&dir, &["push", "-q", &remote, branch])?;
r.git(&dir, &["checkout", "-q", "main"])?;
std::fs::write(dir.join(main_file.0), main_file.1).map_err(|e| e.to_string())?;
r.git(&dir, &["add", main_file.0])?;
r.git(&dir, &["commit", "-qm", &format!("main moves on past {branch}")])?;
r.git(&dir, &["push", "-q", &remote, "main"])?;
let opened = c.eval(
sess,
&format!(
"fetch('/api/repos/alice/pr-web/pulls', {{method:'POST',
headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{title:'{branch} behind main', description:'',
source_ref:'refs/heads/{branch}', target_ref:'refs/heads/main'}})}})
.then(r => r.json())"
),
)?;
let number = opened
.path("number")
.and_then(|n| n.as_f64())
.map(|n| format!("{}", n as i64))
.ok_or_else(|| format!("no pull number: {}", http::cut(&opened.dump(), 200)))?;
c.navigate(sess, &format!("{}/repo/alice/pr-web/pulls/{number}", r.origin()))?;
c.wait_for(
sess,
"[...document.querySelectorAll('button')].some(e => e.textContent.trim() === 'Approve this commit')",
30_000,
)?;
c.eval_str(
sess,
"[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Approve this commit').click()",
)?;
c.wait_for(
sess,
"(() => { const t=(document.querySelector('main')||document.body).innerText;
return t.includes('approved at this commit') && !t.includes('not approved at this commit'); })()",
20_000,
)
.map_err(|e| format!("the review badge did not flip to approved: {e}"))?;
Ok(number)
}
fn browser(
r: &Rig,
rows: &mut Vec<Row>,
chrome_bin: &str,
authenticator: bool,
inline_pull: &str,
inline_blob: &str,
) {
let port = match std::net::TcpListener::bind("127.0.0.1:0").and_then(|l| l.local_addr()) {
Ok(a) => a.port(),
Err(e) => {
go(rows, "chrome-starts-headless", || Err(e.to_string()));
return;
}
};
let profile = r.work.join("chrome");
let mut chrome = match Chrome::launch(chrome_bin, Path::new(&profile), port) {
Ok(c) => {
go(rows, "chrome-starts-headless", || Ok(format!("debugging on {port}")));
c
}
Err(e) => {
go(rows, "chrome-starts-headless", || Err(e));
return;
}
};
let mut c = match Cdp::attach(port) {
Ok(c) => c,
Err(e) => {
go(rows, "devtools-protocol-attaches", || Err(e));
chrome.kill();
return;
}
};
let sess = match c.page() {
Ok(s) => {
go(rows, "devtools-protocol-attaches", || Ok("page attached".into()));
s
}
Err(e) => {
go(rows, "devtools-protocol-attaches", || Err(e));
chrome.kill();
return;
}
};
let auth = std::cell::RefCell::new(String::new());
if !go(rows, "a-virtual-authenticator-is-installed", || {
if !authenticator {
// --no-authenticator: the suite's own red. Every passkey situation
// below must fail without this, and if any of them still passes it
// was never testing the ceremony: a guard that cannot fail is worth
// nothing. Run it after changing anything in this file.
return Err("skipped deliberately (--no-authenticator)".into());
}
let id = c.add_authenticator(&sess)?;
want(!id.is_empty(), || "no authenticatorId".into())?;
*auth.borrow_mut() = id.clone();
Ok(format!("authenticator {id}"))
}) && authenticator
{
chrome.kill();
return;
}
let auth = auth.into_inner();
go(rows, "the-login-page-offers-a-passkey", || {
c.navigate(&sess, &format!("{}/login", r.origin()))?;
let label = c.eval_str(&sess, "document.getElementById('login').textContent")?;
want(is_the_login_button(&label), || format!("the button says {label:?}"))?;
let supported = c.eval(&sess, "!!window.PublicKeyCredential")?;
want(supported.as_bool() == Some(true), || "the browser reports no WebAuthn".into())?;
Ok(label)
});
go(rows, "the-authenticator-starts-with-no-credential", || {
let creds = c.credentials(&sess, &auth)?;
want(creds.is_empty(), || format!("{} credentials already", creds.len()))?;
Ok("0 credentials".into())
});
let registered = go(rows, "a-passkey-registers-with-the-admin-token", || {
c.eval(
&sess,
&format!(
"(() => {{ document.getElementById('reg').open = true;
document.getElementById('token').value = {};
document.getElementById('label').value = 'virtual';
document.getElementById('register').click(); return 'clicked'; }})()",
esc(&r.token)
),
)?;
c.wait_for(
&sess,
"document.getElementById('msg').classList.contains('ok') || document.getElementById('msg').classList.contains('err')",
30_000,
)?;
let msg = c.eval_str(&sess, "document.getElementById('msg').textContent")?;
want(msg.contains("registered"), || format!("the page said {msg:?}"))?;
// The page's word is not the evidence. The authenticator's is.
let creds = c.credentials(&sess, &auth)?;
want(creds.len() == 1, || format!("the authenticator holds {} credentials", creds.len()))?;
want(creds[0].text("rpId") == "localhost", || format!("rpId {:?}", creds[0].text("rpId")))?;
want(creds[0].path("isResidentCredential").and_then(|v| v.as_bool()).is_some(), || {
format!("no resident flag: {}", http::cut(&creds[0].dump(), 200))
})?;
Ok(format!("1 credential for rp {}", creds[0].text("rpId")))
});
if registered {
go(rows, "the-admin-token-retires-once-an-admin-passkey-exists", || {
// The situation this whole ordering exists for: the break-glass
// token worked in every row above, and must stop working here.
// `/api/storage` and not `/api/repos`: the repository list is
// served to a stranger as the guest, so a spent token there reads
// 200 with the public subset and proves nothing about the token.
// Storage is on no anonymous allow-list; only a credential opens it.
let h = [("Authorization", format!("Bearer {}", r.token))];
let resp = http::req(&r.ui_addr(), "GET", "/api/storage", &h, None)?;
want(resp.status == 401, || {
format!("the token still opens /api/storage: {} {}", resp.status, http::cut(&resp.body, 120))
})?;
Ok("401 — the break-glass token is spent".into())
});
}
go(rows, "the-passkey-logs-in", || {
// The registration's answer carried the core's session already, and /login sends a
// signed-in browser on: out first, so this row is the passkey's own login, after the
// logout's second (GHUNT-12).
c.eval(&sess, "fetch('/auth/logout', {method:'POST'}).then(r => r.status)")?;
std::thread::sleep(std::time::Duration::from_millis(1_100));
c.navigate(&sess, &format!("{}/login", r.origin()))?;
c.wait_for(&sess, "!!document.getElementById('login')", 30_000)?;
c.eval(&sess, "document.getElementById('login').click()")?;
c.wait_for(&sess, SETTLED, 30_000)?;
let st = c.eval(&sess, "fetch('/auth/status').then(r => r.json())")?;
want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || {
format!("not authenticated: {}", http::cut(&st.dump(), 200))
})?;
want(st.text("user") == "bob", || format!("logged in as {:?}", st.text("user")))?;
want(st.text("role") == "admin", || format!("role {:?}", st.text("role")))?;
Ok(format!("{} as {}", st.text("user"), st.text("role")))
});
// ── the first admin passkey IS the operator's account (2026-10-04) ────
//
// Owner: "a YubiKey is used to register me as an admin user. I can THEN
// select to ADD ssh keys to it", and "the UI shall not DO anything such as
// create a user — it shall CALL gunnar-server". The token's registration
// above made the console ask gunnar `Accounts.ClaimOperator`; gunnar made
// the account (over the namespace the first push conjured) and the passkey
// is bound to it. Every assertion is gunnar's word or the page's DOM, never
// the console's own claim about itself.
go(rows, "the-first-admin-passkey-is-the-operators-account", || {
let users = r.control(&["user", "list"])?;
want(users.lines().any(|l| l.split('\t').next() == Some("bob")), || {
format!("gunnar holds no principal `bob` after the first admin registered — test2's morning: {}", http::cut(&users, 200))
})?;
let repos = r.control(&["repo", "list"])?;
want(repos.contains("bob/t2-scratch"), || {
format!("the conjured namespace was not ADOPTED with what was pushed into it: {}", http::cut(&repos, 200))
})?;
let key = c.eval(&sess, "fetch('/api/account/console-key').then(r => r.json())")?;
want(key.text("principal_id") == "bob", || {
format!("the session is bound to {:?}, not the operator's account: {}", key.text("principal_id"), http::cut(&key.dump(), 200))
})?;
want(key.text("state") == "enrolled", || {
format!("the session is {:?}, not enrolled (no attestation is owed on a fresh account): {}", key.text("state"), http::cut(&key.dump(), 240))
})?;
// IDENTITY-HOST (gunnar-ui 7a0c3a8): the person's calls carry the identity core's
// session, never a key the console signs with, so the console enrols NO SSH key of
// its own on the operator. His keys are the ones he adds (the next row).
let held = r.control(&["user", "keys", "bob"])?;
want(held.lines().filter(|l| l.starts_with("bob\t")).count() == 0, || {
format!("gunnar holds a key on bob the operator never added (the console enrols none since IDENTITY-HOST): {}", http::cut(&held, 240))
})?;
Ok("bob is a principal, bob/t2-scratch adopted, the passkey bound to his account, no key enrolled for him".into())
});
go(rows, "the-account-page-reads-as-the-operator", || {
c.navigate(&sess, &format!("{}/account", r.origin()))?;
c.wait_for(&sess, "!!document.getElementById('own-key')", 30_000).map_err(|e| {
let body = c.eval_str(&sess, "document.body ? document.body.innerText : ''").unwrap_or_default();
format!("You → Account drew no SSH keys panel ({e}): {}", http::cut(&body, 240))
})?;
// One more round trip for the console-key panel to resolve, then the
// page must hold neither of the two panels the owner saw.
std::thread::sleep(std::time::Duration::from_millis(1_500));
let body = c.eval_str(&sess, "document.body.innerText")?;
for refused in ["This console needs your word", "This console cannot read as you", "no principal bound to this session", "an operator creates it"] {
want(!body.contains(refused), || format!("the account page says {refused:?}: {}", http::cut(&body, 240)))?;
}
Ok("SSH keys panel drawn; no attestation panel, no unbound-session refusal".into())
});
go(rows, "the-operator-adds-an-ssh-key-on-his-account-page", || {
let laptop = r.work.join("operator-laptop");
let _ = std::fs::remove_file(&laptop);
let _ = std::fs::remove_file(laptop.with_extension("pub"));
let made = std::process::Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "bob-laptop", "-f"])
.arg(&laptop)
.status()
.map_err(|e| format!("ssh-keygen: {e}"))?;
want(made.success(), || "ssh-keygen failed".into())?;
let line = std::fs::read_to_string(laptop.with_extension("pub")).map_err(|e| e.to_string())?.trim().to_owned();
c.eval(
&sess,
&format!(
"(() => {{ const i = document.getElementById('own-key');
i.value = {};
i.dispatchEvent(new Event('input', {{bubbles:true}}));
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Add key');
if (!b) return 'no Add key button'; b.click(); return 'clicked'; }})()",
esc(&line)
),
)?;
// gunnar's word first: the key is on bob.
let fingerprint = std::process::Command::new("ssh-keygen")
.arg("-lf")
.arg(laptop.with_extension("pub"))
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().nth(1).unwrap_or_default().to_owned())
.map_err(|e| format!("ssh-keygen -l: {e}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut held = String::new();
while std::time::Instant::now() < deadline {
held = r.control(&["user", "keys", "bob"])?;
if held.contains(&fingerprint) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
let refusal = c.eval_str(&sess, "[...document.querySelectorAll('.failure, [role=alert]')].map(e => e.innerText).join(' | ')").unwrap_or_default();
want(held.contains(&fingerprint), || {
format!("gunnar does not hold the added key {fingerprint} on bob after 30 s (the page said: {refusal:?}): {}", http::cut(&held, 240))
})?;
let n = held.lines().filter(|l| l.starts_with("bob\t")).count();
want(n == 1, || format!("gunnar should hold the laptop key alone on bob (the console enrols none of its own), holds {n}: {}", http::cut(&held, 240)))?;
// Then the page: the SSH keys table lists it.
c.wait_for(&sess, &format!("document.body.innerText.includes({})", esc(&fingerprint)), 30_000).map_err(|e| {
let body = c.eval_str(&sess, "document.body ? document.body.innerText : ''").unwrap_or_default();
let at = body.find("SSH keys").unwrap_or(0);
format!("the added key {fingerprint} never appeared on the page ({e}): {}", http::cut(&body[at..], 400))
})?;
// And it is HIS key now: git over SSH with it is answered as bob.
let dir = r.work.join("as-bob");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let _ = std::fs::remove_dir_all(dir.join("scratch"));
r.git_as(&laptop, &dir, &["clone", "-q", &r.remote_as("bob", "bob/t2-scratch"), "scratch"])?;
Ok("Add key on You → Account: gunnar holds it on bob, and bob clones his own repository with it".into())
});
go(rows, "the-session-lists-the-credential-it-logged-in-with", || {
let j = c.eval(&sess, "fetch('/auth/passkey/credentials').then(r => r.json())")?;
let creds = j.path("credentials").map(|c| c.arr().to_vec()).unwrap_or_default();
want(creds.len() == 1, || format!("{} credentials", creds.len()))?;
want(creds[0].text("label") == "virtual", || format!("label {:?}", creds[0].text("label")))?;
want(creds[0].text("user") == "bob", || format!("user {:?}", creds[0].text("user")))?;
want(j.path("enabled_admins").and_then(|v| v.as_f64()) == Some(1.0), || {
format!("enabled_admins {}", j.text("enabled_admins"))
})?;
Ok(format!("{} ({})", creds[0].text("label"), creds[0].text("role")))
});
// The invite the break-glass token minted (`the-break-glass-token-mints-an-invite-link`),
// as the identity core lists it to the admin's session, then withdrawn by that session.
let token_invite = std::cell::RefCell::new(String::new());
go(rows, "an-invite-is-listed-open-to-the-admin", || {
let list = c.eval(&sess, "fetch('/auth/passkey/invites').then(r => r.json())")?;
let kims = list
.path("invites")
.and_then(|i| i.arr().iter().find(|e| e.text("user") == "kim").cloned())
.ok_or_else(|| format!("kim's invite is not listed to the admin: {}", http::cut(&list.dump(), 200)))?;
want(kims.path("open").and_then(|o| o.as_bool()) == Some(true), || "listed but not open".into())?;
want(kims.text("role") == "viewer", || format!("role {}", kims.text("role")))?;
let id = kims.text("id");
want(!id.is_empty(), || "listed with no id".into())?;
*token_invite.borrow_mut() = id.clone();
Ok(format!("invite {} for kim, viewer, open", &id[..8.min(id.len())]))
});
go(rows, "a-revoked-invite-stops-being-open", || {
let id = token_invite.borrow().clone();
want(!id.is_empty(), || "no invite was listed".into())?;
let j = c.eval(
&sess,
&format!(
"fetch('/auth/passkey/invites/revoke', {{method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{id: {}}})}}).then(async r => ({{status: r.status, body: await r.text()}}))",
esc(&id)
),
)?;
want(j.path("status").and_then(|s| s.as_f64()) == Some(200.0), || {
format!("revoke answered {}: {}", j.text("status"), http::cut(&j.text("body"), 120))
})?;
// The core lists OPEN invites only: a withdrawn one is gone from the list.
let list = c.eval(&sess, "fetch('/auth/passkey/invites').then(r => r.json())")?;
let still = list.path("invites").is_some_and(|i| i.arr().iter().any(|e| e.text("id") == id));
want(!still, || format!("still open after revoke: {}", http::cut(&list.dump(), 200)))?;
Ok("withdrawn at the identity core, and no longer open".into())
});
go(rows, "the-logged-in-session-reads-the-api-with-no-token", || {
let j = c.eval(&sess, "fetch('/api/repos').then(r => r.json())")?;
want(
j.path("items").map(|i| i.arr().iter().any(|e| e.text("path") == "alice/uitest")).unwrap_or(false),
|| format!("the console does not list the pushed repo: {}", http::cut(&j.dump(), 200)),
)?;
Ok("the cookie is the credential now".into())
});
go(rows, "the-console-renders-the-pushed-repository", || {
c.navigate(&sess, &r.origin())?;
// The landing page is the Overview; the repositories are one view
// away. Clicking the console's own navigation is the point of driving
// a browser at all — a direct URL would skip what a user actually does.
// Wait for the console to have drawn its navigation before clicking it.
// Without this the click races the SPA's first paint and the row fails
// as "no Repositories link" perhaps one run in three — a flake that
// would have been read as a product bug.
c.wait_for(
&sess,
"[...document.querySelectorAll('a,button')].some(e => e.textContent.trim() === 'Repositories')",
30_000,
)?;
let nav = c.eval_str(
&sess,
"(() => { const hit = [...document.querySelectorAll('a,button')]
.find(e => e.textContent.trim() === 'Repositories');
if (!hit) return 'no nav item'; hit.click(); return 'clicked'; })()",
)?;
want(nav == "clicked", || format!("the console has no Repositories link"))?;
// A timeout that only says "the text never appeared" cannot be acted
// on, so this polls itself and fails with what the page DID hold.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut seen = String::new();
while std::time::Instant::now() < deadline {
seen = c.eval_str(&sess, "document.body ? document.body.innerText : ''")?;
if seen.contains("uitest") {
return Ok(http::cut(&seen, 110));
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
let url = c.eval_str(&sess, "location.href")?;
let title = c.eval_str(&sess, "document.title")?;
Err(format!(
"after 30 s at {url} (title {title:?}) the page held: {}",
http::cut(&seen, 220)
))
});
// ── the chain page: every verdict in words, and a hole IS a verdict ───
//
// The API rows above proved what gunnar answers; these prove what a person
// SEES. The one thing a provenance page must never do is render a hole as
// anything quieter than a hole, so the unsigned arm asserts the words, not
// just the row count.
go(rows, "the-chain-page-shows-every-link-proven", || {
c.navigate(
&sess,
&format!(
"{}/repo/alice/chain-signed/chain?rev=refs%2Fheads%2Fmain&line=src.txt%3A1",
r.origin()
),
)?;
c.wait_for(
&sess,
"document.querySelectorAll('tr[data-chain-link]').length >= 4",
30_000,
)?;
let verdict = c.eval_str(
&sess,
"document.querySelector('[data-chain-summary]')?.closest('.panel')?.innerText ?? ''",
)?;
want(verdict.contains("UNBROKEN") && verdict.contains("Every link is proven"), || {
format!("the summary does not say UNBROKEN: {}", http::cut(&verdict, 200))
})?;
let holes = c.eval_str(
&sess,
"String(document.querySelectorAll('tr[data-chain-verdict=broken], \
tr[data-chain-verdict=unproven]').length)",
)?;
want(holes.trim() == "0", || format!("{} link(s) not proven", holes.trim()))?;
let sig = c.eval_str(
&sess,
"document.querySelector('tr[data-chain-link=signature]')?.innerText ?? ''",
)?;
want(sig.contains("proven") && sig.contains("is signed by"), || {
format!("the signature row does not name the signer: {}", http::cut(&sig, 200))
})?;
Ok(format!("UNBROKEN; {}", http::cut(&sig.replace('\n', " · "), 110)))
});
go(rows, "the-chain-page-renders-the-unsigned-hole-in-words", || {
c.navigate(
&sess,
&format!(
"{}/repo/alice/uitest/chain?rev=refs%2Fheads%2Fmain&line=README%3A1",
r.origin()
),
)?;
c.wait_for(
&sess,
"document.querySelectorAll('tr[data-chain-link]').length >= 4",
30_000,
)?;
let sig = c.eval_str(
&sess,
"document.querySelector('tr[data-chain-link=signature]')?.innerText ?? ''",
)?;
want(sig.contains("unproven"), || {
format!("the unsigned commit's signature row does not read unproven: {}", http::cut(&sig, 200))
})?;
want(sig.contains("not signed"), || {
format!("the hole is a colour with no words: {}", http::cut(&sig, 200))
})?;
let verdict = c.eval_str(
&sess,
"document.querySelector('[data-chain-summary]')?.closest('.panel')?.innerText ?? ''",
)?;
want(verdict.contains("INCOMPLETE") && verdict.contains("hole"), || {
format!("the summary does not call the hole a hole: {}", http::cut(&verdict, 200))
})?;
Ok(format!("the hole in words: {}", http::cut(&sig.replace('\n', " · "), 110)))
});
// ── the blame page: each line coloured by its commit, signer named ────
//
// The API row proved what gunnar answers; this proves what a person SEES.
// It asserts the ACTUAL oid the first line is coloured by — not that a row
// rendered — and that the signature reads proven, so it cannot pass on a
// page that drew a grey unattributed line.
go(rows, "the-blame-page-colours-each-line-by-its-commit", || {
let head = c.eval_str(
&sess,
"fetch('/api/repos/alice/chain-signed/head').then(r => r.json()).then(h => h.oid)",
)?;
want(head.len() == 64, || format!("could not read the head: {head:?}"))?;
c.navigate(
&sess,
&format!(
"{}/repo/alice/chain-signed/blame?rev=refs%2Fheads%2Fmain&path=src.txt",
r.origin()
),
)?;
c.wait_for(&sess, "document.querySelector('tr[data-blame-commit]')", 30_000)?;
let commit = c.eval_str(
&sess,
"document.querySelector('tr[data-blame-commit]')?.getAttribute('data-blame-commit') ?? ''",
)?;
want(commit == head, || {
format!("line 1 is coloured for {commit}, not the head {head}")
})?;
let sig = c.eval_str(
&sess,
"document.querySelector('tr[data-blame-commit]')?.getAttribute('data-blame-signature') ?? ''",
)?;
want(sig == "proven", || format!("the signed line does not read proven: {sig:?}"))?;
Ok(format!("line 1 → {} · {sig}", &head[..12]))
});
// ── the search page: the file and line of a known string ──────────────
go(rows, "the-search-page-lists-the-file-and-line-of-a-hit", || {
c.navigate(
&sess,
&format!("{}/repo/alice/chain-signed/search?q=provable", r.origin()),
)?;
c.wait_for(&sess, "document.querySelector('tr[data-search-path]')", 30_000)?;
let path = c.eval_str(
&sess,
"document.querySelector('tr[data-search-path]')?.getAttribute('data-search-path') ?? ''",
)?;
want(path == "src.txt", || format!("the hit is not in src.txt: {path:?}"))?;
let line = c.eval_str(
&sess,
"document.querySelector('tr[data-search-path]')?.getAttribute('data-search-line') ?? ''",
)?;
want(line.trim() == "1", || format!("the hit is not on line 1: {line:?}"))?;
Ok(format!("src.txt:{}", line.trim()))
});
// ── the diff page: the inline comment lands on its line ───────────────
//
// The comment was filed against src/lib.rs line 2 on a specific blob. The
// diff page renders it UNDER that line, and this asserts the row's line AND
// its blob — so it cannot pass on a comment that rendered somewhere generic,
// and the blob is what proves it followed the code and not the pull number.
go(rows, "the-diff-page-shows-the-inline-comment-on-its-line", || {
want(!inline_pull.is_empty(), || "the inline pull was never set up".into())?;
c.navigate(
&sess,
&format!(
"{}/repo/alice/uitest/diff?from=refs%2Fheads%2Fmain&to=refs%2Fheads%2Finline-topic&pull={}",
r.origin(),
inline_pull,
),
)?;
c.wait_for(&sess, "document.querySelector('tr.inline-comment')", 30_000)?;
let line = c.eval_str(
&sess,
"document.querySelector('tr.inline-comment')?.getAttribute('data-inline-comment-line') ?? ''",
)?;
want(line.trim() == "2", || format!("the comment landed on line {line:?}, not 2"))?;
let blob = c.eval_str(
&sess,
"document.querySelector('tr.inline-comment')?.getAttribute('data-inline-comment-blob') ?? ''",
)?;
want(blob == inline_blob, || {
format!("the comment is not anchored to the blob it was made on: {blob}")
})?;
let body = c.eval_str(
&sess,
"document.querySelector('tr.inline-comment')?.innerText ?? ''",
)?;
want(body.contains("needs a test"), || format!("the comment body is missing: {}", http::cut(&body, 120)))?;
Ok(format!("on line 2 @ {}", &blob[..blob.len().min(12)]))
});
go(rows, "logging-out-ends-the-session", || {
// Assert the BEFORE state first. Without it this situation passes when
// there was never a session to end — which is exactly how it behaved
// under --no-authenticator, and exactly the hollow guard that measuring
// rather than asserting is about: "logged out" is only a fact if
// "logged in" was one.
let before = c.eval(&sess, "fetch('/auth/status').then(r => r.json())")?;
want(before.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || {
format!("there was no session to end: {}", http::cut(&before.dump(), 150))
})?;
let out = c.eval(
&sess,
"fetch('/auth/logout', {method:'POST'}).then(r => r.status)",
)?;
want(out.as_f64().map(|s| (200.0..400.0).contains(&s)) == Some(true), || {
format!("logout answered {}", out.dump())
})?;
let st = c.eval(&sess, "fetch('/auth/status').then(r => r.json())")?;
want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(false), || {
format!("still authenticated: {}", http::cut(&st.dump(), 150))
})?;
// The session is gone; what remains is the stranger's view, served as
// the grantless guest. The property is NON-DISCLOSURE, not a status
// code: `/api/repos` may answer a stranger, and it must not name the
// private repository this rig holds. (Until 2026-09-10 the rig ran
// with no guest and asserted 401 here, which would have gone green on
// the confused deputy too — the whole list, admin's eyes, 200.)
let repos = c.eval(&sess, "fetch('/api/repos').then(r => r.text())")?;
let repos = repos.as_str().unwrap_or_default().to_owned();
want(!repos.contains("\"alice/uitest\""), || {
format!("a stranger's /api/repos names the PRIVATE repository: {}", http::cut(&repos, 200))
})?;
Ok("logged out, and the private repository is invisible again".into())
});
go(rows, "a-stranger-clicks-log-in-and-reaches-the-login-page", || {
a_stranger_clicks_log_in(&mut c, &sess, &r.origin())
});
go(rows, "the-same-passkey-logs-in-again", || {
// GHUNT-12: Gunnar's logout ends the person's sessions everywhere, and a floor voids
// every session minted AT its second, so the core refuses a login in that very second
// by name ("sign in again in a moment"). A person is never that fast; the row waits
// the second out, as the page's words ask.
std::thread::sleep(std::time::Duration::from_millis(1_100));
c.navigate(&sess, &format!("{}/login", r.origin()))?;
c.wait_for(&sess, "!!document.getElementById('login')", 30_000)?;
c.eval(&sess, "document.getElementById('login').click()")?;
c.wait_for(&sess, SETTLED, 30_000)?;
let st = c.eval(&sess, "fetch('/auth/status').then(r => r.json())")?;
let msg = c.eval_str(&sess, "document.getElementById('msg')?.textContent ?? ''").unwrap_or_default();
want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || {
format!("not authenticated (the page said {msg:?}): {}", http::cut(&st.dump(), 150))
})?;
Ok(format!("back in as {}", st.text("user")))
});
go(rows, "every-view-in-the-console-renders", || {
// The closest thing to "drive all the functions": walk the console's
// own navigation, and require each view both to draw something of its
// own AND to leave the page free of errors. A WASM console that panics
// renders an empty div and still answers questions about itself
// cheerfully, so neither half is sufficient alone.
c.navigate(&sess, &r.origin())?;
c.wait_for(&sess, "document.body && document.body.innerText.length > 40", 30_000)?;
let _ = c.drain_page_errors();
// The top bar since the final navigation (2026-10-02, `nav::menu`):
// Repositories; You ▾ Account, Storage (the first "Storage" in the
// document), Activity; Administration ▾ Overview, Identities. A link
// inside a closed menu is still a link to click.
let views = ["Repositories", "Overview", "Storage", "Activity", "Identities", "Account"];
let mut drawn = Vec::new();
for v in views {
let clicked = c.eval_str(
&sess,
&format!(
"(() => {{ const hit = [...document.querySelectorAll('a,button')]
.find(e => e.textContent.trim() === {});
if (!hit) return 'missing'; hit.click(); return 'clicked'; }})()",
esc(v)
),
)?;
want(clicked == "clicked", || format!("the console has no {v} link"))?;
// Something of this view's own, not merely the chrome around it.
c.wait_for(
&sess,
&format!(
"(() => {{ const m = document.querySelector('main') || document.body;
return m && m.innerText.trim().length > 60; }})()"
),
20_000,
)
.map_err(|e| format!("{v} drew nothing: {e}"))?;
let len = c
.eval(&sess, "(document.querySelector('main') || document.body).innerText.trim().length")?
.as_f64()
.unwrap_or(0.0);
drawn.push(format!("{v} {len:.0}"));
}
let errs = c.drain_page_errors();
want(errs.is_empty(), || {
format!("{} page error(s) while walking the views: {}", errs.len(), http::cut(&errs.join(" | "), 250))
})?;
Ok(drawn.join(", "))
});
go(rows, "the-issues-page-renders-the-folded-log", || {
// The console shows an issue from gunnar's fold, never a store of its
// own. This asserts exactly that: the fold is fetched in the browser,
// and every issue it reports must appear on the rendered page — title
// and state. The issue the API section opened and CLOSED is issue #1,
// so "the boot is dark" must render AND read as closed; a page drawing
// a stale or invented store would not match the fold it just fetched.
c.navigate(&sess, &format!("{}/repo/alice/uitest/issues", r.origin()))?;
c.wait_for(
&sess,
"(document.querySelector('main')||document.body).innerText.includes('Issues')",
30_000,
)?;
// Wait for the list rows themselves to draw, not just the heading.
c.wait_for(
&sess,
"(document.querySelector('main')||document.body).innerText.includes('the boot is dark')",
20_000,
)
.map_err(|e| format!("the issue never rendered from the fold: {e}"))?;
let folded = c.eval_str(
&sess,
"fetch('/api/repos/alice/uitest/issues').then(r=>r.json())\
.then(a=>a.map(i=>i.number+'|'+i.title+'|'+(i.open?'open':'closed')).join('\\n'))",
)?;
want(!folded.trim().is_empty(), || "the fold is empty; nothing to render".into())?;
let text = c.eval_str(&sess, "(document.querySelector('main')||document.body).innerText")?;
let mut checked = 0;
for line in folded.lines() {
let title = line.split('|').nth(1).unwrap_or("");
want(!title.is_empty() && text.contains(title), || {
format!("the page does not render issue {line:?}: {}", http::cut(&text, 250))
})?;
checked += 1;
}
// And the closed issue reads as closed on the page, not open — the
// state IS the fold, and the badge must agree with it.
want(text.contains("closed"), || {
format!("no issue renders as closed, but the fold has one: {}", http::cut(&text, 250))
})?;
let errs = c.drain_page_errors();
want(errs.is_empty(), || format!("page error(s) on the issues page: {}", http::cut(&errs.join(" | "), 200)))?;
Ok(format!("{checked} issue(s) rendered from the fold"))
});
go(rows, "the-overview-hides-the-indexing-panel-when-nothing-is-indexing", || {
// The panel is ABSENT when idle, not showing a zero — a row that reads
// "0 indexing" every day is one an operator learns to skip past, and
// then misses the day it says something.
//
// Absence alone would pass on a page that failed to render at all, so
// this asserts the Overview DREW first and only then that the panel is
// not on it.
// The Overview is Administration → Overview since 2026-10-02.
c.navigate(&sess, &format!("{}/admin/overview", r.origin()))?;
// Waited for by the panel itself: the Machine card above it loads
// first (the estate's topology), and a page caught between the two
// is still drawing, not failing to.
let drew = c.wait_for(&sess, "document.body && document.body.innerText.includes('The server')", 30_000);
let text = c.eval_str(&sess, "(document.querySelector('main') || document.body).innerText")?;
want(drew.is_ok() && text.contains("The server"), || {
format!("the Overview did not draw: {}", http::cut(&text, 160))
})?;
want(!text.contains("Indexing"), || {
format!("an idle box is showing an Indexing panel: {}", http::cut(&text, 200))
})?;
Ok("the Overview drew, and no Indexing panel with it".into())
});
go(rows, "the-indexing-panel-draws-when-there-is-work", || {
// The absence situation above would pass just as happily against a
// panel that never renders at all, so this is its other half.
//
// It proves the COMPONENT, not the server: the page's own `fetch` is
// wrapped so that `/api/stats` answers with one index row, and the
// console is then required to draw the repository, the phase, the
// percentage and an actual progress bar. That the SERVER really fills
// that field is a separate situation
// (`stats-carry-the-index-progress-rows`) — deliberately separate,
// because a repository small enough for a test indexes faster than a
// browser can look, and a situation that has to win a race is a
// situation that will lie eventually.
let stub = r#"{"namespaces":1,"repos":1,"principals":2,"keys":3,"grants":2,
"event_subscribers":1,"events_published":2,"events_dropped":0,
"event_buffer_capacity":1024,
"index":[{"repo":"alice/uitest","pack_id":1,"objects_done":4700000,
"objects_total":13948244,"bytes_done":2900000000,"bytes_total":8731794676,
"phase":"resolve","started_at_unix_ms":0,"resumed":false,"percent":34,
"reinflations":12,"pauses":2}]}"#;
let script = format!(
// `u` is a Request OBJECT here, not a string: the console fetches
// through gloo-net, so `String(u)` is "[object Request]" and a
// naive URL test never matches. Ask the object for its url first.
"(() => {{ const real = window.fetch;
window.fetch = (u, o) => {{
const url = (u && typeof u === 'object' && u.url) ? u.url : String(u);
return url.includes('/api/stats')
? Promise.resolve(new Response({}, {{status:200,
headers:{{'Content-Type':'application/json'}}}}))
: real(u, o);
}}; }})()",
esc(&stub.split_whitespace().collect::<Vec<_>>().join(" ").replace(", ", ","))
);
let added = c.call(
Some(&sess),
"Page.addScriptToEvaluateOnNewDocument",
&format!(r#"{{"source":{}}}"#, esc(&script)),
)?;
let ident = added.text("identifier");
c.navigate(&sess, &format!("{}/admin/overview", r.origin()))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut seen = String::new();
let mut found = None;
while std::time::Instant::now() < deadline {
seen = c.eval_str(&sess, "(document.querySelector('main') || document.body).innerText")?;
if seen.contains("Indexing") {
found = Some(());
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
let outcome = (|| -> Result<String, String> {
want(found.is_some(), || {
format!("the panel never drew; the page held: {}", http::cut(&seen, 200))
})?;
let pct = c.eval_str(&sess, "document.querySelector('.indexing .pct')?.textContent ?? ''")?;
want(pct.trim() == "34%", || format!("the percentage reads {pct:?}, not 34%"))?;
let width = c.eval_str(
&sess,
"document.querySelector('.indexing .bar span')?.style.width ?? '(no bar)'",
)?;
want(width.trim() == "34%", || format!("the bar is {width:?}, not 34%"))?;
want(seen.contains("alice/uitest"), || "the row does not name the repository".into())?;
want(seen.contains("resolve"), || "the row does not show the phase".into())?;
// The strain numbers are the memory ceiling being enforced, and the
// row is where an operator would ever see them.
want(seen.contains("re-inflated"), || {
format!("re-inflations are not shown: {}", http::cut(&seen, 200))
})?;
Ok(format!("{} at {} with a {} bar", "alice/uitest", pct.trim(), width.trim()))
})();
// Put the page back the way it was, whatever happened above.
if !ident.is_empty() {
let _ = c.call(
Some(&sess),
"Page.removeScriptToEvaluateOnNewDocument",
&format!(r#"{{"identifier":{}}}"#, esc(&ident)),
);
}
c.navigate(&sess, &r.origin())?;
outcome
});
go(rows, "the-storage-page-shows-what-a-deletion-still-costs", || {
// Real data, not a stub: a repository really was soft-deleted earlier
// in this walk, so the panel has something true to draw.
c.navigate(&sess, &r.origin())?;
// The machine's storage is Administration → Storage since 2026-10-02:
// the admin clicks THAT link in the top bar's Administration menu.
c.wait_for(&sess, "!!document.querySelector('header.topbar a[href=\"/admin/storage\"]')", 30_000)?;
c.eval(&sess, "document.querySelector('header.topbar a[href=\"/admin/storage\"]').click()")?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut seen = String::new();
while std::time::Instant::now() < deadline {
seen = c.eval_str(&sess, "(document.querySelector('main') || document.body).innerText")?;
if seen.contains("Deleted, not yet purged") {
want(seen.contains("made-by-console"), || {
format!("the panel drew but names no repository: {}", http::cut(&seen, 250))
})?;
// Case-insensitively: the console uppercases metric labels in
// CSS, so `innerText` reads READ CACHE, not "Read cache". A
// case-sensitive match here fails against a page that is
// perfectly correct.
want(seen.to_lowercase().contains("read cache"), || {
format!("the engine panel does not show the read cache: {}", http::cut(&seen, 250))
})?;
return Ok("the deleted repository, its bytes, and the read cache".into());
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
Err(format!("no tombstone panel after 30 s; the page held: {}", http::cut(&seen, 250)))
});
go(rows, "a-standby-that-has-never-synced-is-shown-as-never", || {
// The one alarm this panel exists for. gunnar sends 0 for "never", and
// rendering that as 1970 — or as "synced 56 years ago" — would turn the
// most important value in the message into a joke. A standby that has
// never taken a byte is not behind; it is a durability promise that
// does not exist.
//
// Stubbed at the page's own fetch, like the indexing row: standing up a
// second gunnar and breaking replication between them would test
// gunnar, not this panel.
let stub = r#"{"namespaces":1,"repos":1,"principals":2,"keys":3,"grants":2,
"event_subscribers":1,"events_published":2,"events_dropped":0,
"event_buffer_capacity":1024,"index":[],
"tombstoned_repos":0,"tombstoned_bytes":0,
"replication_configured":true,"replication_synced":0,"replication_failed":7,
"replication_enqueued":3,"replication_bus_gaps":0,
"standbys":[{"name":"backup-b","remote":"ssh://gunnar@backup:2222","pending":12,
"consecutive_failures":7,"last_error":"connection refused",
"last_success_unix_ms":null,"needs_full_resync":false,"stale":true}]}"#;
let script = format!(
"(() => {{ const real = window.fetch;
window.fetch = (u, o) => {{
const url = (u && typeof u === 'object' && u.url) ? u.url : String(u);
return url.includes('/api/stats')
? Promise.resolve(new Response({}, {{status:200,
headers:{{'Content-Type':'application/json'}}}}))
: real(u, o);
}}; }})()",
esc(&stub.split_whitespace().collect::<Vec<_>>().join(" "))
);
let added = c.call(
Some(&sess),
"Page.addScriptToEvaluateOnNewDocument",
&format!(r#"{{"source":{}}}"#, esc(&script)),
)?;
let ident = added.text("identifier");
// `/admin` since 2026-09-18: the Replication panel was moved off the
// Overview with every other instrument that acts on the box.
c.navigate(&sess, &format!("{}/admin", r.origin()))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut seen = String::new();
while std::time::Instant::now() < deadline {
seen = c.eval_str(&sess, "(document.querySelector('main') || document.body).innerText")?;
if seen.contains("Replication") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
let outcome = (|| -> Result<String, String> {
want(seen.contains("Replication"), || {
format!("no Replication panel; the page held: {}", http::cut(&seen, 200))
})?;
want(seen.contains("never synced"), || {
format!("a standby with no successful sync is not called out: {}", http::cut(&seen, 250))
})?;
want(seen.contains("backup-b"), || "the standby is not named".into())?;
want(seen.contains("connection refused"), || {
"the last error is not shown, so nobody can act on it".into()
})?;
// And it must NOT have invented a date out of gunnar's zero.
want(!seen.contains("1970"), || {
format!("`never` was rendered as 1970: {}", http::cut(&seen, 200))
})?;
Ok("never synced, named, with its error and no invented date".into())
})();
if !ident.is_empty() {
let _ = c.call(
Some(&sess),
"Page.removeScriptToEvaluateOnNewDocument",
&format!(r#"{{"identifier":{}}}"#, esc(&ident)),
);
}
c.navigate(&sess, &r.origin())?;
outcome
});
go(rows, "the-admin-page-hides-replication-when-none-is-configured", || {
// Absence, asserted only after the page is shown to have drawn — an
// operator who has not set up replication does not need a panel telling
// them so every day.
//
// The page under it changed on 2026-09-18 and so did the proof that it
// drew: "The server" is the Overview's panel and is not on `/admin`, so
// the marker is the banner every Administration page carries. Asserting
// absence against a page that never rendered is the one way this row
// can pass while meaning nothing.
c.navigate(&sess, &format!("{}/admin", r.origin()))?;
c.wait_for(&sess, "document.body && document.body.innerText.includes('Administration')", 30_000)?;
let text = c.eval_str(&sess, "(document.querySelector('main') || document.body).innerText")?;
want(text.contains("Settings for the whole site."), || {
format!("the Administration page did not draw: {}", http::cut(&text, 160))
})?;
want(!text.contains("Replication"), || {
format!("replication is not configured, yet a panel is shown: {}", http::cut(&text, 200))
})?;
Ok("the Administration page drew, and no Replication panel with it".into())
});
go(rows, "the-live-feed-carries-a-change-as-it-happens", || {
// The dashboard is driven by this feed, and until now the only check on
// it was grepping the front's log for the ABSENCE of "cannot
// subscribe" — which stays quiet whether the feed works or is merely
// silent. This subscribes, makes a change, and requires the change to
// arrive: named events on the `feed` channel, so `onmessage` alone
// would never fire.
//
// Robust to a reconnecting pump. An EARLIER situation (`attest head`,
// in the chain setup) restarts gunnar, and the BFF's single upstream
// subscription takes RECONNECT_DELAY to come back. If this situation
// happens to land inside that window, the change we make is published
// while the BFF is between subscriptions and is NOT replayed to it —
// that is deliberate (bus.rs: a cursor from a dead epoch is void, and
// the console re-reads its lists rather than being sold events nobody
// can place; event_bus.rs pins it, feed.rs acts on it). What the feed
// carries in that case is a `restart`/`gap` line, on which the console
// re-reads. So: if a continuity break arrives, the pump is settled now
// — make the change AGAIN and require THAT to arrive live. A genuine
// drop still fails, because a live change that never arrives times out.
//
// **The probe name carries a nonce, and that is load-bearing now.** A
// feed opens with the BFF's backlog of what happened before this
// connection, so matching a bare `feed-probe` would accept a line left
// by an earlier run of this very row against the same console — a row
// named "as it happens" passing on something that happened yesterday.
// The nonce is minted per run, so only a repository this promise
// created can satisfy it, whether it arrives live or not at all.
let got = c.eval(
&sess,
"new Promise(resolve => {
const tag = 'feed-probe-' + Math.random().toString(36).slice(2, 10);
const es = new EventSource('/api/events');
const seen = [];
let probe = 0;
const change = () => { probe += 1; fetch('/api/repos', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({namespace:'alice', name: tag + '-' + probe})}); };
const done = (ok, why) => { clearTimeout(t); es.close(); resolve({ok, why, seen}); };
const t = setTimeout(() => done(false, 'nothing arrived in 20 s'), 20000);
es.addEventListener('feed', e => {
const data = String(e.data).slice(0, 120);
seen.push(data);
if (data.includes(tag)) { done(true, 'the change arrived'); return; }
// A break in continuity: the pump reconnected under us. It is
// subscribed again now, so a fresh change will arrive live.
if (data.includes('\"kind\":\"restart\"') || data.includes('\"kind\":\"gap\"')) change();
});
es.onerror = () => done(false, 'the event source errored');
es.onopen = () => change();
})",
)?;
want(got.path("ok").and_then(|v| v.as_bool()) == Some(true), || {
format!(
"{} (saw {} events: {})",
got.text("why"),
got.path("seen").map(|s| s.arr().len()).unwrap_or(0),
http::cut(&got.path("seen").map(|s| s.dump()).unwrap_or_default(), 200)
)
})?;
Ok(format!(
"{} after {} event(s)",
got.text("why"),
got.path("seen").map(|s| s.arr().len()).unwrap_or(0)
))
});
go(rows, "a-restarted-gunnar-is-reported-as-a-restart-not-as-silence", || {
// The defect this closes. gunnar's event sequence begins again at 1 on
// every boot, so a console holding sequence 900 that reconnects to a
// restarted server sees sequence 1. The old BFF computed a gap by
// subtracting the two — and after a restart the NEW number is lower, so
// it computed nothing at all and the feed carried on as if the hole
// were not there. Silence is the failure mode being tested for.
//
// Shape: park a collector in the page, restart gunnar underneath it,
// then read back what the console was told. The collector cannot be a
// promise the driver awaits, because the restart has to happen while it
// is listening.
c.eval_str(
&sess,
"(() => {
window.__restartFeed = [];
if (window.__restartEs) { window.__restartEs.close(); }
const es = new EventSource('/api/events');
window.__restartEs = es;
es.addEventListener('feed', e => {
try { window.__restartFeed.push(JSON.parse(e.data)); }
catch (_) { window.__restartFeed.push({kind:'unparsed', summary:String(e.data)}); }
});
return 'listening';
})()",
)?;
// Give the console a cursor worth losing: something must have happened
// in the run that is about to end, or "the epoch changed" would be
// reported about a console that had never been told any sequence
// number and the situation would prove nothing.
//
// **The guard names the repository rather than counting lines.** A feed
// now opens with the BFF's backlog, so a non-zero length proves only
// that something happened at some point — including on a feed that has
// been dead since. This repository cannot be in any backlog: it did not
// exist when the collector connected. Its position is also the cut
// between the replayed history and this run, which is what the
// assertions below need in order to be about THIS restart.
r.control(&["repo", "create", "alice/before-the-restart"])?;
std::thread::sleep(std::time::Duration::from_millis(1500));
let before = c.eval(&sess, "window.__restartFeed")?;
let cut = before
.arr()
.iter()
.rposition(|i| i.text("repo") == "alice/before-the-restart")
.ok_or_else(|| {
format!(
"the repository created after the collector connected never reached the \
feed, so this situation would be measuring a dead feed rather than a \
restart. It saw: {}",
http::cut(&before.dump(), 400)
)
})?;
r.restart_server()?;
// The BFF waits 3 s before re-subscribing (RECONNECT_DELAY), so this
// has to outlast that with room for the reconnect itself.
std::thread::sleep(std::time::Duration::from_millis(6000));
r.control(&["repo", "create", "alice/after-the-restart"])?;
std::thread::sleep(std::time::Duration::from_millis(2000));
let seen = c.eval(&sess, "window.__restartFeed")?;
// From the cut onwards: everything before it is either the backlog the
// feed opened with or the line that established the cut, and a
// `restart` found in there would be an EARLIER restart — the chain
// setup performs one — answering for this one.
let items: Vec<&Json> = seen.arr().iter().skip(cut + 1).collect();
let restarts: Vec<&Json> = items
.iter()
.copied()
.filter(|i| i.text("kind") == "restart")
.collect();
want(!restarts.is_empty(), || {
format!(
"gunnar restarted and the console was never told; the feed carried on as if \
nothing had happened. It saw: {}",
http::cut(&seen.dump(), 400)
)
})?;
let line = restarts[0];
want(line.text("summary").contains("restarted"), || {
format!("the line does not say what happened: {}", line.dump())
})?;
// The count must be ABSENT. Nothing buffered the events on the other
// side of a restart, so a number here would be invented — which is
// exactly what the synthesised gap used to do.
want(line.path("dropped").is_none_or(|d| *d == Json::Null), || {
format!(
"the restart line reports a count of missed events, which nothing could have \
measured: {}",
line.dump()
)
})?;
// …and the feed recovered: the change made AFTER the restart arrives.
want(
items.iter().any(|i| i.text("summary").contains("created")
|| i.text("kind") == "repo_created"),
|| {
format!(
"the feed did not resume after the restart: {}",
http::cut(&seen.dump(), 400)
)
},
)?;
let _ = c.eval_str(&sess, "(window.__restartEs && window.__restartEs.close(), 'closed')");
Ok(format!(
"the console was told the feed restarted, with no invented count, and recovered \
({} line(s))",
items.len()
))
});
// ── pull requests, driven through the browser ─────────────────────────
//
// The API rows proved gunnar's merge gate. These prove the PAGE a reviewer
// uses: the Review panel, the Approve button, the Merge button behind a
// verified approval, and the honest note that an approval made here is the
// console's key and not the operator's. The console shipped a Merge button
// with no way to approve at all until REVIEW/MERGEGATE — the one pairing a
// happy-path row cannot catch.
let pr_web = std::cell::RefCell::new(String::new());
go(rows, "the-pull-request-page-refuses-a-merge-with-no-approval", || {
// Plumbing through gunnar directly, so the browser's only job is the
// review: a repository, a base commit, and a branch to propose.
r.control(&["repo", "create", "alice/pr-web"])?;
let dir = r.work.join("pr-web");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
r.git(&dir, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&dir, &["config", "user.name", "uitest"])?;
r.git(&dir, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(dir.join("README"), "base\n").map_err(|e| e.to_string())?;
r.git(&dir, &["add", "README"])?;
r.git(&dir, &["commit", "-qm", "base"])?;
r.git(&dir, &["push", "-q", &r.remote("alice/pr-web"), "main"])?;
r.git(&dir, &["checkout", "-q", "-b", "feature"])?;
std::fs::write(dir.join("FEATURE"), "the change under review\n").map_err(|e| e.to_string())?;
r.git(&dir, &["add", "FEATURE"])?;
r.git(&dir, &["commit", "-qm", "a change that needs review"])?;
r.git(&dir, &["push", "-q", &r.remote("alice/pr-web"), "feature"])?;
r.git(&dir, &["checkout", "-q", "main"])?;
// Opened through the logged-in console — the admin token has retired by
// now, so this could not go over a bearer even if it wanted to.
let opened = c.eval(
&sess,
"fetch('/api/repos/alice/pr-web/pulls', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({title:'a change that needs review', description:'',
source_ref:'refs/heads/feature', target_ref:'refs/heads/main'})})
.then(r => r.json())",
)?;
let number = opened
.path("number")
.and_then(|n| n.as_f64())
.map(|n| format!("{}", n as i64))
.ok_or_else(|| format!("no pull number: {}", http::cut(&opened.dump(), 200)))?;
*pr_web.borrow_mut() = number.clone();
c.navigate(&sess, &format!("{}/repo/alice/pr-web/pulls/{number}", r.origin()))?;
// Wait for the review to have LOADED, not merely for the panel title:
// the badge text ("approved at this commit" / "not approved at this
// commit") only renders once the review resource resolves, and reading
// while it still says "Loading …" was a hollow wait that raced the fetch.
c.wait_for(
&sess,
"document.body && document.body.innerText.includes('approved at this commit')",
30_000,
)?;
let panel = c.eval_str(&sess, "(document.querySelector('main')||document.body).innerText")?;
// BEFORE: a fresh pull must read NOT approved. A tick here would be the
// exact lie the commit-not-pull design exists to prevent.
want(panel.contains("not approved at this commit"), || {
format!("a fresh pull request is not shown as unapproved: {}", http::cut(&panel, 250))
})?;
// The honest note, now that a passkey reviewer signs as THEMSELVES:
// the page tells bob his approval is his OWN key, not the console's.
// Red-first — the old page disclosed "signed by the console's own key".
want(panel.contains("your own key") && panel.contains("not the console"), || {
format!("the page does not disclose the approval is the reviewer's own key: {}", http::cut(&panel, 300))
})?;
// With nothing approved the page offers NO Merge button and shows gunnar's own
// refusal, asked ahead through PullRequests.CheckMerge (GUN-FIELD-TEST,
// 2026-11-01: a button that could only be refused is not offered). main stays
// where it was.
let before = server_main(r, "alice/pr-web")?;
want(!before.is_empty(), || "the server has no main tip to watch".into())?;
c.wait_for(
&sess,
"(() => { const t=(document.querySelector('main')||document.body).innerText;
return t.includes('Not mergeable yet') && t.includes('no verified approval'); })()",
20_000,
)
.map_err(|e| format!("the page does not say in gunnar's words why it cannot merge: {e}"))?;
let offered = c.eval_str(
&sess,
"String([...document.querySelectorAll('button')].some(e => /^(Merge|Fast-forward)/.test(e.textContent.trim())))",
)?;
want(offered == "false", || "the page offers a Merge button gunnar would refuse".into())?;
let after = server_main(r, "alice/pr-web")?;
want(after == before, || format!("main moved with nothing approved: {before} → {after}"))?;
Ok(format!("#{number}: unapproved, no Merge button, gunnar's reason shown, main unmoved"))
});
go(rows, "approving-in-the-browser-then-merging-moves-the-branch", || {
let n = pr_web.borrow().clone();
want(!n.is_empty(), || "the pull request was never opened".into())?;
c.navigate(&sess, &format!("{}/repo/alice/pr-web/pulls/{n}", r.origin()))?;
c.wait_for(
&sess,
"[...document.querySelectorAll('button')].some(e => e.textContent.trim() === 'Approve this commit')",
30_000,
)?;
// BEFORE — still not approved.
let before_panel = c.eval_str(&sess, "(document.querySelector('main')||document.body).innerText")?;
want(before_panel.contains("not approved at this commit"), || {
format!("not in the unapproved state before approving: {}", http::cut(&before_panel, 200))
})?;
// Click Approve and require the badge to FLIP — the applied output, not
// that a POST returned 200.
c.eval_str(
&sess,
"[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Approve this commit').click()",
)?;
c.wait_for(
&sess,
"(() => { const t=(document.querySelector('main')||document.body).innerText;
return t.includes('approved at this commit') && !t.includes('not approved at this commit'); })()",
20_000,
)
.map_err(|e| format!("the review badge did not flip to approved after clicking Approve: {e}"))?;
// gunnar itself agrees the head is approved now.
let review = c.eval(&sess, &format!("fetch('/api/repos/alice/pr-web/pulls/{n}/review').then(r => r.json())"))?;
want(review.path("head_is_approved").and_then(|v| v.as_bool()) == Some(true), || {
format!("the page says approved but gunnar does not: {}", http::cut(&review.dump(), 200))
})?;
// THE FIX, in the applied output: the approval is attributed to the
// REVIEWER's own principal — `bob` himself since the thin layer (signed
// with his session key; no `console-*` reviewer principal exists any
// more, and gunnar refuses one) — verified against the key table and
// covering the tip, and NOT to the console (`uitest-console`).
// GHUNT-19: bob's browser session holds no key of his, so gunnar signs
// his approval itself after his fresh passkey touch, and the verdict
// says so: "attested for session step-up at <time>".
let names_reviewer = review.path("approvals").map(|v| v.arr().iter().any(|e| {
e.text("actor") == "bob"
&& e.path("verified").and_then(|x| x.as_bool()) == Some(true)
&& e.path("covers_head").and_then(|x| x.as_bool()) == Some(true)
&& e.text("verdict").starts_with("attested for session step-up at ")
})).unwrap_or(false);
want(names_reviewer, || {
format!("the approval does not name the reviewer bob: {}", http::cut(&review.dump(), 300))
})?;
let names_console = review.path("approvals")
.map(|v| v.arr().iter().any(|e| e.text("actor") == "uitest-console"))
.unwrap_or(false);
want(!names_console, || {
format!("the approval is the console's after all: {}", http::cut(&review.dump(), 300))
})?;
// Now Merge, and the branch moves on the SERVER.
let before = server_main(r, "alice/pr-web")?;
c.eval_str(
&sess,
"[...document.querySelectorAll('button')].find(e => /^(Merge|Fast-forward)/.test(e.textContent.trim())).click()",
)?;
c.wait_for(&sess, "(document.querySelector('main')||document.body).innerText.includes('merged')", 20_000)
.map_err(|e| format!("the pull request did not become merged in the UI: {e}"))?;
let after = server_main(r, "alice/pr-web")?;
want(after != before && !after.is_empty(), || format!("main did not move: {before} → {after}"))?;
// The pull is merged in gunnar too, not merely in the console's word.
let pull = c.eval(&sess, &format!("fetch('/api/repos/alice/pr-web/pulls/{n}').then(r => r.json())"))?;
want(pull.text("state") == "merged", || format!("gunnar's pull state is {:?}", pull.text("state")))?;
Ok(format!("approved in the UI, merged, main {} → {}", &before[..8.min(before.len())], &after[..8.min(after.len())]))
});
// ── the merge engine, through the browser (GUN-MERGE-ENGINE, 2026-11-01) ──
//
// The owner hit it on gunnar.rs: a pull request whose branch did not descend
// from main could not be merged at all. These two rows drive the page: a
// branch behind main is offered "Merge" (a merge commit, not "Fast-forward"),
// and the press writes a two-parent commit a real git client accepts; a branch
// that conflicts with main shows gunnar's conflict list and no button.
// `pr_behind_main` sets each one up.
go(rows, "a-pull-request-behind-main-is-merged-with-a-merge-commit-in-the-browser", || {
let n = pr_web.borrow().clone();
want(!n.is_empty(), || "the first pull request was never opened".into())?;
let number = pr_behind_main(r, &mut c, &sess, "behind", ("BEHIND", "made on the branch\n"), ("AHEAD", "made on main\n"))?;
let dir = r.work.join("pr-web");
let head = r.git(&dir, &["rev-parse", "behind"])?.trim().to_owned();
let before = server_main(r, "alice/pr-web")?;
// gunnar's check says a merge commit: the button reads "Merge", never
// "Fast-forward" (which would drop main's new commit).
c.wait_for(
&sess,
"[...document.querySelectorAll('button')].some(e => e.textContent.trim() === 'Merge')",
20_000,
)
.map_err(|e| format!("a branch behind main is not offered 'Merge': {e}"))?;
let ff = c.eval_str(
&sess,
"String([...document.querySelectorAll('button')].some(e => e.textContent.trim() === 'Fast-forward'))",
)?;
want(ff == "false", || "a branch behind main is offered a fast-forward".into())?;
c.eval_str(
&sess,
"[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Merge').click()",
)?;
c.wait_for(&sess, "(document.querySelector('main')||document.body).innerText.includes('merged')", 20_000)
.map_err(|e| format!("the pull request did not become merged in the UI: {e}"))?;
let after = server_main(r, "alice/pr-web")?;
want(after != before && after != head, || format!("main is not a new merge commit: {before} → {after}"))?;
// A real git client: both parents, a clean fsck, a merge in the graph.
r.git(&dir, &["fetch", "-q", &r.remote("alice/pr-web"), "main"])?;
let parents = r.git(&dir, &["rev-list", "--parents", "-n1", "FETCH_HEAD"])?;
let parents: Vec<&str> = parents.split_whitespace().collect();
want(parents == [after.as_str(), before.as_str(), head.as_str()], || {
format!("the merge commit's parents are {parents:?}, not [{after}, {before}, {head}]")
})?;
r.git(&dir, &["fsck", "--full", "--strict", "--no-dangling"])?;
let graph = r.git(&dir, &["log", "--graph", "--oneline", "-4", "FETCH_HEAD"])?;
want(graph.contains("|\\"), || format!("git log --graph draws no merge:\n{graph}"))?;
let pull = c.eval(&sess, &format!("fetch('/api/repos/alice/pr-web/pulls/{number}').then(r => r.json())"))?;
want(pull.text("state") == "merged" && pull.text("merge_oid") == after, || {
format!("gunnar's record: {}", http::cut(&pull.dump(), 200))
})?;
Ok(format!("#{number}: Merge (not Fast-forward), main {} → merge {}", &before[..8], &after[..8]))
});
go(rows, "a-conflicting-pull-request-lists-its-paths-in-the-browser", || {
let n = pr_web.borrow().clone();
want(!n.is_empty(), || "the first pull request was never opened".into())?;
let number = pr_behind_main(r, &mut c, &sess, "clash", ("README", "the branch's README\n"), ("README", "main's README\n"))?;
let before = server_main(r, "alice/pr-web")?;
c.wait_for(
&sess,
"(() => { const t=(document.querySelector('main')||document.body).innerText;
return t.includes('changed on both sides') && t.includes('README (both sides changed the same lines)'); })()",
20_000,
)
.map_err(|e| format!("the page does not list gunnar's conflicting path: {e}"))?;
let offered = c.eval_str(
&sess,
"String([...document.querySelectorAll('button')].some(e => /^(Merge|Fast-forward)/.test(e.textContent.trim())))",
)?;
want(offered == "false", || "a conflicting pull request is offered a merge button".into())?;
let after = server_main(r, "alice/pr-web")?;
want(after == before, || format!("main moved on a conflict: {before} → {after}"))?;
Ok(format!("#{number}: README listed as a conflict, no button, main unmoved"))
});
go(rows, "a-self-approved-merge-is-refused-in-the-browser", || {
// The merge-side of forbid_self_approval, driven through the Merge
// button. bob BOTH opens the pull request and approves it, so — now
// that a web approval carries the human's own principal — the author and
// the approver are the SAME real reviewer (`bob` himself, his session
// key — thin layer), and the
// rule refuses his approve-then-merge. This is a genuine human
// self-approval, not the console's: before the fix, author and approver
// were both `uitest-console` and the refusal named the console. The rule
// is the only variable: the SAME Merge click is refused with it and
// lands without it.
r.control(&["repo", "create", "alice/pr-web-self"])?;
let dir = r.work.join("pr-web-self");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
r.git(&dir, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&dir, &["config", "user.name", "uitest"])?;
r.git(&dir, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(dir.join("README"), "base\n").map_err(|e| e.to_string())?;
r.git(&dir, &["add", "README"])?;
r.git(&dir, &["commit", "-qm", "base"])?;
r.git(&dir, &["push", "-q", &r.remote("alice/pr-web-self"), "main"])?;
r.git(&dir, &["checkout", "-q", "-b", "review-me"])?;
std::fs::write(dir.join("CHANGE"), "please review\n").map_err(|e| e.to_string())?;
r.git(&dir, &["add", "CHANGE"])?;
r.git(&dir, &["commit", "-qm", "a change that needs another pair of eyes"])?;
r.git(&dir, &["push", "-q", &r.remote("alice/pr-web-self"), "review-me"])?;
r.git(&dir, &["checkout", "-q", "main"])?;
let opened = c.eval(
&sess,
"fetch('/api/repos/alice/pr-web-self/pulls', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({title:'needs another pair of eyes', description:'',
source_ref:'refs/heads/review-me', target_ref:'refs/heads/main'})})
.then(r => r.json())",
)?;
let n = opened.path("number").and_then(|x| x.as_f64()).map(|x| x as i64)
.ok_or_else(|| format!("no pull number: {}", http::cut(&opened.dump(), 200)))?;
// Opened FROM the browser, so gunnar records bob's own principal as
// the author — not the console. This is what makes the refusal below a
// real self-approval; red-first, the old path authored it `uitest-console`.
want(opened.text("author") == "bob", || {
format!("a browser-opened pull request is authored by {:?}, not the reviewer", opened.text("author"))
})?;
// bob approves his own change. Write the rule AFTER, so only the
// merge is under test.
let review = c.eval(&sess, &format!("fetch('/api/repos/alice/pr-web-self/pulls/{n}/review').then(r => r.json())"))?;
let head = review.text("head");
want(!head.is_empty(), || "the review does not say where the branch is".into())?;
// As the page sends it: a `"code":"stepup"` answer (bob's touch is past
// gunnar's 10-minute window) is a fresh touch of his key, then the same
// call once more (GHUNT-19).
let approved = c.eval(
&sess,
&stepped_post(
&format!("/api/repos/alice/pr-web-self/pulls/{n}/approve"),
&format!("JSON.stringify({{commit:{}}})", esc(&head)),
),
)?;
want(approved.as_f64().map(|s| (200.0..300.0).contains(&s)) == Some(true), || {
format!("the approval did not take: {}", approved.dump())
})?;
// The approver is bob too — the same real principal as the author,
// which is exactly what forbid-self-approval turns on. Under the old
// path this was the console approving the console.
let approved_review = c.eval(&sess, &format!("fetch('/api/repos/alice/pr-web-self/pulls/{n}/review').then(r => r.json())"))?;
want(approved_review.path("approvals").map(|v| v.arr().iter().any(|e| e.text("actor") == "bob")).unwrap_or(false), || {
format!("the approval is not the reviewer's own: {}", http::cut(&approved_review.dump(), 300))
})?;
let ruled = c.eval(
&sess,
"fetch('/api/repos/alice/pr-web-self/protection', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ref_pattern:'refs/heads/main', forbid_self_approval:true})})
.then(r => r.status)",
)?;
want(ruled.as_f64().map(|s| (200.0..300.0).contains(&s)) == Some(true), || {
format!("the rule did not store: {}", ruled.dump())
})?;
// Under the rule the page offers no Merge button and says why in gunnar's words
// (CheckMerge, GUN-FIELD-TEST 2026-11-01); main unmoved.
c.navigate(&sess, &format!("{}/repo/alice/pr-web-self/pulls/{n}", r.origin()))?;
let before = server_main(r, "alice/pr-web-self")?;
c.wait_for(
&sess,
"(() => { const t=(document.querySelector('main')||document.body).innerText;
return t.includes('Not mergeable yet') && t.includes('self-approval'); })()",
30_000,
)
.map_err(|e| format!("the self-approved merge was not refused in words in the UI: {e}"))?;
let offered = c.eval_str(
&sess,
"String([...document.querySelectorAll('button')].some(e => /^(Merge|Fast-forward)/.test(e.textContent.trim())))",
)?;
want(offered == "false", || "the page offers a Merge button the rule refuses".into())?;
want(server_main(r, "alice/pr-web-self")? == before, || "main moved under the rule".into())?;
// The control: rule gone, the IDENTICAL Merge click lands — so the
// refusal above was the rule and nothing else.
let unruled = c.eval(
&sess,
"fetch('/api/repos/alice/pr-web-self/protection?ref_pattern=refs%2Fheads%2Fmain', {method:'DELETE'})
.then(r => r.status)",
)?;
want(unruled.as_f64().map(|s| (200.0..300.0).contains(&s)) == Some(true), || {
format!("removing the rule answered {}", unruled.dump())
})?;
c.navigate(&sess, &format!("{}/repo/alice/pr-web-self/pulls/{n}", r.origin()))?;
c.wait_for(&sess, "[...document.querySelectorAll('button')].some(e => /^(Merge|Fast-forward)/.test(e.textContent.trim()))", 30_000)?;
c.eval_str(&sess, "[...document.querySelectorAll('button')].find(e => /^(Merge|Fast-forward)/.test(e.textContent.trim())).click()")?;
c.wait_for(&sess, "(document.querySelector('main')||document.body).innerText.includes('merged')", 20_000)
.map_err(|e| format!("the rule was removed and the identical UI merge still did not land: {e}"))?;
want(server_main(r, "alice/pr-web-self")? != before, || "the merge landed but main did not move".into())?;
Ok("not offered under the rule (gunnar's words shown), offered and merged without it".into())
});
// ── branch protection, set and removed from the UI ────────────────────
//
// The API rows proved a viewer cannot set protection and that gunnar
// enforces it. These prove the OPERATOR's panel: the form writes a rule the
// page then renders in its forbids-words, gunnar's own CLI sees the same
// document, and the Remove button takes it away again.
go(rows, "branch-protection-set-from-the-ui-is-stored-by-gunnar", || {
r.control(&["repo", "create", "alice/ui-guarded"])?;
// A real commit, so the operator is protecting a repository that has
// content — and the detail page renders its history rather than the
// "does not exist" state a bare namespace entry shows.
let dir = r.work.join("ui-guarded");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
r.git(&dir, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&dir, &["config", "user.name", "uitest"])?;
r.git(&dir, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(dir.join("README"), "guard me\n").map_err(|e| e.to_string())?;
r.git(&dir, &["add", "README"])?;
r.git(&dir, &["commit", "-qm", "one"])?;
r.git(&dir, &["push", "-q", &r.remote("alice/ui-guarded"), "main"])?;
// The Settings tab since the final navigation (2026-10-02).
c.navigate(&sess, &format!("{}/repo/alice/ui-guarded/settings", r.origin()))?;
c.wait_for(&sess, "document.getElementById('prot-pattern') !== null", 30_000)?;
// Wait for the protection list to have ANSWERED, not merely for the
// form control beside it: the pattern input is in the page's markup
// and renders at once, while the list is a fetch. Reading between the
// two is a race this row lost under load on 2026-09-12 — "the panel
// did not start empty", with `Loading …` in the text it printed.
//
// Either outcome ends the wait, so the assertion below still decides:
// if a rule IS already there, this returns and the row fails saying
// so, rather than timing out with a message about the wait.
//
// The list's OWN marks, not a word the panel prints anyway: the help
// line and the pattern's hint both say `refs/heads/…`, so
// `includes('refs/heads/')` was true the moment the form rendered and
// this wait waited for nothing — the 2026-09-12 race still open, lost
// again on 2026-10-04 with `Loading …` in the text. The empty state,
// the rules table's caption, or a failure.
c.wait_for(
&sess,
"(() => { const t=(document.querySelector('main')||document.body).innerText;
return t.includes('Nothing is protected')
|| [...document.querySelectorAll('caption')].some(c => c.textContent.includes('Branch protection rules'))
|| !!document.querySelector('.failure'); })()",
30_000,
)?;
// BEFORE: nothing protected, and gunnar holds no rule. gunnar's empty
// help LISTS `refs/heads/main` as an example, so the empty state is read
// off its own sentence rather than off the pattern.
let before = c.eval_str(&sess, "(document.querySelector('main')||document.body).innerText")?;
want(before.contains("Nothing is protected"), || {
format!("the panel did not start empty: {}", http::cut(&before, 250))
})?;
let cli_before = r.control(&["policy", "list", "alice/ui-guarded"])?;
want(cli_before.contains("NOTHING is protected"), || {
format!("gunnar already holds a rule: {}", http::cut(&cli_before, 200))
})?;
// Type the pattern into the Leptos-controlled input (setting `.value`
// then dispatching `input`, which is what the `on:input` handler reads)
// and Protect. The refuse-force and refuse-delete switches start on, so
// this stores a real protection.
c.eval_str(
&sess,
"(() => { const p=document.getElementById('prot-pattern');
p.value='refs/heads/main';
p.dispatchEvent(new Event('input', {bubbles:true}));
return p.value; })()",
)?;
let clicked = c.eval_str(
&sess,
"(() => { const b=[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Protect');
if (!b) return 'no button'; b.click(); return 'clicked'; })()",
)?;
want(clicked == "clicked", || format!("no Protect button: {clicked}"))?;
// AFTER: the rule row renders, in the forbids-words the panel promises.
c.wait_for(
&sess,
"(() => { const t=(document.querySelector('main')||document.body).innerText;
return t.includes('force-push refused') && t.includes('refs/heads/main'); })()",
20_000,
)
.map_err(|e| format!("the rule did not render after Protect: {e}"))?;
let after = c.eval_str(&sess, "(document.querySelector('main')||document.body).innerText")?;
want(!after.contains("Nothing is protected"), || "the empty state is still shown".into())?;
want(after.contains("delete refused"), || {
format!("the delete-refused chip is missing: {}", http::cut(&after, 250))
})?;
// gunnar stored it — the CLI is a different surface onto the same
// policy document, which is the whole point of the two agreeing. "N
// rule(s) on" appears only when a rule really exists, so it cannot be
// satisfied by the empty help's example pattern.
let cli = r.control(&["policy", "list", "alice/ui-guarded"])?;
want(cli.contains("rule(s) on") && cli.contains("refs/heads/main") && !cli.contains("NOTHING is protected"), || {
format!("gunnar did not store the UI's rule: {}", http::cut(&cli, 250))
})?;
Ok("protected from the UI, stored in gunnar".into())
});
go(rows, "required-approvals-set-from-the-ui-then-removed", || {
// A second rule on the same repository (a concrete staging branch),
// exercising the number field and the self-approval switch, then its own
// Remove button — so a rule that renders is one that can be taken away
// again without touching the main rule beside it.
// The Settings tab since the final navigation (2026-10-02).
c.navigate(&sess, &format!("{}/repo/alice/ui-guarded/settings", r.origin()))?;
c.wait_for(&sess, "document.getElementById('prot-required') !== null", 30_000)?;
let ready = c.eval_str(
&sess,
"(() => { const p=document.getElementById('prot-pattern');
p.value='refs/heads/staging';
p.dispatchEvent(new Event('input', {bubbles:true}));
const n=document.getElementById('prot-required');
n.value='2'; n.dispatchEvent(new Event('input', {bubbles:true}));
const lab=[...document.querySelectorAll('label.switch')].find(l => l.textContent.includes('self-approval'));
const cb=lab && lab.querySelector('input');
if (cb && !cb.checked) cb.click();
return (cb && cb.checked) ? 'ready' : 'switch-missing'; })()",
)?;
want(ready == "ready", || format!("could not set up the staging rule form: {ready}"))?;
c.eval_str(&sess, "[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Protect').click()")?;
The file goes on; the console shows all of it.