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