crates/monetize-ui-server/tests/overview_browser.rs
//! ★ **The Overview, clicked** (OVW-CLEAN, owner 2026-10-29): the rows are the estates that
//! EXIST, what ended or was never installed is one quiet line, and one empty slot makes a new
//! estate and hands it to the one site order.
//!
//! The BUILT console (`trunk build`'s `dist`) is served by the REAL router of this crate, over a
//! fake monetize that answers `Estates.Overview / NewEstate` (and `Guardian.Cases` for the rows'
//! summaries) on the wire — so the page, this server's routes and the gRPC mapping are the real
//! ones. Headless Chrome drives it over the DevTools Protocol (`uitest`'s own client) with REAL
//! mouse clicks:
//!
//! 1. the rows: one per estate that exists, a row being laid says monetize's words; no row
//! reads "not installed";
//! 2. the quiet line: "2 ended or never-installed", closed, and opened it names them;
//! 3. the slot: a name, a product, the ready mail's address, Order → monetize's sentence and
//! "Pay 0.50 EUR" (the vendor's page), and the new row with NO reload; a refusal is the
//! server's own sentence.
//!
//! Needs `MONETIZE_UI_CHROME` (a Chrome binary) and `MONETIZE_UI_DIST` (the built console);
//! without both the test says NOT RUN and returns.
#[path = "../../../uitest/src/cdp.rs"]
mod cdp;
#[allow(dead_code, clippy::collapsible_if)]
#[path = "../../../uitest/src/http.rs"]
mod http;
#[allow(dead_code)]
#[path = "../../../uitest/src/json.rs"]
mod json;
#[allow(dead_code)]
#[path = "../../../uitest/src/ws.rs"]
mod ws;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use cdp::{Cdp, Chrome};
use json::{esc, Json};
use monetize_proto as pb;
use monetize_proto::estates_server::{Estates, EstatesServer};
use monetize_proto::guardian_server::{Guardian, GuardianServer};
use monetize_ui_server::app::App;
use monetize_ui_server::grpc::Monetize;
use monetize_ui_server::serve;
use monetize_ui_server::sse::EventHub;
use tonic::{Request as TReq, Response, Status};
const SERVICE: &str = "the-service-bearer";
const OPERATOR: &str = "the-operator-bearer";
const CHECKOUT: &str = "https://checkout.example.invalid/c/pay/cs_test_new_estate_1";
const REFUSED: &str = "REFUSED [new-estate-name] \"Bad Name\" is not an estate's name: lowercase letters, digits and inner hyphens, at most 63 (`test4-example-com`)";
/// What the fake monetize holds: its classified estates and every slot call taken.
#[derive(Default)]
struct Held {
overview: pb::EstatesOverviewResponse,
made: Vec<pb::NewEstateRequest>,
bearers: Vec<String>,
}
#[derive(Clone)]
struct Fake(Arc<Mutex<Held>>);
fn line(name: &str, product: &str, standing: &str, words: &str, in_fleet: bool, domain: &str) -> pb::EstateStanding {
pb::EstateStanding { name: name.into(), product: product.into(), standing: standing.into(), words: words.into(), in_fleet, domain: domain.into() }
}
/// Prod's ONE box as the owner saw it, classified: two estates serve, the test2 pair is
/// declared (the slot's), five leftovers of earlier orders and signups.
fn prod() -> pb::EstatesOverviewResponse {
pb::EstatesOverviewResponse {
estates: vec![line("gunnar-rs", "gunnar", "installed", "installed: its machines have reported in", true, "gunnar.rs"), line("holger-rs", "holger", "installed", "installed: its machines have reported in", true, "holger.rs")],
ended: vec![
line("acme2", "holger", "ended", "terminated on 2026-10-27", true, ""),
line("ignalina", "gunnar", "ended", "terminated on 2026-10-27", true, ""),
line("polka", "gunnar", "ended", "terminated on 2026-10-22", true, ""),
line("rickardtest", "gunnar", "ended", "terminated on 2026-10-27", true, ""),
line("test3-holger-rs", "holger", "ended", "terminated on 2026-10-23", true, ""),
],
slot: Some(pb::NewEstateSlot { products: vec!["gunnar".into(), "holger".into()], declared: vec!["test2-gunnar-rs".into(), "test2-holger-rs".into()] }),
}
}
impl Fake {
fn took<T>(&self, req: &TReq<T>) {
let bearer = req.metadata().get("authorization").and_then(|v| v.to_str().ok()).and_then(|s| s.strip_prefix("Bearer ")).unwrap_or("").to_owned();
self.0.lock().unwrap().bearers.push(bearer);
}
}
#[tonic::async_trait]
impl Estates for Fake {
async fn overview(&self, _: TReq<pb::EstatesOverviewRequest>) -> Result<Response<pb::EstatesOverviewResponse>, Status> {
Ok(Response::new(self.0.lock().unwrap().overview.clone()))
}
async fn new_estate(&self, req: TReq<pb::NewEstateRequest>) -> Result<Response<pb::NewEstateResponse>, Status> {
self.took(&req);
let r = req.into_inner();
let mut held = self.0.lock().unwrap();
held.made.push(r.clone());
if r.name != r.name.to_ascii_lowercase() || r.name.contains(' ') {
return Err(Status::invalid_argument(REFUSED));
}
let domain = format!("{}.gunnar.rs", r.name.trim_end_matches("-gunnar-rs"));
// As monetize: the new estate is a row being laid, before the fleet knows it.
held.overview.estates.push(line(&r.name, &r.product, "laying", "ordered: waiting for its payment", false, &domain));
Ok(Response::new(pb::NewEstateResponse {
estate: r.name.clone(),
domain: domain.clone(),
created: true,
reference: format!("estate/{}", r.name.replace('-', "")),
checkout_url: CHECKOUT.into(),
price_words: "0.50 EUR".into(),
words: format!("{} is made as {domain} (its admin, account, terms and media as test2-gunnar-rs's) and ordered: pay 0.50 EUR at the payment page, and this box lays it", r.name),
}))
}
}
/// The fleet the rows' summaries read: the two that serve, heard just now.
#[tonic::async_trait]
impl Guardian for Fake {
async fn cases(&self, r: TReq<pb::GuardianCasesRequest>) -> Result<Response<pb::GuardianCasesResponse>, Status> {
let want = r.into_inner().instance;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let e = |instance: &str, product: &str| pb::GuardianEstate { instance: instance.into(), product: product.into(), boxes: vec![pb::GuardianBox { role: "main".into(), heard_unix_ms: now, ..Default::default() }], ..Default::default() };
let all = vec![e("gunnar-rs", "gunnar"), e("holger-rs", "holger")];
Ok(Response::new(pb::GuardianCasesResponse { estates: all.into_iter().filter(|e| want.is_empty() || e.instance == want).collect() }))
}
async fn press(&self, _: TReq<pb::GuardianPressRequest>) -> Result<Response<pb::GuardianCase>, Status> {
Err(Status::unimplemented("not in this fake"))
}
async fn set_auto_fix(&self, _: TReq<pb::GuardianAutoFixRequest>) -> Result<Response<pb::GuardianAutoFixResponse>, Status> {
Err(Status::unimplemented("not in this fake"))
}
async fn set_hold(&self, _: TReq<pb::GuardianHoldRequest>) -> Result<Response<pb::GuardianHoldResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
}
/// The fake monetize and the real console over it, on loopback: the console's origin.
fn up(fake: Fake, dist: PathBuf) -> String {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().expect("a runtime");
rt.block_on(async move {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap().to_string();
tokio::spawn(tonic::transport::Server::builder().add_service(EstatesServer::new(fake.clone())).add_service(GuardianServer::new(fake)).serve_with_incoming(tonic::transport::server::TcpIncoming::from(l)));
let monetize = Monetize::connect_lazy(&at, SERVICE).unwrap();
let operator = Some(Monetize::connect_lazy(&at, OPERATOR).unwrap());
// The open posture (no passkey, no token, loopback): every request an admin's.
let app = Arc::new(App { webauthn: None, admin_token: None, events: EventHub::spawn(monetize.clone(), Duration::from_secs(3600)), monetize, operator, ui_dir: Some(dist) });
let web = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
tx.send(format!("http://127.0.0.1:{}", web.local_addr().unwrap().port())).unwrap();
axum::serve(web, serve::router(app)).await.unwrap();
});
});
rx.recv_timeout(Duration::from_secs(20)).expect("the console came up")
}
/// A person's click: the mouse pressed and released at the middle of the element's own
/// box, scrolled into view first. A button something covers is not pressed.
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");
}
}
/// Typed as a person types it: the field focused by a click, then the keys.
fn type_into(c: &mut Cdp, sess: &str, selector: &str, text: &str) {
click(c, sess, selector);
c.eval(sess, &format!("(() => {{ const e = document.querySelector({}); e.focus(); e.value = ''; e.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()", esc(selector))).expect("the caret");
c.call(Some(sess), "Input.insertText", &format!(r#"{{"text":{}}}"#, esc(text))).expect("the keys");
}
fn text(c: &mut Cdp, sess: &str, selector: &str) -> String {
c.eval_str(sess, &format!("(document.querySelector({}) || {{innerText: ''}}).innerText.replace(/\\s+/g, ' ').trim()", esc(selector))).unwrap()
}
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 count(c: &mut Cdp, sess: &str, selector: &str) -> f64 {
c.eval(sess, &format!("document.querySelectorAll({}).length", esc(selector))).unwrap().as_f64().unwrap_or(-1.0)
}
/// Wait for the page to say `want` in `selector`, and say what it said when it never did.
fn reads(c: &mut Cdp, sess: &str, selector: &str, want: &str) {
let expr = format!("(document.querySelector({}) || {{innerText: ''}}).innerText.includes({})", esc(selector), esc(want));
if c.wait_for(sess, &expr, 15_000).is_err() {
panic!("{selector} never read {want:?}; it reads {:?}", text(c, sess, selector));
}
}
/// **Screenshots**: desktop and a 390 px phone, into `MONETIZE_UI_SHOTS` when it names a directory.
fn shots(c: &mut Cdp, sess: &str, name: &str) {
use base64::Engine as _;
let Some(dir) = std::env::var_os("MONETIZE_UI_SHOTS").map(PathBuf::from) else { return };
std::fs::create_dir_all(&dir).unwrap();
for (tag, width, mobile) in [("desktop", 1440, false), ("390px", 390, true)] {
c.call(Some(sess), "Emulation.setDeviceMetricsOverride", &format!(r#"{{"width":{width},"height":900,"deviceScaleFactor":1,"mobile":{mobile}}}"#)).expect("the viewport");
std::thread::sleep(Duration::from_millis(400));
let r = c.call(Some(sess), "Page.captureScreenshot", r#"{"format":"png"}"#).expect("a screenshot");
let data = r.get("data").and_then(|d| d.as_str()).expect("png data").to_string();
std::fs::write(dir.join(format!("{name}-{tag}.png")), base64::engine::general_purpose::STANDARD.decode(data).expect("base64")).unwrap();
let wide = c.eval(sess, "document.documentElement.scrollWidth > window.innerWidth").ok().and_then(|v| v.as_bool()).unwrap_or(false);
assert!(!wide, "{name} at {width} px scrolls sideways");
}
c.call(Some(sess), "Emulation.clearDeviceMetricsOverride", "{}").expect("the viewport back");
}
#[test]
fn the_overview_lists_what_exists_hides_the_leftovers_and_makes_a_new_estate_with_real_clicks() {
let (Some(bin), Some(dist)) = (std::env::var("MONETIZE_UI_CHROME").ok().filter(|v| !v.is_empty()), std::env::var("MONETIZE_UI_DIST").ok().filter(|v| !v.is_empty())) else {
eprintln!("NOT RUN: set MONETIZE_UI_CHROME (a Chrome binary) and MONETIZE_UI_DIST (the console's built dist)");
return;
};
assert!(std::path::Path::new(&dist).join("index.html").is_file(), "{dist} holds no built console");
let fake = Fake(Arc::new(Mutex::new(Held { overview: prod(), ..Default::default() })));
let origin = up(fake.clone(), PathBuf::from(dist));
let debug = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let profile = std::env::temp_dir().join(format!("overview-browser-{}", std::process::id()));
let _chrome = Chrome::launch(&bin, &profile, debug).expect("chrome starts");
let mut c = Cdp::attach(debug).expect("cdp");
let sess = c.page().expect("a page");
let c = &mut c;
c.navigate(&sess, &format!("{origin}/")).expect("the console loads");
if c.wait_for(&sess, "!!document.querySelector('tr.estate-slot')", 30_000).is_err() {
panic!("the Overview never drew its slot: {}", text(c, &sess, "body"));
}
c.eval(&sess, "(() => { window.__never_reloaded = true; return true; })()").unwrap();
// ── 1. The rows: only what exists ───────────────────────────────────────
assert_eq!(count(c, &sess, "tr.estate-row"), 2.0, "{}", text(c, &sess, "table.overview"));
for name in ["gunnar-rs", "holger-rs"] {
assert!(truthy(c, &sess, &format!("document.querySelector('tr.estate-row[data-estate=\"{name}\"]')")), "{name} is a row");
}
reads(c, &sess, "tr.estate-row[data-estate=\"gunnar-rs\"]", "serving");
let table = text(c, &sess, "table.overview");
assert!(!table.contains("not installed"), "no row is drawn as not installed: {table}");
for leftover in ["acme2", "ignalina", "polka", "rickardtest", "test3-holger-rs", "test2-gunnar-rs"] {
assert!(!truthy(c, &sess, &format!("document.querySelector('tr.estate-row[data-estate=\"{leftover}\"]')")), "{leftover} is a row");
}
// ── 2. The quiet line ───────────────────────────────────────────────────
assert_eq!(text(c, &sess, "details.ended summary"), "5 ended or never-installed");
assert!(!truthy(c, &sess, "document.querySelector('details.ended').open"), "the line is closed until it is opened");
click(c, &sess, "details.ended summary");
c.wait_for(&sess, "document.querySelector('details.ended').open", 5_000).expect("the line opens");
let ended = text(c, &sess, "details.ended ul");
for leftover in ["acme2", "ignalina", "polka", "rickardtest", "test3-holger-rs"] {
assert!(ended.contains(leftover), "{leftover} is named under the line: {ended}");
}
assert!(ended.contains("terminated on 2026-10-27"), "{ended}");
shots(c, &sess, "overview");
// ── 3. The slot ─────────────────────────────────────────────────────────
let slot = "tr.estate-slot";
assert!(text(c, &sess, slot).contains("New estate"), "{}", text(c, &sess, slot));
assert_eq!(c.eval_str(&sess, "[...document.querySelectorAll('#slot-declared option')].map(o => o.value).join(',')").unwrap(), "test2-gunnar-rs,test2-holger-rs", "a declared site with no row is offered by its name");
assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{slot} .slot-product').value")).unwrap(), "gunnar", "the first product the box can make");
assert!(truthy(c, &sess, &format!("document.querySelector('{slot} .slot-make').disabled")), "Order is armed with nothing typed");
// A refusal is the server's own sentence.
type_into(c, &sess, &format!("{slot} .slot-name"), "Bad Name");
type_into(c, &sess, &format!("{slot} .slot-email"), "ops@example.invalid");
c.wait_for(&sess, &format!("!document.querySelector('{slot} .slot-make').disabled"), 5_000).expect("a name and an address arm Order");
click(c, &sess, &format!("{slot} .slot-make"));
reads(c, &sess, &format!("{slot} .slot-failed"), REFUSED);
for raw in ["invalidargument", "InvalidArgument", "HTTP 4", "status:", "grpc"] {
assert!(!text(c, &sess, "main").contains(raw), "the page shows {raw:?}");
}
assert_eq!(count(c, &sess, "tr.estate-row"), 2.0, "a refusal adds no row");
// The order: monetize's sentence and the vendor's own page.
type_into(c, &sess, &format!("{slot} .slot-name"), "test4-gunnar-rs");
click(c, &sess, &format!("{slot} .slot-make"));
reads(c, &sess, &format!("{slot} .slot-made"), "test4-gunnar-rs is made as test4.gunnar.rs");
assert_eq!(text(c, &sess, &format!("{slot} .slot-pay")), "Pay 0.50 EUR", "the price, on the one payment page");
assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{slot} .slot-pay').href")).unwrap(), CHECKOUT);
// The new estate is a row being laid, with NO reload.
c.wait_for(&sess, "!!document.querySelector('tr.estate-row[data-estate=\"test4-gunnar-rs\"]')", 10_000).expect("the new estate is a row");
reads(c, &sess, "tr.estate-row[data-estate=\"test4-gunnar-rs\"]", "ordered: waiting for its payment");
assert!(truthy(c, &sess, "window.__never_reloaded === true"), "the page reloaded to show the new row");
{
let held = fake.0.lock().unwrap();
let sent: Vec<(String, String, String)> = held.made.iter().map(|m| (m.name.clone(), m.product.clone(), m.email.clone())).collect();
assert_eq!(sent, [("Bad Name".to_string(), "gunnar".to_string(), "ops@example.invalid".to_string()), ("test4-gunnar-rs".to_string(), "gunnar".to_string(), "ops@example.invalid".to_string())], "what was typed, carried");
assert!(held.made.iter().all(|m| m.return_url.ends_with('/') || m.return_url.is_empty()), "the payment page returns to the Overview");
assert!(held.bearers.iter().all(|b| b == OPERATOR), "the slot goes with the operator bearer: {:?}", held.bearers);
}
shots(c, &sess, "overview-new-estate");
}