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

nordisk/holger-ui

browser-tests/tests/page_jobs.rs

//! ★ **One menu entry, one job** (lane HOL-UI-FIX, owner 2026-10-31: "the holger ui ... did look
//! like shit, with 3 menus doing the same flow"). A holger declaring two upstream sources, the
//! upload door and one downstream with an unacknowledged invalidation; an invited writer's
//! wheel waits for its two approvals; the owner signed in, in a real browser:
//!
//! * the menu is five jobs (Overview, Flow, Repositories, Security, Account) and Admin for an
//!   admin; each page marks its own entry; the search is in the header;
//! * the Overview is the estate's state at a glance: what needs action, every box, each
//!   downstream's last sync and lag, the intake queue, the refusals, the storage, the sources
//!   and this server, all of it holger's answers, and NO picture: it links to the Flow;
//! * the Flow page is the only picture;
//! * Repositories carries search, replication and the migration as its tabs, and Account the
//!   person's own repositories and tokens.

use holger_ui_browser_tests::browser::{Browser, Tab, NEVER};
use holger_ui_browser_tests::rig::{self, Rig, CONSOLE, GRAPH_DOWNSTREAM, WHEEL};

async fn register(tab: &Tab, user: &str) {
    tab.until("the register button", "!!document.getElementById('register')").await;
    tab.js("document.getElementById('reg').open = true").await.unwrap();
    tab.fill("#user", user).await;
    tab.click("#register").await;
    tab.until("registered", holger_ui_browser_tests::browser::SIGNED_IN_OR_REGISTERED).await;
    if !tab.signed_in().await {
        tab.press("#login").await;
        tab.until("holger's session in this tab", holger_ui_browser_tests::browser::SIGNED_IN).await;
    }
}

async fn owner_of(rig: &Rig, browser: &Browser) -> Tab {
    let owner = browser.tab().await.unwrap();
    owner.open(&rig.at("/login")).await.unwrap();
    owner.until("the bootstrap door", "!document.getElementById('reg').hidden").await;
    owner.fill("#token", CONSOLE).await;
    owner.js("document.getElementById('role').value = 'admin'").await.unwrap();
    register(&owner, "owner").await;
    owner
}

/// The text of the Overview card `key`, once it says more than "loading".
async fn card(tab: &Tab, key: &str, what: &str) -> String {
    let sel = serde_json::to_string(&format!("[data-overview={key}]")).unwrap();
    let v = tab.until(&format!("the Overview's {key} card ({what})"), &format!("(() => {{ const c = document.querySelector({sel}); if (!c || /loading/.test(c.innerText)) return ''; return c.innerText; }})()")).await;
    v.as_str().unwrap_or_default().to_owned()
}

/// The menu's entries, as shown, and the one marked.
const MENU: &str = "[...document.querySelectorAll('header nav.nav a')].map(a => a.textContent.trim())";
const MARKED: &str = "(document.querySelector('header nav.nav a[aria-current=page]') || {}).textContent || ''";

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_menu_is_one_entry_per_job_and_each_page_marks_its_own() {
    let rig = Rig::start().await;
    let browser = Browser::launch().await.expect("chromium starts");
    let owner = owner_of(&rig, &browser).await;

    owner.open(&rig.at("/overview")).await.unwrap();
    owner.until("the Admin entry for an admin", &format!("{MENU}.includes('Admin')")).await;
    let menu = owner.js(MENU).await.unwrap();
    assert_eq!(menu, serde_json::json!(["Overview", "Flow", "Repositories", "Security", "Account", "Admin"]), "one entry per job: no Operations, no Mine, no Search entry");

    for (page, entry) in [
        ("/overview", "Overview"),
        ("/flow", "Flow"),
        ("/repositories", "Repositories"),
        ("/repo/incoming", "Repositories"),
        ("/search?name=demo", "Repositories"),
        ("/operations", "Repositories"),
        ("/operations/migrate", "Repositories"),
        ("/security", "Security"),
        ("/account", "Account"),
        ("/mine", "Account"),
        ("/tokens", "Account"),
        ("/admin", "Admin"),
    ] {
        owner.open(&rig.at(page)).await.unwrap();
        let marked = owner.until(&format!("{page} marks its entry"), MARKED).await;
        assert_eq!(marked, serde_json::json!(entry), "{page} sits under {entry}");
    }

    // The search is in the header, on every page: it runs the Search page.
    owner.open(&rig.at("/security")).await.unwrap();
    owner.fill("header form[role=search] input[name=name]", "demo").await;
    owner.js("document.querySelector('header form[role=search]').requestSubmit()").await.unwrap();
    owner.until_url("the header's search runs the Search page", &rig.at("/search?name=demo")).await;

    // Repositories' tabs carry what works across repositories; Account's the person's own.
    owner.open(&rig.at("/repositories")).await.unwrap();
    let tabs = owner.until("the Repositories tabs", "(() => { const t = [...document.querySelectorAll('nav.subnav[aria-label=Repositories] a')].map(a => a.textContent.trim()); return t.length ? t : null; })()").await;
    assert_eq!(tabs, serde_json::json!(["All repositories", "Search", "Replicate", "Migrate in"]));
    owner.open(&rig.at("/account")).await.unwrap();
    let tabs = owner.until("the Account tabs", "(() => { const t = [...document.querySelectorAll('nav.subnav[aria-label=Account] a')].map(a => a.textContent.trim()); return t.length ? t : null; })()").await;
    assert_eq!(tabs, serde_json::json!(["Account", "My repositories", "My plan", "My tokens"]));
    owner.assert_no_link_to(NEVER).await.unwrap();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_overview_is_the_estate_at_a_glance_and_draws_no_picture() {
    let rig = Rig::start_with_graph().await;
    // One invalidation the downstream has not acknowledged: its lag is one generation.
    rig.invalidate("pypi/demo_pkg@1.0.0", "the board's lag");
    let browser = Browser::launch().await.expect("chromium starts");
    let owner = owner_of(&rig, &browser).await;

    // An invited writer's wheel, scanned and policy-checked: it waits for two approvals.
    let writer = browser.person().await.unwrap();
    let invite = rig.invite("wanda", "holger-writers");
    writer.open(&rig.at(&format!("/login?invite={invite}"))).await.unwrap();
    register(&writer, "wanda").await;
    let files = tempfile::tempdir().unwrap();
    let wheel = rig::write_wheel(files.path());
    writer.open(&rig.at("/repo/incoming")).await.unwrap();
    writer.until("the upload panel", "!!document.querySelector('details.upload')").await;
    writer.js("document.querySelector('details.upload').open = true").await.unwrap();
    writer.pick_file("details.upload input[type=file]", &wheel).await.unwrap();
    writer.until("the name from the file", &format!("[...document.querySelectorAll('details.upload input[type=text]')].some(i => i.value === {})", serde_json::to_string(WHEEL).unwrap())).await;
    writer.click_text("Upload").await;
    writer.until("uploaded", "!!document.querySelector('details.upload .ok')").await;
    for want in ["SCANNED", "POLICY_CHECKED"] {
        let (status, body) = rig.console_post(&format!("/-/intake/advance?name={WHEEL}&verb=advance"), None);
        assert!(status == 200 && body.contains(want), "→ {want}: {status} {body}");
    }

    owner.open(&rig.at("/overview")).await.unwrap();

    // What needs a person: the wheel waiting for its approvals, linked to its security view.
    let todo = card(&owner, "needs-action", "the waiting wheel").await;
    assert!(todo.contains(&format!("{WHEEL} waits for approval")), "{todo}");
    let link = owner.js(&format!("!!document.querySelector('[data-overview=needs-action] a[href*={}]')", serde_json::to_string(&format!("name={WHEEL}")).unwrap())).await.unwrap();
    assert_eq!(link, serde_json::Value::Bool(true), "the waiting wheel links to its security view");

    // Every box holger knows, with its state; the downstream with its lag. holger's own rows.
    let (_, served) = rig.console_get("/-/estate.json");
    let v: serde_json::Value = serde_json::from_str(&served).unwrap();
    let boxes = card(&owner, "boxes", "the boxes").await;
    for b in v["boxes"].as_array().unwrap().iter().filter(|b| b["role"] != "upstream") {
        let name = b["name"].as_str().unwrap();
        let shown = owner.js(&format!("!!document.querySelector('[data-overview=boxes] [data-box={}]')", serde_json::to_string(name).unwrap())).await.unwrap();
        assert_eq!(shown, serde_json::Value::Bool(true), "the box {name} is on the Overview: {boxes}");
        assert!(boxes.contains(b["headline"].as_str().unwrap()), "{name}: holger's own words ({}): {boxes}", b["headline"]);
    }
    assert!(boxes.contains("1 entry behind"), "the downstream's lag in entries: {boxes}");

    // Each downstream's last sync and lag, the sync board's own words.
    let down = card(&owner, "downstreams", "the downstreams").await;
    // innerText follows the CSS (a table head is set in capitals), so the heads are read lower-cased.
    assert!(down.contains(GRAPH_DOWNSTREAM) && down.to_lowercase().contains("last sync") && down.contains("1 generation(s)"), "{down}");

    // The intake queue, the refusals, the storage, the sources, this server.
    let queue = card(&owner, "queue", "the queue").await;
    assert!(queue.to_lowercase().contains("awaiting approval") || queue.contains('1'), "the queue counts: {queue}");
    card(&owner, "refusals", "the refusals").await;
    let storage = card(&owner, "storage", "the storage").await;
    assert!(storage.contains("% full") && storage.contains(" used of "), "this box's measured data volume: {storage}");
    let sources = card(&owner, "sources", "the sources").await;
    for s in ["crates-io", "cloud", "team"] {
        assert!(sources.contains(s), "the source {s}: {sources}");
    }
    let server = card(&owner, "server", "this server").await;
    assert!(server.contains("up ") && server.contains("Repositories"), "{server}");

    // No picture: the flow is the Flow page's, linked.
    let drawn = owner.js("!!document.querySelector('[data-board]') || !!document.querySelector('img.board-img')").await.unwrap();
    assert_eq!(drawn, serde_json::Value::Bool(false), "the Overview draws the flow again: {}", owner.text().await);
    owner.click("[data-overview=flow-link]").await;
    owner.until("the Flow page, the picture", "location.pathname === '/flow' && !!document.querySelector('[data-board=estate] img.board-img')").await;
    owner.assert_no_link_to(NEVER).await.unwrap();
}

/// ★ At a phone's width every page fits: no sideways scroll on the Overview, the Flow page
/// scrolls its picture inside its own frame, and the menu wraps instead of hiding entries.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_page_fits_a_phone() {
    use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
    let rig = Rig::start_with_graph().await;
    let browser = Browser::launch().await.expect("chromium starts");
    let owner = owner_of(&rig, &browser).await;
    owner.page.execute(SetDeviceMetricsOverrideParams::new(400, 860, 1.0, true)).await.unwrap();
    for page in ["/overview", "/flow", "/repositories", "/search?name=demo", "/operations", "/security", "/account", "/mine", "/tokens", "/admin"] {
        owner.open(&rig.at(page)).await.unwrap();
        owner.until(&format!("{page} drawn"), "!!document.querySelector('main section') && !/loading/.test(document.querySelector('main').innerText)").await;
        let wide = owner.js("document.documentElement.scrollWidth").await.unwrap();
        assert!(wide.as_i64().unwrap_or(9999) <= 400, "{page} is {wide} px wide at a 400 px phone: a sideways scroll");
        let hidden = owner.js("[...document.querySelectorAll('header nav.nav a')].filter(a => { const r = a.getBoundingClientRect(); return r.right > 400 || r.width === 0; }).map(a => a.textContent)").await.unwrap();
        assert_eq!(hidden, serde_json::json!([]), "{page}: menu entries off the screen");
    }
}