crates/monetize-ui-server/tests/sites_browser.rs
//! ★ **The Sites page, clicked** (owner 2026-10-13: the operator's own sites, ordered at
//! this box). Until this file the page had a compile test and no press.
//!
//! The BUILT console (`trunk build`'s `dist`) is served by the REAL router of this crate,
//! over a fake monetize that answers `EstateOrder.ListSites / OrderSite / Terminate` on
//! the wire with the operator bearer — so the page, its cells, this server's routes and
//! the gRPC mapping are all the real ones. Headless Chrome drives it over the DevTools
//! Protocol (`uitest`'s own zero-dependency client) with REAL mouse clicks:
//!
//! 1. the list: each site's domain, Stripe, DNS, cloud and first admin read "already
//! fed"; a site that runs is not orderable and says why, with no form; an orderable
//! one draws the form;
//! 2. size Mini, the first admin's address, Order → the price (0.50 EUR), the checkout
//! link, and then the order's state as it moves (waiting for payment → ordered →
//! laying → installing → ready) with NO reload;
//! 3. Terminate on a terminable site is armed only by its name typed back, ends it in one
//! press with no backup offered, and a protected site offers none;
//! 4. a refusal is the server's own sentence — never a gRPC code or an HTTP status.
//!
//! 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::estate_order_server::{EstateOrder, EstateOrderServer};
use monetize_proto::site_book_server::{SiteBook, SiteBookServer};
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_site_1";
const REFUSED: &str = "broken.example cannot be ordered: this box cannot write its DNS names. Nothing was ordered.";
/// What the fake monetize holds: the sites as it would answer them, and every call taken.
#[derive(Default)]
struct Held {
sites: Vec<pb::EstateSite>,
orders: Vec<pb::EstateOrderSiteRequest>,
terminates: Vec<pb::EstateOrderTerminateRequest>,
settles: Vec<pb::EstateOrderSettleRequest>,
updates: Vec<pb::EstateUpdateRequest>,
bearers: Vec<String>,
/// The site book's puts, as the console sent them (2026-10-14).
puts: Vec<pb::SitePutRequest>,
/// CODE RED as monetize would hold it (2026-10-18).
code_red: Vec<pb::CodeRed>,
}
#[derive(Clone)]
struct Fake(Arc<Mutex<Held>>);
fn site(estate: &str, domain: &str, state: &str, words: &str) -> pb::EstateSite {
pb::EstateSite {
estate: estate.into(),
domain: domain.into(),
vendor: format!("stripe/{estate}"),
first_admin: "ops".into(),
dns_words: "this box writes its names".into(),
dns_writes: true,
cloud_words: "upcloud · se-sto1".into(),
state: state.into(),
words: words.into(),
// A33-6: the server says what it costs.
price_words: "0.50 EUR".into(),
..Default::default()
}
}
fn sites() -> Vec<pb::EstateSite> {
vec![
// Runs already (the operator's kit laid it): not orderable, protected.
pb::EstateSite { orderable: false, why_not: "it already runs.".into(), terminable: false, ..site("gunnar-rs", "gunnar.example", "stands", "Its machines report in.") },
// Declared, not laid: the form.
pb::EstateSite { orderable: true, ..site("new-rs", "new.example", "none", "Not ordered yet.") },
// Declared, but monetize will refuse its order (the refusal row).
pb::EstateSite { orderable: true, ..site("broken-rs", "broken.example", "none", "Not ordered yet.") },
// Laid by this box and declared terminable.
pb::EstateSite { orderable: false, why_not: "this box already set it up.".into(), terminable: true, ..site("old-rs", "old.example", "ready", "It is up.") },
// Laid by this box and up to date (test2 live, 2026-10-17).
pb::EstateSite { orderable: false, why_not: "it is already ordered.".into(), ..site("test2-rs", "test2.example", "ready", "The site is ready at https://test2.example. Its first sign-in link was mailed.") },
// Its setup failed after payment: owed, settled here (retry or refund).
pb::EstateSite { orderable: false, why_not: "it is already ordered.".into(), ..site("owed-rs", "owed.example", "owed", "The setup failed after payment; the operator can retry or refund.") },
]
}
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);
}
/// The order moves on, as monetize's runner would move it.
fn moves(&self, estate: &str, state: &str, words: &str) {
let mut held = self.0.lock().unwrap();
let s = held.sites.iter_mut().find(|s| s.estate == estate).expect("the site");
(s.state, s.words) = (state.into(), words.into());
if state != "waiting_for_payment" {
s.checkout_url.clear();
}
}
}
/// What monetize answers about a site's update: release 02 sold, the boxes on 01.
fn update_of(estate: &str, state: &str) -> pb::EstateUpdateResponse {
let line = |role: &str, st: &str, words: &str| pb::EstateUpdateBox { role: role.into(), state: st.into(), words: words.into() };
if estate == "test2-rs" {
// test2 live (owner 2026-10-17): every box runs the release sold now — nothing to offer.
return pb::EstateUpdateResponse {
host: "test2.example".into(),
release: "27478f3a499ff8c5".into(),
live_on: true,
up_to_date: true,
release_name: "r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44".into(),
boxes: vec![line("twin", "current", "current · release 27478f3a499f"), line("main", "current", "current · release 27478f3a499f"), line("front", "not-reported", "not reported yet — the front has no update taker yet; the update starts with the front")],
words: "no update yet".into(),
..Default::default()
};
}
pb::EstateUpdateResponse {
host: format!("{estate}.example"),
release: "02abcdef0123".into(),
release_of: [("twin".to_string(), "01".to_string()), ("main".to_string(), "01".to_string()), ("front".to_string(), "01".to_string())].into(),
live_on: true,
state: state.into(),
words: if state.is_empty() { "no update yet".into() } else { format!("update to 02abcdef0123: {state}") },
offered: state.is_empty(),
release_name: "r9 · 2026-10-18 · gunnar bb22222".into(),
boxes: ["twin", "main", "front"].iter().map(|r| line(r, "behind", "runs release 01 · release 02abcdef0123 is published")).collect(),
..Default::default()
}
}
fn not_here<T>() -> Result<Response<T>, Status> {
Err(Status::unimplemented("not in this fake"))
}
#[tonic::async_trait]
impl EstateOrder for Fake {
async fn product_order(&self, _: tonic::Request<pb::EstateProductOrderRequest>) -> Result<tonic::Response<pb::EstateProductOrderResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order")) }
async fn product_order_answer(&self, _: tonic::Request<pb::EstateProductOrderAnswerRequest>) -> Result<tonic::Response<pb::EstateProductOrderAnswerResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_answer")) }
async fn product_order_kinds(&self, _: tonic::Request<pb::EstateProductOrderKindsRequest>) -> Result<tonic::Response<pb::EstateProductOrderKindsResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_kinds")) }
async fn update_estate(&self, req: TReq<pb::EstateUpdateRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> {
self.took(&req);
let r = req.into_inner();
self.0.lock().unwrap().updates.push(r.clone());
Ok(Response::new(update_of(&r.estate, "running")))
}
async fn update_status(&self, req: TReq<pb::EstateUpdateStatusRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> {
self.took(&req);
let r = req.into_inner();
let running = self.0.lock().unwrap().updates.iter().any(|u| u.estate == r.estate);
Ok(Response::new(update_of(&r.estate, if running { "running" } else { "" })))
}
async fn cancel_update(&self, _: TReq<pb::EstateUpdateStatusRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> { Err(Status::unimplemented("cancel_update")) }
async fn set_live_update(&self, req: TReq<pb::EstateSetLiveUpdateRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> {
self.took(&req);
Ok(Response::new(update_of(&req.into_inner().estate, "")))
}
async fn adopt_estate(&self, _: TReq<pb::EstateAdoptRequest>) -> Result<Response<pb::EstateAdoptResponse>, Status> {
Err(Status::unimplemented("not in this fake"))
}
async fn list_code_red(&self, req: TReq<pb::CodeRedListRequest>) -> Result<Response<pb::CodeRedList>, Status> {
self.took(&req);
Ok(Response::new(pb::CodeRedList { items: self.0.lock().unwrap().code_red.clone() }))
}
async fn raise_code_red(&self, _: TReq<pb::CodeRed>) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn clear_code_red(&self, _: TReq<pb::CodeRedClearRequest>) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn narrate_log(&self, _: TReq<pb::EstateNarrateLogRequest>) -> Result<Response<pb::EstateNarrateLogResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn install_log(&self, _: TReq<pb::EstateInstallLogRequest>) -> Result<Response<pb::EstateInstallLogResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn ack_code_red(&self, req: TReq<pb::CodeRedAckRequest>) -> Result<Response<pb::CodeRedList>, Status> {
self.took(&req);
let r = req.into_inner();
let mut h = self.0.lock().unwrap();
let c = h.code_red.iter_mut().find(|c| c.key == r.key).ok_or_else(|| Status::not_found("no such code red"))?;
c.acked_by = r.by;
c.acked_at_unix_ms = 1;
Ok(Response::new(pb::CodeRedList { items: h.code_red.clone() }))
}
async fn list_sites(&self, req: TReq<pb::EstateSitesRequest>) -> Result<Response<pb::EstateSitesResponse>, Status> {
self.took(&req);
Ok(Response::new(pb::EstateSitesResponse { sites: self.0.lock().unwrap().sites.clone(), sizes: vec!["Mini".into(), "Small".into(), "Medium".into()] }))
}
async fn order_site(&self, req: TReq<pb::EstateOrderSiteRequest>) -> Result<Response<pb::EstateOrderSiteResponse>, Status> {
self.took(&req);
let r = req.into_inner();
let mut held = self.0.lock().unwrap();
held.orders.push(r.clone());
if r.estate == "broken-rs" {
return Err(Status::failed_precondition(REFUSED));
}
let s = held.sites.iter_mut().find(|s| s.estate == r.estate).ok_or_else(|| Status::not_found("no such site"))?;
(s.state, s.words, s.checkout_url, s.orderable, s.why_not) = ("waiting_for_payment".into(), "Waiting for your payment.".into(), CHECKOUT.into(), false, "it is already ordered.".into());
Ok(Response::new(pb::EstateOrderSiteResponse {
reference: "site/new-rs/1".into(),
checkout_url: CHECKOUT.into(),
price_words: "0.50 EUR".into(),
words: "new.example is ordered. Pay to start setting it up.".into(),
company: r.estate,
}))
}
async fn first_link(&self, _: TReq<pb::EstateOrderFirstLinkRequest>) -> Result<Response<pb::EstateOrderFirstLinkResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn refeed(&self, _: TReq<pb::EstateOrderRefeedRequest>) -> Result<Response<pb::EstateOrderRefeedResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn console(&self, _: TReq<pb::EstateOrderConsoleRequest>) -> Result<Response<pb::EstateOrderConsoleResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn terminate(&self, req: TReq<pb::EstateOrderTerminateRequest>) -> Result<Response<pb::EstateOrderTerminateResponse>, Status> {
self.took(&req);
let r = req.into_inner();
let mut held = self.0.lock().unwrap();
held.terminates.push(r.clone());
held.sites.retain(|s| s.estate != r.company);
Ok(Response::new(pb::EstateOrderTerminateResponse { words: "old.example is terminated.".into(), done: true }))
}
async fn rebuild_main(&self, _: TReq<pb::EstateRebuildMainRequest>) -> Result<Response<pb::EstateRebuildMainResponse>, Status> {
not_here()
}
async fn start(&self, _: TReq<pb::EstateOrderStartRequest>) -> Result<Response<pb::EstateOrderStartResponse>, Status> {
not_here()
}
async fn status(&self, _: TReq<pb::EstateOrderStatusRequest>) -> Result<Response<pb::EstateOrderStatusResponse>, Status> {
not_here()
}
async fn renew(&self, _: TReq<pb::EstateOrderRenewRequest>) -> Result<Response<pb::EstateOrderRenewResponse>, Status> {
not_here()
}
async fn send_sign_in_again(&self, _: TReq<pb::EstateOrderSignInAgainRequest>) -> Result<Response<pb::EstateOrderSignInAgainResponse>, Status> {
not_here()
}
async fn change_plan(&self, _: TReq<pb::EstateOrderChangePlanRequest>) -> Result<Response<pb::EstateOrderChangePlanResponse>, Status> {
not_here()
}
async fn ladder(&self, _: TReq<pb::EstateOrderLadderRequest>) -> Result<Response<pb::EstateOrderLadderResponse>, Status> {
not_here()
}
async fn purchases(&self, _: TReq<pb::EstateOrderPurchasesRequest>) -> Result<Response<pb::EstateOrderPurchasesResponse>, Status> {
not_here()
}
async fn notice(&self, _: TReq<pb::EstateNoticeRequest>) -> Result<Response<pb::EstateNoticeResponse>, Status> {
not_here()
}
async fn sells(&self, _: TReq<pb::EstateOrderSellsRequest>) -> Result<Response<pb::EstateOrderSellsResponse>, Status> {
not_here()
}
async fn cancel(&self, _: TReq<pb::EstateOrderCancelRequest>) -> Result<Response<pb::EstateOrderCancelResponse>, Status> {
not_here()
}
async fn restore_front(&self, _: TReq<pb::EstateOrderRestoreFrontRequest>) -> Result<Response<pb::EstateOrderRestoreFrontResponse>, Status> {
not_here()
}
async fn settle(&self, req: TReq<pb::EstateOrderSettleRequest>) -> Result<Response<pb::EstateOrderSettleResponse>, Status> {
self.took(&req);
let r = req.into_inner();
let mut held = self.0.lock().unwrap();
held.settles.push(r.clone());
let s = held.sites.iter_mut().find(|s| s.estate == r.company).ok_or_else(|| Status::not_found("no order for that site"))?;
(s.state, s.words) = ("queued".into(), "Paid. It is next.".into());
Ok(Response::new(pb::EstateOrderSettleResponse { phase: "held".into(), words: "the operator sets it up again".into() }))
}
}
const NEW_KEY: &str = "sk_test_51BROWSERSECRETKEYVALUE";
const NEW_HOOK: &str = "whsec_BROWSERHOOKVALUE";
/// The fake monetize's site book: a put of a NEW site adds it to the sites it lists (orderable,
/// as monetize would once its watcher took it); an edit moves its domain. Never a secret back.
#[tonic::async_trait]
impl SiteBook for Fake {
async fn put_site(&self, req: TReq<pb::SitePutRequest>) -> Result<Response<pb::SitePutResponse>, Status> {
self.took(&req);
let r = req.into_inner();
let mut held = self.0.lock().unwrap();
held.puts.push(r.clone());
if r.domain.ends_with(".invalid") {
return Err(Status::failed_precondition("REFUSED [site-judged] the box's loader refused the feed with this change, and nothing was kept"));
}
if r.create {
let vendor = if r.vendor.is_empty() { format!("stripe/{}", r.estate) } else { r.vendor.clone() };
held.sites.push(pb::EstateSite { orderable: true, vendor, first_admin: r.first_admin.clone(), ..site(&r.estate, &r.domain, "none", "Not ordered yet.") });
return Ok(Response::new(pb::SitePutResponse { words: format!("{} is added: the box takes it on its next look (two seconds), and it is ordered on the Sites page", r.estate), changed: vec![format!("domain: {}", r.domain), "Stripe secret key: set (sha256:0123abcd)".into()] }));
}
let s = held.sites.iter_mut().find(|s| s.estate == r.estate).ok_or_else(|| Status::not_found("no such site"))?;
let was = std::mem::replace(&mut s.domain, r.domain.clone());
Ok(Response::new(pb::SitePutResponse { words: format!("{} is edited: the box takes it on its next look (two seconds), and it is ordered on the Sites page", r.estate), changed: vec![format!("domain: {was} → {}", r.domain)] }))
}
async fn show_site(&self, req: TReq<pb::SiteShowRequest>) -> Result<Response<pb::SiteShowResponse>, Status> {
self.took(&req);
let estate = req.into_inner().estate;
let held = self.0.lock().unwrap();
let site = if estate.is_empty() {
pb::SiteRecord { product: "gunnar".into(), operator_site: true, ..Default::default() }
} else {
let s = held.sites.iter().find(|s| s.estate == estate).ok_or_else(|| Status::not_found("no such site"))?;
pb::SiteRecord { estate: s.estate.clone(), domain: s.domain.clone(), product: "gunnar".into(), first_admin: s.first_admin.clone(), operator_site: true, vendor: s.vendor.clone(), source: "fed".into(), secret_key_set: true, secret_key_fingerprint: "sha256:0123abcd".into(), webhook_secret_set: true, webhook_secret_fingerprint: "sha256:89abcdef".into(), terms: Some(pb::SiteTerms { term_days: 1, ..Default::default() }), ..Default::default() }
};
let vendors = held.sites.iter().map(|s| s.vendor.clone()).collect();
Ok(Response::new(pb::SiteShowResponse { site: Some(site), products: vec!["gunnar".into()], vendors, media_sites: vec!["gunnar-rs".into()], currency: "EUR".into(), sizes: vec!["Mini".into(), "Small".into()], history: vec!["2026-10-14 12:00:00 UTC — console:op-1 — edited gunnar-rs: term days: 2 → 1".into()] }))
}
}
/// 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(EstateOrderServer::new(fake.clone())).add_service(SiteBookServer::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.setSelectionRange && e.type !== 'email' && e.setSelectionRange(e.value.length, e.value.length); 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)
}
/// **Screenshots** (owner 2026-10-17: look at them): 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));
// The whole page, not the first screen: the viewport as tall as the document.
let tall = c.eval(sess, "Math.max(...[...document.querySelectorAll('*')].map(e => e.scrollHeight))").ok().and_then(|h| h.as_f64()).unwrap_or(900.0) as u64;
c.call(Some(sess), "Emulation.setDeviceMetricsOverride", &format!(r#"{{"width":{width},"height":{tall},"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();
let png = base64::engine::general_purpose::STANDARD.decode(data).expect("base64");
std::fs::write(dir.join(format!("{name}-{tag}.png")), png).unwrap();
}
c.call(Some(sess), "Emulation.clearDeviceMetricsOverride", "{}").expect("the viewport back");
}
fn card(estate: &str) -> String {
format!("section.site[data-site=\"{estate}\"]")
}
/// 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));
}
}
#[test]
fn the_sites_page_lists_orders_follows_and_terminates_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 { sites: sites(), ..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!("sites-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}/sites")).expect("the console loads");
// The page marks its own document, so a reload would be seen.
if c.wait_for(&sess, "!!document.querySelector('section.site')", 30_000).is_err() {
panic!("/sites never drew: {}", text(c, &sess, "body"));
}
c.eval(&sess, "(() => { window.__never_reloaded = true; return true; })()").unwrap();
// ── 1. The list ─────────────────────────────────────────────────────────
let stands = text(c, &sess, &card("gunnar-rs"));
// One line for what is configured (the details are on Edit); a running site says where it
// runs; a kit-installed one says it is not managed here until adopted (owner 2026-10-17).
assert!(stands.contains("Configured") && !stands.contains("already fed"), "{stands}");
assert!(stands.contains("Running at https://gunnar.example") && stands.contains("Not managed by this box yet — adopt it to update it here"), "{stands}");
assert!(!stands.contains("Cannot be ordered"), "a running site is not offered for ordering: {stands}");
assert!(!truthy(c, &sess, &format!("document.querySelector('{} .site-order-send')", card("gunnar-rs"))), "a site that runs draws an order form");
assert!(!truthy(c, &sess, &format!("document.querySelector('{} .site-terminate')", card("gunnar-rs"))), "a protected site offers Terminate");
let new = card("new-rs");
assert!(text(c, &sess, &new).contains("not ordered"), "{}", text(c, &sess, &new));
assert_eq!(text(c, &sess, &format!("{new} .site-order-send")), "Order new.example");
assert!(truthy(c, &sess, &format!("document.querySelector('{new} .site-order-send').disabled")), "Order is armed with no address typed");
let page = text(c, &sess, "main");
for internal in ["Market", ".toml", "waiting_for_payment", "monetize"] {
assert!(!page.contains(internal), "the page says {internal:?}: {page}");
}
// ── 2. The order ────────────────────────────────────────────────────────
assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{new} .site-size').value")).unwrap(), "Mini", "the smallest size is chosen first");
c.eval(&sess, &format!("(() => {{ const s = document.querySelector('{new} .site-size'); s.value = 'Small'; s.dispatchEvent(new Event('change', {{bubbles: true}})); s.value = 'Mini'; s.dispatchEvent(new Event('change', {{bubbles: true}})); return true; }})()")).unwrap();
type_into(c, &sess, &format!("{new} .site-email"), "ops@example.invalid");
c.wait_for(&sess, &format!("!document.querySelector('{new} .site-order-send').disabled"), 5_000).expect("the address arms Order");
click(c, &sess, &format!("{new} .site-order-send"));
reads(c, &sess, "#site-ordered", "new.example is ordered. Pay to start setting it up.");
assert_eq!(text(c, &sess, "#site-pay"), "Pay 0.50 EUR", "the price");
assert_eq!(c.eval_str(&sess, "document.querySelector('#site-pay').href").unwrap(), CHECKOUT, "the checkout link");
{
let held = fake.0.lock().unwrap();
let o = held.orders.last().expect("an order was sent");
assert_eq!((o.estate.as_str(), o.size.as_str(), o.email.as_str(), o.seed_key.as_str()), ("new-rs", "Mini", "ops@example.invalid", ""), "the order as typed");
assert!(held.bearers.iter().all(|b| b == OPERATOR), "the Sites verbs go with the operator bearer: {:?}", held.bearers);
}
// Its state, as monetize moves it — read off the page with no reload.
reads(c, &sess, &format!("{new} .site-state"), "waiting for payment");
assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{new} .site-pay').href")).unwrap(), CHECKOUT);
assert!(!truthy(c, &sess, &format!("document.querySelector('{new} .site-order-send')")), "an ordered site still draws its form");
for (state, words, shown) in [("queued", "Paid. It is next.", "ordered"), ("laying", "Its machines are being made.", "laying"), ("installing", "Gunnar is being installed.", "installing"), ("ready", "Running at https://new.example", "ready")] {
fake.moves("new-rs", state, words);
reads(c, &sess, &format!("{new} .site-state"), shown);
reads(c, &sess, &format!("{new} .site-state"), words);
}
assert!(truthy(c, &sess, "window.__never_reloaded === true"), "the page reloaded to show the state");
assert!(!truthy(c, &sess, &format!("document.querySelector('{new} .site-pay')")), "a paid order still offers the payment page");
// ── 4. A refusal, in the server's own sentence ──────────────────────────
let broken = card("broken-rs");
type_into(c, &sess, &format!("{broken} .site-email"), "ops@example.invalid");
c.wait_for(&sess, &format!("!document.querySelector('{broken} .site-order-send').disabled"), 5_000).expect("the address arms Order");
click(c, &sess, &format!("{broken} .site-order-send"));
reads(c, &sess, "#site-failed", REFUSED);
let said = text(c, &sess, "#site-failed");
assert_eq!(said, REFUSED, "the refusal is the sentence and nothing else");
for raw in ["failedprecondition", "FailedPrecondition", "HTTP 4", "status:", "grpc"] {
assert!(!text(c, &sess, "main").contains(raw), "the page shows {raw:?}");
}
assert_eq!(c.eval_str(&sess, &format!("document.querySelector('{broken} .site-email').value")).unwrap(), "ops@example.invalid", "a refusal emptied the form");
// ── 3. Terminate ────────────────────────────────────────────────────────
let old = card("old-rs");
assert!(!text(c, &sess, &old).contains("Cannot be ordered"), "{}", text(c, &sess, &old));
assert!(truthy(c, &sess, &format!("document.querySelector('{old} .site-terminate-send').disabled")), "Terminate is armed with nothing typed");
click(c, &sess, &format!("{old} .site-terminate-send"));
type_into(c, &sess, &format!("{old} .site-terminate-confirm"), "old.exampl");
assert!(truthy(c, &sess, &format!("document.querySelector('{old} .site-terminate-send').disabled")), "a name not typed whole arms Terminate");
click(c, &sess, &format!("{old} .site-terminate-send"));
std::thread::sleep(Duration::from_millis(300));
assert!(fake.0.lock().unwrap().terminates.is_empty(), "a disabled press was sent");
type_into(c, &sess, &format!("{old} .site-terminate-confirm"), "e");
c.wait_for(&sess, &format!("!document.querySelector('{old} .site-terminate-send').disabled"), 5_000).expect("the name arms Terminate");
click(c, &sess, &format!("{old} .site-terminate-send"));
// No backup is offered: one press ends it.
reads(c, &sess, "#site-ended", "old.example is terminated.");
c.wait_for(&sess, &format!("!document.querySelector('{old}')"), 10_000).expect("the terminated site leaves the list");
let sent: Vec<(String, String)> = fake.0.lock().unwrap().terminates.iter().map(|t| (t.company.clone(), t.confirm.clone())).collect();
assert_eq!(sent, [("old-rs".to_owned(), "old.example".to_owned())], "the name typed back (the wire carries no backup word, A32-27)");
assert!(truthy(c, &sess, "window.__never_reloaded === true"), "the page reloaded");
// ── 5. An owed site order: Set up again / Refund, and the buttons go after the press ──
let owed = card("owed-rs");
reads(c, &sess, &owed, "setup failed after payment");
assert_eq!(text(c, &sess, &format!("{owed} .site-settle-send[data-action=retry]")), "Set up again", "the two settles");
assert_eq!(text(c, &sess, &format!("{owed} .site-settle-send[data-action=refund]")), "Refund", "the two settles");
assert!(!truthy(c, &sess, &format!("document.querySelector('{owed} .site-terminate')")), "nothing laid: no Terminate");
click(c, &sess, &format!("{owed} .site-settle-send[data-action=retry]"));
c.wait_for(&sess, &format!("!!document.querySelector('{owed} .site-settle-confirm[data-action=retry]')"), 5_000).expect("Set up again asks first");
assert!(fake.0.lock().unwrap().settles.is_empty(), "nothing is sent before the confirm");
click(c, &sess, &format!("{owed} .site-settle-confirm[data-action=retry]"));
reads(c, &sess, &format!("{owed} .site-state"), "ordered");
reads(c, &sess, "#site-ended", "the operator sets it up again");
c.wait_for(&sess, &format!("!document.querySelector('{owed} .site-settle')"), 5_000).expect("the settle buttons go once it is set up again");
{
let held = fake.0.lock().unwrap();
let sent: Vec<(String, String)> = held.settles.iter().map(|s| (s.company.clone(), s.action.clone())).collect();
assert_eq!(sent, [("owed-rs".to_owned(), "retry".to_owned())], "the same EstateOrder.Settle the Accounts page uses");
assert!(held.bearers.iter().all(|b| b == OPERATOR), "with the operator bearer: {:?}", held.bearers);
}
assert!(truthy(c, &sess, "window.__never_reloaded === true"), "the page reloaded");
}
fn value(c: &mut Cdp, sess: &str, selector: &str) -> String {
c.eval_str(sess, &format!("(document.querySelector({}) || {{value: ''}}).value", esc(selector))).unwrap()
}
fn choose(c: &mut Cdp, sess: &str, selector: &str, v: &str) {
c.eval(sess, &format!("(() => {{ const s = document.querySelector({}); s.value = {}; s.dispatchEvent(new Event('change', {{bubbles: true}})); return true; }})()", esc(selector), esc(v))).unwrap();
}
/// ★ **A site ADDED in the console, then ordered** (owner 2026-10-14: config in the
/// application). New site → the form (the box's choices; a new Stripe account of its own;
/// the secrets as password fields) → Add site → the box's answer, the new card in the list
/// with NO reload → its order form → ordered. Edit on a fed site: the form filled from the box,
/// each secret "set" with a fingerprint and its field EMPTY; a save sends no secret. A secret
/// typed is never shown back on the page; a refusal is the box's sentence.
#[test]
fn a_site_is_added_in_the_console_then_ordered_and_edited_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 { sites: sites(), ..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!("sites-book-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}/sites")).expect("the console loads");
if c.wait_for(&sess, "!!document.querySelector('section.site')", 30_000).is_err() {
panic!("/sites never drew: {}", text(c, &sess, "body"));
}
c.eval(&sess, "(() => { window.__never_reloaded = true; return true; })()").unwrap();
// ── New site: the form ──────────────────────────────────────────────────
click(c, &sess, "#site-new");
c.wait_for(&sess, "!!document.querySelector('#site-form .sb-estate')", 10_000).expect("the New site form");
let form = text(c, &sess, "#site-form");
assert!(form.contains("Sold in EUR") && form.contains("Mini, Small"), "the shop's currency and sizes, read-only: {form}");
assert_eq!(value(c, &sess, "#site-form .sb-vendor"), "", "a new site starts on a new Stripe account of its own");
assert_eq!(value(c, &sess, "#site-form .sb-product"), "gunnar");
for f in [".sb-key", ".sb-hook"] {
assert_eq!(c.eval_str(&sess, &format!("document.querySelector('#site-form {f}').type")).unwrap(), "password", "{f} is a password field");
}
assert!(truthy(c, &sess, "document.querySelector('#site-form .sb-send').disabled"), "Add site is armed on an empty form");
type_into(c, &sess, "#site-form .sb-estate", "test5-rs");
type_into(c, &sess, "#site-form .sb-domain", "test5.example");
type_into(c, &sess, "#site-form .sb-first-admin", "ops");
// A number field has no caret to place: focused by a click, then the keys.
click(c, &sess, "#site-form .sb-term-days");
c.call(Some(&sess), "Input.insertText", r#"{"text":"1"}"#).expect("the keys");
choose(c, &sess, "#site-form .sb-media", "gunnar-rs");
click(c, &sess, "#site-form .sb-terminable");
assert!(truthy(c, &sess, "document.querySelector('#site-form .sb-send').disabled"), "a new account is armed without its two secrets");
type_into(c, &sess, "#site-form .sb-key", NEW_KEY);
type_into(c, &sess, "#site-form .sb-hook", NEW_HOOK);
c.wait_for(&sess, "!document.querySelector('#site-form .sb-send').disabled", 5_000).expect("the form arms Add site");
assert_eq!(text(c, &sess, "#site-form .sb-send"), "Add site");
click(c, &sess, "#site-form .sb-send");
reads(c, &sess, "#site-put-done", "test5-rs is added");
reads(c, &sess, "#site-put-done", "Stripe secret key: set (sha256:0123abcd)");
assert!(!truthy(c, &sess, "document.querySelector('#site-form')"), "the form (and the secrets in it) left the page");
{
let held = fake.0.lock().unwrap();
let p = held.puts.last().expect("a put was sent");
assert_eq!((p.estate.as_str(), p.create, p.domain.as_str(), p.first_admin.as_str(), p.vendor.as_str(), p.media_from.as_str(), p.terminable, p.operator_site, p.live), ("test5-rs", true, "test5.example", "ops", "", "gunnar-rs", true, true, false), "the site as typed");
assert_eq!((p.vendor_secret_key.as_str(), p.vendor_webhook_secret.as_str(), p.terms.as_ref().map(|t| t.term_days)), (NEW_KEY, NEW_HOOK, Some(1)), "the secrets go to monetize, and only there");
assert!(p.actor.starts_with("console"), "who: {}", p.actor);
assert!(held.bearers.iter().all(|b| b == OPERATOR), "the site book goes with the operator bearer: {:?}", held.bearers);
}
// ── The new site is in the list with no reload, and is ordered ──────────
let t5 = card("test5-rs");
c.wait_for(&sess, &format!("!!document.querySelector('{t5} .site-order-send')"), 15_000).expect("the added site's order form");
assert_eq!(text(c, &sess, &format!("{t5} .site-order-send")), "Order test5.example");
type_into(c, &sess, &format!("{t5} .site-email"), "ops@example.invalid");
c.wait_for(&sess, &format!("!document.querySelector('{t5} .site-order-send').disabled"), 5_000).expect("the address arms Order");
click(c, &sess, &format!("{t5} .site-order-send"));
reads(c, &sess, "#site-ordered", "is ordered");
reads(c, &sess, &format!("{t5} .site-state"), "waiting for payment");
assert_eq!(fake.0.lock().unwrap().orders.last().map(|o| (o.estate.clone(), o.email.clone())), Some(("test5-rs".to_string(), "ops@example.invalid".to_string())), "the added site is the one ordered");
let page = text(c, &sess, "body");
assert!(!page.contains("BROWSERSECRETKEYVALUE") && !page.contains("BROWSERHOOKVALUE"), "a secret is shown back");
// ── Edit a fed site: filled from the box, the secrets "set" and EMPTY ───
click(c, &sess, &format!("{} .site-edit", card("gunnar-rs")));
c.wait_for(&sess, "!!document.querySelector('#site-form .sb-domain')", 10_000).expect("the Edit form");
assert_eq!(value(c, &sess, "#site-form .sb-domain"), "gunnar.example");
assert_eq!(value(c, &sess, "#site-form .sb-term-days"), "1");
assert!(!truthy(c, &sess, "document.querySelector('#site-form .sb-estate')"), "an edit does not rename the site");
assert_eq!(text(c, &sess, "#site-form .sb-key-status"), "set (sha256:0123abcd)");
assert_eq!(text(c, &sess, "#site-form .sb-hook-status"), "set (sha256:89abcdef)");
assert_eq!((value(c, &sess, "#site-form .sb-key"), value(c, &sess, "#site-form .sb-hook")), (String::new(), String::new()), "a held secret is never put back in a field");
// The history is folded (a closed <details>): its text is there, not drawn.
assert!(c.eval_str(&sess, "document.querySelector('#site-form .sb-history').textContent").unwrap().contains("console:op-1"), "the history");
// A refusal is the box's own sentence, and keeps the form.
c.eval(&sess, "(() => { const e = document.querySelector('#site-form .sb-domain'); e.select(); return true; })()").unwrap();
c.call(Some(&sess), "Input.insertText", r#"{"text":"gunnar.invalid"}"#).expect("the keys");
click(c, &sess, "#site-form .sb-send");
reads(c, &sess, "#site-failed", "REFUSED [site-judged]");
assert!(truthy(c, &sess, "document.querySelector('#site-form')"), "a refusal closed the form");
c.eval(&sess, "(() => { const e = document.querySelector('#site-form .sb-domain'); e.select(); return true; })()").unwrap();
c.call(Some(&sess), "Input.insertText", r#"{"text":"gunnar2.example"}"#).expect("the keys");
assert_eq!(text(c, &sess, "#site-form .sb-send"), "Save");
click(c, &sess, "#site-form .sb-send");
reads(c, &sess, "#site-put-done", "domain: gunnar.example → gunnar2.example");
{
let held = fake.0.lock().unwrap();
let p = held.puts.last().unwrap();
assert_eq!((p.estate.as_str(), p.create, p.domain.as_str(), p.vendor.as_str(), p.vendor_secret_key.as_str(), p.vendor_webhook_secret.as_str()), ("gunnar-rs", false, "gunnar2.example", "stripe/gunnar-rs", "", ""), "an edit sends no secret it was not typed");
}
reads(c, &sess, &card("gunnar-rs"), "gunnar2.example");
assert!(truthy(c, &sess, "window.__never_reloaded === true"), "the page reloaded");
}
/// ★ **"Update to release X", clicked** (owner 2026-10-14: the ONE box updates the sites it
/// laid): a site this box laid shows what each box runs and the button naming the release
/// monetize sells now; a press sends THAT release with the operator bearer, and the page then
/// reads the update running with the button disarmed — no reload.
#[test]
fn the_update_button_names_the_release_and_sends_what_the_page_showed() {
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;
};
let fake = Fake(Arc::new(Mutex::new(Held { sites: sites(), ..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!("sites-update-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}/sites")).expect("the console loads");
let old = card("old-rs");
if c.wait_for(&sess, &format!("!!document.querySelector('{old} .site-update-send')"), 30_000).is_err() {
panic!("the laid site draws no update: {}", text(c, &sess, "main"));
}
c.eval(&sess, "(() => { window.__never_reloaded = true; return true; })()").unwrap();
assert!(!truthy(c, &sess, &format!("document.querySelector('{} .site-update-send')", card("gunnar-rs"))), "a site this box did not lay offers an update");
assert_eq!(text(c, &sess, &format!("{old} .site-update-send")), "Update to r9 · 2026-10-18 · gunnar bb22222", "a release named for a person");
// The row names are drawn in capitals by the style sheet.
let boxes = text(c, &sess, &format!("{old} .site-boxes")).to_lowercase();
for role in ["twin", "main", "front"] {
assert!(boxes.contains(&format!("{role} runs release 01")), "{boxes}");
}
let live = text(c, &sess, &format!("{old} .site-live"));
assert!(live.contains("Live updates — on") && live.contains("Off = every update reinstalls the box from its keep medium, /data kept"), "{live}");
// test2: up to date — said, named, no button; every box its line.
let t2 = card("test2-rs");
reads(c, &sess, &format!("{t2} .site-release"), "Up to date · r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44");
assert!(!truthy(c, &sess, &format!("document.querySelector('{t2} .site-update-send')")), "an update offered to the release it runs");
assert!(text(c, &sess, &format!("{t2} .site-boxes")).contains("the update starts with the front"), "{}", text(c, &sess, &format!("{t2} .site-boxes")));
assert!(text(c, &sess, &t2).contains("Running at https://test2.example") && !text(c, &sess, &t2).contains("first sign-in link"));
shots(c, &sess, "sites");
c.wait_for(&sess, &format!("!document.querySelector('{old} .site-update-send').disabled"), 5_000).expect("an admin may press it");
click(c, &sess, &format!("{old} .site-update-send"));
reads(c, &sess, &format!("{old} .site-update-words"), "update to 02abcdef0123: running");
c.wait_for(&sess, &format!("document.querySelector('{old} .site-update-send').disabled"), 5_000).expect("disarmed while it runs");
{
let held = fake.0.lock().unwrap();
let sent: Vec<(String, String)> = held.updates.iter().map(|u| (u.estate.clone(), u.release.clone())).collect();
assert_eq!(sent, [("old-rs".to_string(), "02abcdef0123".to_string())], "the release the page showed");
assert!(held.bearers.iter().all(|b| b == OPERATOR), "{:?}", held.bearers);
}
assert!(truthy(c, &sess, "window.__never_reloaded === true"), "the page reloaded");
}
/// ★ **CODE RED is on every page until an admin acknowledges it** (owner 2026-10-18): the
/// banner says site · box · what is down · the error · the next action, on the Overview and on
/// Sites alike; Ack sends the key and takes it off; nothing reloads.
#[test]
fn code_red_is_on_every_page_and_ack_takes_it_off() {
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;
};
let red = pb::CodeRed { key: "update:gunnarrs:twin".into(), site: "gunnar.rs".into(), r#box: "twin".into(), what: "the update stopped at the twin".into(), error: "HTTP 404 STORAGE_NOT_FOUND".into(), next: "start the twin, then press Update again".into(), raised_at_unix_ms: 1, ..Default::default() };
let fake = Fake(Arc::new(Mutex::new(Held { sites: sites(), code_red: vec![red], ..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!("code-red-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;
for page in ["/", "/sites"] {
c.navigate(&sess, &format!("{origin}{page}")).expect("the console loads");
if c.wait_for(&sess, "!!document.querySelector('.code-red')", 30_000).is_err() {
panic!("{page}: no CODE RED banner: {}", text(c, &sess, "body"));
}
let banner = text(c, &sess, ".code-red");
assert!(banner.contains("CODE RED · gunnar.rs · twin — the update stopped at the twin: HTTP 404 STORAGE_NOT_FOUND") && banner.contains("Next: start the twin, then press Update again"), "{page}: {banner}");
}
shots(c, &sess, "code-red");
click(c, &sess, ".code-red-ack");
c.wait_for(&sess, "!document.querySelector('.code-red')", 10_000).expect("acknowledged: off the banner");
assert!(fake.0.lock().unwrap().code_red.iter().all(|r| r.acked_at_unix_ms != 0), "the ack reached monetize");
}
/// The box's half for the browser test: the next release offered; once asked it does not answer
/// for one read (restarting), then runs it on trial, then confirmed.
struct FakeBox(Mutex<(u32, Vec<String>)>);
impl monetize_ui_server::self_box::SelfBox for FakeBox {
fn look(&self) -> Result<monetize_ui_server::self_box::SelfLook, String> {
use monetize_ui_server::self_box::SelfLook;
let mut g = self.0.lock().unwrap();
let base = SelfLook { app: "aaaaaaaaaaaaaaaa".into(), slot: "a".into(), base: "base1".into(), live_update: true, next_app: "bbbbbbbbbbbbbbbb".into(), next_build: "fd0ed14".into(), plan: "live".into(), ..Default::default() };
let step = g.0;
if step > 0 {
g.0 += 1;
}
Ok(match step {
0 => base,
1 => SelfLook { asked: true, ..base },
2 => return Err("the box is restarting".into()),
3 | 4 => SelfLook { app: "bbbbbbbbbbbbbbbb".into(), slot: "b trial".into(), plan: "wait".into(), why: "[live-trial-open] slot b trial".into(), ..base },
_ => SelfLook { app: "bbbbbbbbbbbbbbbb".into(), slot: "b".into(), plan: "nothing".into(), ..base },
})
}
fn ask_update(&self, by: &str) -> Result<(), String> {
let mut g = self.0.lock().unwrap();
g.1.push(by.to_string());
g.0 = 1;
Ok(())
}
}
/// ★ **"Update this box"** (owner 2026-10-18): the Sites page names the release the box would
/// take, the press asks the box (once), and the page follows it — asked, restarting, on trial —
/// to "Up to date" on the other slot, with real clicks.
#[test]
fn update_this_box_names_the_release_and_follows_it_to_confirmed() {
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;
};
let the_box = Arc::new(FakeBox(Mutex::new((0, Vec::new()))));
monetize_ui_server::self_box::install(the_box.clone());
let fake = Fake(Arc::new(Mutex::new(Held { sites: sites(), ..Default::default() })));
let origin = up(fake, 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!("box-update-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}/sites")).expect("the console loads");
if c.wait_for(&sess, "!!document.querySelector('#box-update-button') && !document.querySelector('#box-update-button').disabled", 30_000).is_err() {
panic!("no Update this box button to press: {}", text(c, &sess, "body"));
}
assert_eq!(text(c, &sess, "#box-update-button"), "Update this box to fd0ed14");
assert_eq!(text(c, &sess, ".box-words"), "Release fd0ed14 is ready for this box (it runs aaaaaaaaaaaa, slot a)");
shots(c, &sess, "box-update");
click(c, &sess, "#box-update-button");
if c.wait_for(&sess, "(document.querySelector('.box-words')||{}).textContent==='Up to date — this box runs fd0ed14 (slot b)'", 60_000).is_err() {
panic!("not followed to confirmed: {}", text(c, &sess, "#box-update"));
}
assert_eq!(the_box.0.lock().unwrap().1.len(), 1, "asked once: {:?}", the_box.0.lock().unwrap().1);
assert!(truthy(c, &sess, "document.querySelector('#box-update-button').disabled"), "nothing more to take");
}