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

nordisk/monetize

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