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/shots.rs

//! **Screenshots of every console page** (lane HOL-UI-FIX, owner 2026-10-31): not a check, a
//! camera. A real holger with data (the graph rig: two upstreams, the upload door, one
//! downstream with an unacknowledged invalidation; three uploads: one waiting for its two
//! approvals, one quarantined, one blocked), the owner signed in; then every page at 1280 px and
//! at 400 px, light and dark. Then the same pages on a fresh holger (the empty states) and
//! signed out (the refusals). Ignored in the suite; run it by name:
//!
//! ```text
//! SHOTS_DIR=/tgt/shots SHOTS_TAG=before cargo test --test shots -- --ignored
//! ```

use std::path::PathBuf;

use chromiumoxide::cdp::browser_protocol::emulation::{MediaFeature, SetDeviceMetricsOverrideParams, SetEmulatedMediaParams};
use chromiumoxide::page::ScreenshotParams;
use holger_ui_browser_tests::browser::{Browser, Tab};
use holger_ui_browser_tests::rig::{self, Rig, CONSOLE, WHEEL};

async fn register_owner(tab: &Tab) {
    tab.until("the bootstrap door", "!document.getElementById('reg').hidden").await;
    tab.fill("#token", CONSOLE).await;
    tab.js("document.getElementById('role').value = 'admin'").await.unwrap();
    tab.until("the register button", "!!document.getElementById('register')").await;
    tab.js("document.getElementById('reg').open = true").await.unwrap();
    tab.fill("#user", "owner").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;
    }
}

/// The login's register panel, as the page drives it, for an invited person.
async fn register_person(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;
    }
}

/// Upload `file` into `incoming` through the console's own upload panel.
async fn upload(tab: &Tab, rig: &Rig, file: &std::path::Path) {
    tab.open(&rig.at("/repo/incoming")).await.unwrap();
    tab.until("the upload panel", "!!document.querySelector('details.upload')").await;
    tab.js("document.querySelector('details.upload').open = true").await.unwrap();
    tab.pick_file("details.upload input[type=file]", file).await.unwrap();
    let name = file.file_name().unwrap().to_string_lossy().into_owned();
    tab.until("the name from the file", &format!("[...document.querySelectorAll('details.upload input[type=text]')].some(i => i.value === {})", serde_json::to_string(&name).unwrap())).await;
    tab.click_text("Upload").await;
    let said = tab.until("uploaded", "(document.querySelector('details.upload .ok') || document.querySelector('details.upload .err') || {}).textContent || ''").await;
    eprintln!("upload {name}: {said}");
}

fn out_dir() -> PathBuf {
    let dir = PathBuf::from(std::env::var("SHOTS_DIR").unwrap_or_else(|_| "target/shots".into())).join(std::env::var("SHOTS_TAG").unwrap_or_else(|_| "now".into()));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// One page at one width and theme: `<set>-<slug>-<width>-<theme>.png`.
async fn shoot(tab: &Tab, url: &str, set: &str, slug: &str, settle: &str) {
    let dir = out_dir();
    for width in [1280i64, 400] {
        for theme in ["light", "dark"] {
            tab.page.execute(SetDeviceMetricsOverrideParams::new(width, 900, 1.0, width < 600)).await.unwrap();
            tab.page.execute(SetEmulatedMediaParams::builder().features(vec![MediaFeature::new("prefers-color-scheme", theme)]).build()).await.unwrap();
            tab.open(url).await.unwrap();
            // What the page is waiting for, then a moment for the last answers to draw.
            let _ = tokio::time::timeout(std::time::Duration::from_secs(45), async {
                loop {
                    if tab.js(settle).await.ok().is_some_and(|v| v == serde_json::Value::Bool(true)) {
                        break;
                    }
                    tokio::time::sleep(std::time::Duration::from_millis(150)).await;
                }
            })
            .await;
            tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
            // The page's own width at this viewport: wider than the viewport = a sideways scroll.
            let wide = tab.js("document.documentElement.scrollWidth").await.unwrap_or_default();
            let png = tab.page.screenshot(ScreenshotParams::builder().full_page(true).build()).await.unwrap();
            let name = format!("{set}-{slug}-{width}-{theme}.png");
            std::fs::write(dir.join(&name), png).unwrap();
            let text = tab.js("document.body ? document.body.innerText.length : 0").await.unwrap_or_default();
            // What sticks out past the viewport, when something does: the widest element's tag and class.
            let widest = tab.js(&format!("(() => {{ let w = null; for (const e of document.querySelectorAll('main *')) {{ const r = e.getBoundingClientRect(); if (r.right > {width} + 1 && (!w || r.right > w.r)) w = {{ r: r.right, d: e.tagName + '.' + e.className }}; }} return w ? w.d + ' ' + Math.round(w.r) : ''; }})()")).await.unwrap_or_default();
            if widest.as_str().is_some_and(|w| !w.is_empty()) {
                eprintln!("wide {set}-{slug}-{width}: {widest}");
            }
            eprintln!("shot {name} (scrollWidth {wide}, text {text})");
        }
    }
}

/// No "…" loading word left on the page.
/// The console mounted (or a page of the server's own, the front and the login), and no "loading"
/// left on it.
const SETTLED: &str = "(!!document.querySelector('header.topbar') || (!!document.body && document.body.innerText.length > 40 && !document.querySelector('script[type=module]'))) && !/loading|connecting/i.test((document.querySelector('main')||document.body).innerText)";

/// The pages, as `(slug, path)`.
fn pages(artifact: &str, security: &str) -> Vec<(&'static str, String)> {
    let mut v: Vec<(&'static str, String)> = vec![
        ("home", "/".into()),
        ("overview", "/overview".into()),
        ("flow", "/flow".into()),
        ("flow-open-main", "/flow?open=main".into()),
        ("flow-open-all", "/flow?open=all".into()),
        ("repositories", "/repositories".into()),
        ("repo-incoming", "/repo/incoming".into()),
        ("search", format!("/search?name={}", "demo")),
        ("security", "/security".into()),
        ("operations", "/operations".into()),
        ("operations-migrate", "/operations/migrate".into()),
        ("mine", "/mine".into()),
        ("tokens", "/tokens".into()),
        ("account", "/account".into()),
        ("admin", "/admin".into()),
        ("plan", "/plan".into()),
        ("login", "/login".into()),
        ("nosuchpage", "/no-such-page".into()),
    ];
    if !artifact.is_empty() {
        v.push(("artifact", artifact.to_owned()));
    }
    if !security.is_empty() {
        v.push(("security-artifact", security.to_owned()));
    }
    v
}

/// The first link on the page at `path` whose address starts with `prefix`.
async fn first_link(tab: &Tab, rig: &Rig, path: &str, prefix: &str) -> String {
    tab.open(&rig.at(path)).await.unwrap();
    let p = serde_json::to_string(prefix).unwrap();
    let js = format!("(() => {{ const a = [...document.querySelectorAll('a')].map(a => a.getAttribute('href') || '').find(h => h.startsWith({p})); return a || ''; }})()");
    for _ in 0..60 {
        if let Ok(v) = tab.js(&js).await {
            if let Some(h) = v.as_str().filter(|h| !h.is_empty()) {
                return h.to_owned();
            }
        }
        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
    }
    eprintln!("no link {prefix} on {path}");
    String::new()
}

/// Admin's panes, each clicked: `<set>-admin-<pane>`.
async fn shoot_admin_panes(tab: &Tab, rig: &Rig, set: &str) {
    tab.open(&rig.at("/admin")).await.unwrap();
    tab.until("the admin tabs", "document.querySelectorAll('nav.tabs button').length > 0").await;
    let labels = tab.js("[...document.querySelectorAll('nav.tabs button')].map(b => b.textContent.trim())").await.unwrap();
    let dir = out_dir();
    for label in labels.as_array().cloned().unwrap_or_default() {
        let label = label.as_str().unwrap_or_default().to_owned();
        for width in [1280i64, 400] {
            tab.page.execute(SetDeviceMetricsOverrideParams::new(width, 900, 1.0, width < 600)).await.unwrap();
            tab.page.execute(SetEmulatedMediaParams::builder().features(vec![MediaFeature::new("prefers-color-scheme", "dark")]).build()).await.unwrap();
            tab.open(&rig.at("/admin")).await.unwrap();
            tab.until("the admin tabs", "document.querySelectorAll('nav.tabs button').length > 0").await;
            tab.click_text(&label).await;
            tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
            let png = tab.page.screenshot(ScreenshotParams::builder().full_page(true).build()).await.unwrap();
            let slug: String = label.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
            std::fs::write(dir.join(format!("{set}-admin-pane-{slug}-{width}-dark.png")), png).unwrap();
        }
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "a camera, not a check: run by name with --ignored"]
async fn shots_of_every_page() {
    // ── A: the graph rig with data, the owner signed in ──
    let rig = Rig::start_with_graph().await;
    rig.invalidate("pypi/demo_pkg@1.0.0", "the board's lag");
    let browser = Browser::launch().await.expect("chromium starts");
    let owner = browser.tab().await.unwrap();
    owner.open(&rig.at("/login")).await.unwrap();
    register_owner(&owner).await;
    let files = tempfile::tempdir().unwrap();
    let wheel = rig::write_wheel(files.path());
    let bytes = std::fs::read(&wheel).unwrap();
    let blocked = files.path().join("badlib-0.3.0-py3-none-any.whl");
    std::fs::write(&blocked, &bytes).unwrap();
    let held = files.path().join("tool-2.1.0.tar.gz");
    std::fs::write(&held, b"not really a tarball, a test file\n").unwrap();
    // The owner holds the approver's duties, so an invited writer uploads (holger-duty-2).
    let writer = browser.person().await.unwrap();
    let invite = rig.invite("wanda", "holger-writers");
    writer.open(&rig.at(&format!("/login?invite={invite}"))).await.unwrap();
    register_person(&writer, "wanda").await;
    for f in [&wheel, &blocked, &held] {
        upload(&writer, &rig, f).await;
    }
    for want in ["SCANNED", "POLICY_CHECKED"] {
        let (status, body) = rig.console_post(&format!("/-/intake/advance?name={WHEEL}&verb=advance"), None);
        eprintln!("advance {WHEEL} -> {want}: {status} {body}");
    }
    let (s, b) = rig.console_post("/-/intake/advance?name=badlib-0.3.0-py3-none-any.whl&verb=block&reason=license%20GPL-3.0-only%20is%20not%20allowed%20here", None);
    eprintln!("block: {s} {b}");
    let (s, b) = rig.console_post("/-/intake/advance?name=tool-2.1.0.tar.gz&verb=quarantine&reason=malware%20scanner%3A%20EICAR%20test%20signature", None);
    eprintln!("quarantine: {s} {b}");

    let mut artifact = first_link(&owner, &rig, "/repo/incoming", "/artifact").await;
    if artifact.is_empty() {
        artifact = first_link(&owner, &rig, &format!("/search?name={}", "demo"), "/artifact").await;
    }
    let security = first_link(&owner, &rig, "/security", "/security/artifact").await;
    for (slug, path) in pages(&artifact, &security) {
        shoot(&owner, &rig.at(&path), "data", slug, SETTLED).await;
    }
    shoot_admin_panes(&owner, &rig, "data").await;

    // ── C: the same holger, signed out (the refusals) ──
    let stranger = browser.person().await.unwrap();
    for (slug, path) in pages("", "") {
        shoot(&stranger, &rig.at(&path), "signedout", slug, SETTLED).await;
    }

    // ── B: a fresh holger, the owner signed in (the empty states) ──
    let fresh = Rig::start().await;
    let other = Browser::launch().await.expect("chromium starts");
    let tab = other.tab().await.unwrap();
    tab.open(&fresh.at("/login")).await.unwrap();
    register_owner(&tab).await;
    for (slug, path) in pages("", "") {
        shoot(&tab, &fresh.at(&path), "empty", slug, SETTLED).await;
    }
}