browser-tests/tests/flow_front_and_links.rs
//! ★ **The front unfolds, and another Holger leads to its own flow** (HOL-FLOW-FRONT,
//! Henrik's Q1 and Q4 of HOL-FLOW-SMART), in a real browser against a real holger.
//!
//! * **Q1.** The front reports its doors and the names it serves to main (holger
//! `front_report`); the console's Flow page then unfolds the front at level 2 (a click on the
//! front, holger's link over the image) and at level 3 (`open=all`), with what it reported
//! inside its border. Before its report the front is one closed box with nothing to click.
//! * **Q4.** A downstream that reported its console's address carries a link to THAT server's
//! own flow: a person's press on it lands on the other console's `/flow`. A downstream with
//! no known console has no such link.
//!
//! The console stays thin: it lays holger's links over holger's image, and decides nothing.
//! Its own test binary: [`Rig::pair_with_front`] is process-wide.
//!
//! With `SHOTS_DIR` set, the camera keeps a picture of each step (`SHOTS_TAG`, default `now`).
use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
use chromiumoxide::page::ScreenshotParams;
use holger_ui_browser_tests::browser::{Browser, Tab};
use holger_ui_browser_tests::rig::{Rig, CONSOLE, FRONT_REPO_HOST, GRAPH_DOWNSTREAM};
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();
owner.until("the register button", "!!document.getElementById('register')").await;
owner.js("document.getElementById('reg').open = true").await.unwrap();
owner.fill("#user", "owner").await;
owner.click("#register").await;
owner.until("registered", holger_ui_browser_tests::browser::SIGNED_IN_OR_REGISTERED).await;
if !owner.signed_in().await {
owner.press("#login").await;
owner.until("holger's session in this tab", holger_ui_browser_tests::browser::SIGNED_IN).await;
}
owner
}
/// The drawing's zoom level, as holger wrote it into the image (`data-level`).
const LEVEL: &str = "(() => { const i = document.querySelector('[data-board=estate] img.board-img'); if (!i) return ''; const m = decodeURIComponent(i.src).match(/data-level=\"(\\d)\"/); return m ? m[1] : ''; })()";
/// The drawing the page shows, decoded from its image.
async fn drawn(tab: &Tab) -> String {
tab.until("the drawing", "!!document.querySelector('[data-board=estate] img.board-img')").await;
tab.js("(() => { const s = document.querySelector('[data-board=estate] img.board-img').src; return decodeURIComponent(s.slice(s.indexOf(',') + 1)); })()")
.await
.unwrap()
.as_str()
.unwrap()
.to_owned()
}
/// A selector for holger's link over the image to `href`.
fn hit(href: &str) -> String {
format!("[data-board=estate] a.board-hit[href=\"{href}\"]")
}
/// The camera: one picture of the page as it is, when `SHOTS_DIR` is set.
async fn shot(tab: &Tab, name: &str) {
let Ok(dir) = std::env::var("SHOTS_DIR") else {
return;
};
let dir = std::path::PathBuf::from(dir).join(std::env::var("SHOTS_TAG").unwrap_or_else(|_| "now".into()));
std::fs::create_dir_all(&dir).unwrap();
// The whole drawing in the picture: a wide window, and the Flow's scroll frame let out.
tab.page.execute(SetDeviceMetricsOverrideParams::new(2400, 1600, 1.0, false)).await.unwrap();
let _ = tab.js("(() => { const s = document.createElement('style'); s.textContent = '.board-scroll{max-height:none!important;overflow:visible!important}'; document.head.appendChild(s); return true; })()").await;
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
let png = tab.page.screenshot(ScreenshotParams::builder().full_page(true).build()).await.expect("a picture");
tab.page.execute(SetDeviceMetricsOverrideParams::new(1600, 1000, 1.0, false)).await.unwrap();
std::fs::write(dir.join(format!("{name}.png")), png).unwrap();
}
/// **The other server's console**, on loopback: its own Flow page, so a press on the link
/// lands somewhere that says whose flow it is.
async fn other_console() -> String {
let app = axum::Router::new().route(
"/lab/flow",
axum::routing::get(|| async { axum::response::Html("<!doctype html><title>Flow</title><h1>lab-holger's own flow</h1>") }),
);
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap();
tokio::spawn(async move { axum::serve(l, app).await });
format!("http://127.0.0.1:{}/lab", at.port())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_front_unfolds_at_levels_two_and_three_and_a_downstream_leads_to_its_own_flow() {
let rig = Rig::start_with_graph().await;
rig.pair_with_front();
let browser = Browser::launch().await.expect("chromium starts");
let owner = owner_of(&rig, &browser).await;
owner.page.execute(SetDeviceMetricsOverrideParams::new(1600, 1000, 1.0, false)).await.unwrap();
let down = format!("downstream:{GRAPH_DOWNSTREAM}");
// ── before any report: the front is one closed box, nothing to click on it ──
owner.open(&rig.at("/flow?open=")).await.unwrap();
assert_eq!(owner.until("level 1", LEVEL).await, serde_json::json!("1"));
let svg = drawn(&owner).await;
assert!(svg.contains(r#"data-node="estate:front""#), "the pairing names a front: it is drawn");
owner.until("main's link over the image", &format!("!!document.querySelector('{}')", hit("flow?open=main").replace('\'', "\\'"))).await;
let none = owner.js(&format!("document.querySelectorAll('{}').length", hit("flow?open=front").replace('\'', "\\'"))).await.unwrap();
assert_eq!(none, serde_json::json!(0), "a front that never reported has nothing to unfold");
let out = owner.js("[...document.querySelectorAll('[data-board=estate] a.board-hit')].filter(a => !a.getAttribute('href').startsWith('flow?')).length").await.unwrap();
assert_eq!(out, serde_json::json!(0), "no console known for any server: no link out");
shot(&owner, "1-level-1-no-reports").await;
owner.open(&rig.at("/flow?open=all")).await.unwrap();
owner.until("level 3", &format!("{LEVEL} === '3'")).await;
shot(&owner, "2-level-3-no-reports").await;
// ── the front reports its doors and routes; the downstream its console ──
let kept = rig.front_reports();
let lab = other_console().await;
rig.downstream_reports_console(&lab);
let own_flow = format!("{lab}/flow");
// The pictures first, then what they must show.
owner.open(&rig.at("/flow?open=")).await.unwrap();
owner.until("level 1", &format!("{LEVEL} === '1'")).await;
shot(&owner, "3-level-1-reported").await;
owner.open(&rig.at("/flow?open=all")).await.unwrap();
owner.until("level 3", &format!("{LEVEL} === '3'")).await;
shot(&owner, "4-level-3-reported").await;
assert_eq!(kept, 200, "holger keeps its front's report");
// Level 1: the front now unfolds; a click on it is level 2, the front unfolded in place.
owner.open(&rig.at("/flow?open=")).await.unwrap();
owner.until("level 1", &format!("{LEVEL} === '1'")).await;
owner.click(&hit("flow?open=front")).await;
owner.until("the Flow, the front unfolded", "location.pathname === '/flow' && location.search === '?open=front'").await;
owner.until("level 2", &format!("{LEVEL} === '2'")).await;
let svg = drawn(&owner).await;
for part in ["estate:front:door:https", "estate:front:door:http", &format!("estate:front:host:{FRONT_REPO_HOST}"), "estate:front:host:www.holger.test"] {
assert!(svg.contains(&format!(r#"data-node="{part}""#)), "level 2 shows the front's {part}");
}
assert!(!svg.contains(r#"data-node="door:web""#) && !svg.contains(r#"data-node="incoming""#), "main stays folded");
let (_, model) = rig.console_get("/-/flow.json?open=front");
let model: serde_json::Value = serde_json::from_str(&model).unwrap();
for n in model["nodes"].as_array().unwrap() {
let id = n["id"].as_str().unwrap();
assert!(svg.contains(&format!(r#"data-node="{id}""#)), "the console shows holger's node {id}, as holger drew it");
}
shot(&owner, "5-level-2-front-unfolded").await;
// The front folds again from its own head.
owner.until("the front's fold link", &format!("!!document.querySelector('{}')", hit("flow?open=").replace('\'', "\\'"))).await;
// Level 3: everything, the front's parts and main's.
owner.open(&rig.at("/flow?open=all")).await.unwrap();
owner.until("level 3", &format!("{LEVEL} === '3'")).await;
let svg = drawn(&owner).await;
assert!(svg.contains(&format!(r#"data-node="estate:front:host:{FRONT_REPO_HOST}""#)) && svg.contains(r#"data-node="incoming""#), "level 3 shows the front's parts and main's");
// ── Q4: the downstream leads to its own flow, at its console ──
owner.open(&rig.at("/flow?open=")).await.unwrap();
owner.until("level 1", &format!("{LEVEL} === '1'")).await;
let link = hit(&own_flow);
owner.until("the downstream's own-flow link over the image", &format!("!!document.querySelector('{}')", link.replace('\'', "\\'"))).await;
let label = owner.js(&format!("document.querySelector('{}').getAttribute('aria-label')", link.replace('\'', "\\'"))).await.unwrap();
assert_eq!(label, serde_json::json!(format!("open {GRAPH_DOWNSTREAM}'s own flow at its console")));
let svg = drawn(&owner).await;
assert!(svg.contains(&format!(r#"data-own-flow="{down}""#)), "holger drew it");
// On top: the point a person presses is the link out, not the box's unfold.
let top = owner
.js(&format!("(() => {{ const a = document.querySelector('{}'); a.scrollIntoView({{ block: 'center', inline: 'center' }}); const r = a.getBoundingClientRect(); const e = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return e === a; }})()", link.replace('\'', "\\'")))
.await
.unwrap();
assert_eq!(top, serde_json::json!(true), "the link out lies on top of the box");
shot(&owner, "6-level-1-own-flow-link").await;
owner.press(&link).await;
owner.until_url("the other server's own flow", &own_flow).await;
owner.until("its page", "document.body.innerText.includes(\"lab-holger's own flow\")").await;
shot(&owner, "7-the-other-servers-flow").await;
}