Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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')"));
}