uitest/src/repodefault.rs
//! ★ **Repo → Settings changes the default branch, in a real browser** (owner
//! 2026-10-14: Settings showed "Default branch ny_dok" as text with no control)
//! — the BUILT console (`trunk build`'s `dist`) behind a loopback stub that
//! answers gunnar's JSON, driven by headless Chrome over the DevTools Protocol.
//!
//! * The select lists the repository's branches by short name (no tags), the
//! one set is selected, and Save is disabled until another is chosen.
//! * Another branch chosen and Save pressed with a REAL mouse click sends
//! `PATCH /api/repos/acme/app {"default_branch":"refs/heads/main"}`; the page
//! says so in plain words and the Default branch metric moves.
//! * A refusal (gunnar's 403) is said in plain words, gunnar's own sentence
//! under it, and nothing on the page claims the branch moved.
//!
//! Needs `GUNNAR_UI_CHROME` (a Chrome binary) and `GUNNAR_UI_DIST` (the built
//! console's `dist`); without both each test says NOT RUN and returns.
#![cfg(test)]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::cdp::{Cdp, Chrome};
use crate::json::{esc, Json};
const STATUS: &str = r#"{"authenticated":true,"user":"rickard","role":"admin","passkey_enabled":true,"open":false,"account":"rickard","account_role":"admin"}"#;
const HEALTH: &str = r#"{"serving":true,"agent":"gunnar","version":"1.5.0","build_ref":"v1.4.0","uptime_seconds":60,"grpc_authentication_enabled":true,"grpc_bind":"127.0.0.1:1","ssh_bind":"127.0.0.1:2"}"#;
const REFS: &str = r#"{"items":[
{"ref_name":"refs/heads/ny_dok","oid":"1111111111111111111111111111111111111111","symbolic_target":null,"peeled_oid":null},
{"ref_name":"refs/heads/main","oid":"2222222222222222222222222222222222222222","symbolic_target":null,"peeled_oid":null},
{"ref_name":"refs/tags/v1","oid":"3333333333333333333333333333333333333333","symbolic_target":null,"peeled_oid":null}],
"total":3,"truncated":false}"#;
const AUTHORITY: &str = r#"{"account":"acme","account_admin":true,"may_change":true,"why":null}"#;
const REFUSAL: &str = r#"{"code":"permissiondenied","message":"Repos.SetDefaultBranch needs Admin on acme/app, which rickard does not hold"}"#;
/// The stub's state: the default branch gunnar holds, whether it refuses, and
/// every PATCH body it was sent.
#[derive(Default)]
struct Stub {
branch: String,
refuse: bool,
patched: Vec<String>,
}
type Shared = Arc<Mutex<Stub>>;
fn repo_json(branch: &str) -> String {
format!(
r#"{{"path":"acme/app","namespace":"acme","name":"app","hash_kind":"sha256","default_branch":"{branch}","visibility":"private","created_at_unix_ms":1760000000000,"description":"","encrypted":false}}"#
)
}
fn mime(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("js") => "text/javascript",
Some("wasm") => "application/wasm",
Some("css") => "text/css",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
}
}
fn answer(mut conn: TcpStream, dist: &Path, stub: &Shared) {
let _ = conn.set_read_timeout(Some(Duration::from_secs(5)));
let mut r = BufReader::new(conn.try_clone().unwrap());
let mut line = String::new();
if r.read_line(&mut line).is_err() {
return;
}
let mut parts = line.split_whitespace();
let (method, target) = (parts.next().unwrap_or("").to_owned(), parts.next().unwrap_or("/").to_owned());
let mut len = 0usize;
loop {
let mut h = String::new();
if r.read_line(&mut h).is_err() || h.trim().is_empty() {
break;
}
if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; len];
let _ = r.read_exact(&mut body);
let path = target.split('?').next().unwrap_or("/").to_owned();
let json = |s: &str| (200u16, "application/json", s.as_bytes().to_vec());
let (code, kind, out): (u16, &str, Vec<u8>) = match (method.as_str(), path.as_str()) {
("GET", "/auth/status") => json(STATUS),
("GET", "/api/health") => json(HEALTH),
("GET", "/api/repos/acme/app") => json(&repo_json(&stub.lock().unwrap().branch)),
("GET", "/api/repos/acme/app/refs") => json(REFS),
("GET", "/api/repos/acme/app/visibility") => json(AUTHORITY),
("PATCH", "/api/repos/acme/app") => {
let sent = String::from_utf8_lossy(&body).into_owned();
let mut s = stub.lock().unwrap();
s.patched.push(sent.clone());
if s.refuse {
(403, "application/json", REFUSAL.as_bytes().to_vec())
} else {
let to = crate::json::parse(&sent).ok().and_then(|j| j.get("default_branch").and_then(|v| v.as_str().map(str::to_owned)));
if let Some(to) = to {
s.branch = to;
}
json(&repo_json(&s.branch))
}
}
("GET", p) if p.starts_with("/api/") || p.starts_with("/auth/") => (404, "application/json", br#"{"code":"notfound","message":"not in this stub"}"#.to_vec()),
("GET", p) => {
// A built file, else the app's own page (its router takes the path).
let file = dist.join(p.trim_start_matches('/'));
let file = if p != "/" && file.is_file() { file } else { dist.join("index.html") };
match std::fs::read(&file) {
Ok(bytes) => (200, mime(&file), bytes),
Err(_) => (404, "text/plain", b"no".to_vec()),
}
}
_ => (404, "text/plain", b"no".to_vec()),
};
let head = format!("HTTP/1.1 {code} X\r\ncontent-type: {kind}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", out.len());
let _ = conn.write_all(head.as_bytes());
let _ = conn.write_all(&out);
}
fn serve(dist: PathBuf, stub: Shared) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
for conn in l.incoming().flatten() {
let (dist, stub) = (dist.clone(), stub.clone());
std::thread::spawn(move || answer(conn, &dist, &stub));
}
});
port
}
/// The browser on Repo → Settings of acme/app, or NOT RUN.
fn rig(tag: &str, refuse: bool) -> Option<(Chrome, Cdp, String, Shared)> {
let (Some(bin), Some(dist)) = (std::env::var("GUNNAR_UI_CHROME").ok().filter(|v| !v.is_empty()), std::env::var("GUNNAR_UI_DIST").ok().filter(|v| !v.is_empty())) else {
eprintln!("NOT RUN: set GUNNAR_UI_CHROME (a Chrome binary) and GUNNAR_UI_DIST (the console's built dist)");
return None;
};
assert!(Path::new(&dist).join("index.html").is_file(), "{dist} holds no built console");
let stub: Shared = Arc::new(Mutex::new(Stub { branch: "refs/heads/ny_dok".to_owned(), refuse, patched: Vec::new() }));
let port = serve(PathBuf::from(dist), stub.clone());
let debug = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let profile = std::env::temp_dir().join(format!("repodefault-{tag}-{}", std::process::id()));
let chrome = Chrome::launch(&bin, &profile, debug).expect("chrome starts");
let mut cdp = Cdp::attach(debug).expect("cdp");
let sess = cdp.page().expect("a page");
cdp.navigate(&sess, &format!("http://127.0.0.1:{port}/repo/acme/app/settings")).expect("the console loads");
if let Err(e) = cdp.wait_for(&sess, "!!document.querySelector('#branch-select option')", 30_000) {
let text = cdp.eval_str(&sess, "document.body.innerText").unwrap_or_default();
panic!("Settings never drew the branch select ({e}): {}", crate::http::cut(&text, 600));
}
Some((chrome, cdp, sess, stub))
}
/// A person's click: the mouse pressed and released at the middle of the
/// element's own box, scrolled into view first.
fn click(c: &mut Cdp, sess: &str, selector: &str) {
let at = c
.eval(sess, &format!("(() => {{ const e = document.querySelector({}); e.scrollIntoView({{block: 'center'}}); const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()", esc(selector)))
.unwrap_or_else(|e| panic!("no {selector} to press: {e}"));
let xy: Vec<f64> = at.arr().iter().filter_map(Json::as_f64).collect();
for kind in ["mousePressed", "mouseReleased"] {
c.call(Some(sess), "Input.dispatchMouseEvent", &format!(r#"{{"type":"{kind}","x":{},"y":{},"button":"left","clickCount":1}}"#, xy[0], xy[1])).expect("the mouse");
}
}
/// A person's choice in a `<select>`: focused by a click, then the keyboard
/// (a headless browser draws no native popup to click in), which fires the
/// same `change` a mouse pick does.
fn choose(c: &mut Cdp, sess: &str, selector: &str, value: &str) {
let want = c
.eval(sess, &format!("[...document.querySelector({}).options].findIndex(o => o.value === {})", esc(selector), esc(value)))
.unwrap()
.as_f64()
.expect("an index") as i64;
assert!(want >= 0, "{selector} offers no {value}");
c.eval(sess, &format!("document.querySelector({}).focus()", esc(selector))).unwrap();
for _ in 0..8 {
let at = c.eval(sess, &format!("document.querySelector({}).selectedIndex", esc(selector))).unwrap().as_f64().unwrap() as i64;
if at == want {
break;
}
let key = if at > want { ("ArrowUp", 38) } else { ("ArrowDown", 40) };
for kind in ["rawKeyDown", "keyUp"] {
c.call(Some(sess), "Input.dispatchKeyEvent", &format!(r#"{{"type":"{kind}","key":"{}","code":"{}","windowsVirtualKeyCode":{},"nativeVirtualKeyCode":{}}}"#, key.0, key.0, key.1, key.1)).expect("the keyboard");
}
}
let now = c.eval_str(sess, &format!("document.querySelector({}).value", esc(selector))).unwrap();
if now != value {
// Some Chrome builds keep a focused select's keys for a popup they never draw: pick it as the
// popup would, with the same `change` event.
c.eval(sess, &format!("(() => {{ const s = document.querySelector({}); s.value = {}; s.dispatchEvent(new Event('change', {{bubbles: true}})); }})()", esc(selector), esc(value))).unwrap();
}
assert_eq!(c.eval_str(sess, &format!("document.querySelector({}).value", esc(selector))).unwrap(), value);
}
fn truthy(c: &mut Cdp, sess: &str, expr: &str) -> bool {
c.eval(sess, &format!("!!({expr})")).unwrap_or_else(|e| panic!("{expr}: {e}")).as_bool().unwrap_or(false)
}
fn metric(c: &mut Cdp, sess: &str) -> String {
c.eval_str(sess, "(() => { const d = [...document.querySelectorAll('dt')].find(e => e.textContent.trim() === 'Default branch'); return d ? d.nextElementSibling.textContent.trim() : ''; })()").unwrap()
}
#[test]
fn settings_changes_the_default_branch_with_a_select_and_save() {
let Some((_chrome, mut c, sess, stub)) = rig("change", false) else { return };
// The branches, by short name, no tag; the one set is selected; nothing to save yet.
let offered = c.eval_str(&sess, "[...document.querySelectorAll('#branch-select option')].map(o => o.value).join(',')").unwrap();
assert_eq!(offered, "main,ny_dok");
assert_eq!(c.eval_str(&sess, "document.querySelector('#branch-select').value").unwrap(), "ny_dok");
assert_eq!(metric(&mut c, &sess), "ny_dok");
assert!(truthy(&mut c, &sess, "document.querySelector('#branch-save').disabled"), "Save is armed with the branch already set");
click(&mut c, &sess, "#branch-save");
std::thread::sleep(Duration::from_millis(300));
assert!(stub.lock().unwrap().patched.is_empty(), "a disabled Save sent something");
choose(&mut c, &sess, "#branch-select", "main");
c.wait_for(&sess, "!document.querySelector('#branch-save').disabled", 5_000).expect("another branch arms Save");
click(&mut c, &sess, "#branch-save");
c.wait_for(&sess, "!!document.getElementById('branch-said')", 10_000).expect("the page says what happened");
assert_eq!(stub.lock().unwrap().patched, vec![r#"{"default_branch":"refs/heads/main"}"#.to_owned()]);
let said = c.eval_str(&sess, "document.getElementById('branch-said').innerText.trim()").unwrap();
assert_eq!(said, "The default branch is now main. A new clone checks it out.");
assert_eq!(metric(&mut c, &sess), "main", "the Default branch line still says the old one");
assert!(truthy(&mut c, &sess, "document.querySelector('#branch-save').disabled"), "Save stays armed for the branch now set");
}
#[test]
fn a_refused_change_is_said_in_plain_words_and_nothing_moves() {
let Some((_chrome, mut c, sess, stub)) = rig("refused", true) else { return };
choose(&mut c, &sess, "#branch-select", "main");
c.wait_for(&sess, "!document.querySelector('#branch-save').disabled", 5_000).expect("another branch arms Save");
click(&mut c, &sess, "#branch-save");
c.wait_for(&sess, "!!document.getElementById('branch-refused')", 10_000).expect("the refusal is said");
assert_eq!(stub.lock().unwrap().patched.len(), 1);
let said = c.eval_str(&sess, "document.getElementById('branch-refused').innerText").unwrap();
assert!(said.contains("Not changed: only an admin of acme/app may change its default branch."), "{said}");
assert!(said.contains("needs Admin on acme/app"), "gunnar's own sentence is not under it: {said}");
assert!(!said.contains("HTTP 403"), "{said}");
assert_eq!(metric(&mut c, &sess), "ny_dok", "the page claims a branch moved that gunnar refused");
assert!(!truthy(&mut c, &sess, "document.getElementById('branch-said')"));
}