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

nordisk/monetize

crates/monetize-site-buy/tests/browser_plans_order.rs

//! ★ **The plans are listed cheapest first, in a real browser** (owner, gunnar.rs 2026-10-27:
//! signed in, Account → "Grow your plan" opened on 100 GB, not 10 GB). The order is the ONE
//! box's: monetize-server answers every list cheapest first (`monetize::bundle::offers`, the
//! account plans by size, the ladder smallest first), and the shared `buy.js` draws the rows in
//! the order it is given and opens on the first: a thin page that never sorts.
//!
//! The page (`tests/browser/page.html`, `buy.js` filled as a site fills it) runs in headless
//! Chromium over the crate's relay and the fake ONE box, for Gunnar and Holger, signed in and
//! not, on both tabs: the account ("Account on <site>" / "Grow your plan") and the private
//! instance ("New instance"). What the buyer sees is read from the page's `verdict`.
//!
//! Needs `CHROME` (a Chromium binary, as holger-ui's browser image sets it); without it the
//! test says NOT RUN and returns, and with `MONETIZE_NEEDS_BROWSER` set (the CI's `cargo xtask
//! browser`, in the browser image) it FAILS instead.

use std::net::SocketAddr;
use std::sync::Arc;

use monetize_proto as pb;
use monetize_site_buy::assets::{buy_js, Settings};
use monetize_site_buy::testkit::{FakeOneBox, Shape, TestSite, LADDER, PAID_WORDS};
use monetize_site_buy::Relay;
use serde_json::Value;

const BEARER: &str = "site-bearer-for-browser-tests";
const PAGE: &str = include_str!("browser/page.html");
const GIB: u64 = 1 << 30;

fn chrome() -> Option<String> {
    let c = std::env::var("CHROME").ok().filter(|c| !c.is_empty());
    if c.is_none() {
        // The CI's browser step (`cargo xtask browser`) requires the browser: red, never NOT RUN.
        assert!(std::env::var_os("MONETIZE_NEEDS_BROWSER").is_none(), "MONETIZE_NEEDS_BROWSER is set and CHROME names no Chromium: this browser test cannot run (`cargo xtask browser` runs it in the browser image)");
        eprintln!("NOT RUN: set CHROME to a Chromium binary to open the buy page");
    }
    c
}

fn settings(shape: Shape) -> Settings {
    Settings {
        product: shape.word().into(),
        storage: format!("{}_", shape.product()),
        free_account: format!("{}/account", shape.product()),
        free_note: "Free: clone only. Pushing needs a paid plan.".into(),
        card_check: "A card check at Stripe (0 kr) proves you are a person.".into(),
        beta_line: "Beta: we replicate your data, but cannot guarantee it.".into(),
        manage: shape.pages().manage,
        account_plan: "account".into(),
        here: "site".into(),
        bearer_key: String::new(),
        status_url: "/auth/status".into(),
        status_shape: String::new(),
        offer_url: "/auth/buy/offer".into(),
        contact: String::new(),
    }
}

/// The site on loopback, `user` signed in (`None`: a visitor). The browser's session is played
/// by the site itself: every request carries `user`'s cookie (Gunnar) or bearer (Holger).
async fn site(shape: Shape, fake: &FakeOneBox, user: Option<&'static str>) -> String {
    let relay = Arc::new(Relay::new(TestSite::over(shape, fake), fake.link()));
    let page = PAGE.replacen("/*@buy-script*/", &buy_js(&settings(shape)), 1);
    let status = match user {
        None => {
            serde_json::json!({ "authenticated": false, "private_url": shape.pages().private, "buy_url": "/signup?plan=10gb" })
        }
        Some(u) => {
            serde_json::json!({ "authenticated": true, "user": u, "private_url": shape.pages().private, "buy_url": "/signup?plan=10gb", "grow_account_url": "/signup?plan=10gb" })
        }
    };
    let app = monetize_site_buy::router(relay)
        .route(
            "/signup",
            axum::routing::get(move || async move { axum::response::Html(page) }),
        )
        .route(
            "/auth/status",
            axum::routing::get(move || async move { axum::Json(status) }),
        )
        .layer(axum::middleware::map_request(
            move |mut req: axum::extract::Request| async move {
                if let Some(u) = user {
                    let (name, value) = match shape {
                        Shape::Gunnar => {
                            (axum::http::header::COOKIE, format!("gunnar_session={u}"))
                        }
                        Shape::Holger => (axum::http::header::AUTHORIZATION, format!("Bearer {u}")),
                    };
                    req.headers_mut().insert(name, value.parse().unwrap());
                }
                req
            },
        ));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
    tokio::spawn(async move {
        axum::serve(
            listener,
            app.into_make_service_with_connect_info::<SocketAddr>(),
        )
        .await
    });
    base
}

/// What a buyer sees at `url` once the page has settled: the page's `verdict`.
async fn seen(chrome: &str, url: &str) -> Value {
    let (chrome, url) = (chrome.to_owned(), url.to_owned());
    tokio::task::spawn_blocking(move || {
        let profile = profile_dir(&url);
        std::fs::create_dir_all(&profile).unwrap();
        let out = std::process::Command::new(&chrome)
            .args([
                "--headless",
                "--no-sandbox",
                "--disable-gpu",
                "--disable-dev-shm-usage",
                "--no-first-run",
            ])
            .arg(format!("--user-data-dir={}", profile.display()))
            .arg("--virtual-time-budget=8000")
            .arg("--dump-dom")
            .arg(&url)
            .output()
            .expect("chromium runs");
        let _ = std::fs::remove_dir_all(&profile);
        let dom = String::from_utf8_lossy(&out.stdout).into_owned();
        let verdict = dom
            .split("<pre id=\"verdict\">")
            .nth(1)
            .and_then(|r| r.split("</pre>").next())
            .unwrap_or("");
        let verdict = verdict
            .replace("&quot;", "\"")
            .replace("&lt;", "<")
            .replace("&gt;", ">")
            .replace("&amp;", "&");
        serde_json::from_str(&verdict).unwrap_or_else(|e| {
            panic!(
                "no verdict at {url} ({e}):\n{dom}\n{}",
                String::from_utf8_lossy(&out.stderr)
            )
        })
    })
    .await
    .unwrap()
}

/// One row of the grow window, as monetize-server answers it.
fn row(id: &str, gib: u64, price: &str, meter: &str) -> pb::Bundle {
    pb::Bundle {
        id: id.into(),
        label: format!("{gib}.0 GiB more room"),
        price: price.into(),
        period: "month".into(),
        delta: [(meter.to_string(), gib * GIB)].into(),
        can_serve: true,
        ..Default::default()
    }
}

/// ★ Signed in, Account → "Grow your plan": the page opens on the cheapest row, the − is the
/// floor, and the rows follow in the ONE box's order (10, 50, 100 GiB on Gunnar; 5, 10, 50 GiB
/// on Holger; the ids "100gb" and "10gib" sort first as text, and must not lead).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn grow_your_plan_opens_on_the_cheapest_step() {
    let Some(chrome) = chrome() else { return };
    for shape in [Shape::Gunnar, Shape::Holger] {
        let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
        fake.add_account("alica", "alica@mock.invalid", &[]);
        let rows = match shape {
            Shape::Gunnar => vec![
                row("10gb", 10, "220.00 SEK", "pack_bytes"),
                row("50gb", 50, "1100.00 SEK", "pack_bytes"),
                row("100gb", 100, "2200.00 SEK", "pack_bytes"),
            ],
            Shape::Holger => vec![
                row("5gib", 5, "110.00 SEK", "disk_bytes"),
                row("10gib", 10, "220.00 SEK", "disk_bytes"),
                row("50gib", 50, "1100.00 SEK", "disk_bytes"),
            ],
        };
        let first = rows[0].label.clone();
        fake.sell_bundles(rows);
        let base = site(shape, &fake, Some("alica")).await;
        let v = seen(&chrome, &format!("{base}/signup?plan=10gb")).await;
        assert!(
            v["choices"]
                .as_array()
                .into_iter()
                .flatten()
                .any(|c| c == "Grow your plan"),
            "{shape:?}: the signed-in account tab: {v}"
        );
        assert_eq!(
            v["pressed"],
            serde_json::json!(["Grow your plan"]),
            "{shape:?}: the grow tab is the one open: {v}"
        );
        assert_eq!(
            v["step"].as_str(),
            Some(first.as_str()),
            "{shape:?}: the page opens on the cheapest step: {v}"
        );
        assert!(
            v["buttons"]
                .as_array()
                .into_iter()
                .flatten()
                .any(|b| b == "Upgrade"),
            "{shape:?}: the step is buyable: {v}"
        );
    }
}

/// ★ Not signed in, "Account on <site>": the free account, then the plans cheapest first; and
/// the private instance ("New instance", signed in or not) opens on the ladder's smallest step.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_account_plans_and_the_new_instance_open_on_the_cheapest_step() {
    let Some(chrome) = chrome() else { return };
    for shape in [Shape::Gunnar, Shape::Holger] {
        let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
        fake.add_account("alica", "alica@mock.invalid", &[]);
        let visitor = site(shape, &fake, None).await;
        let v = seen(&chrome, &format!("{visitor}{}", shape.pages().account)).await;
        let rows: Vec<&str> = v["rows"]
            .as_array()
            .into_iter()
            .flatten()
            .filter_map(Value::as_str)
            .collect();
        assert!(
            rows.len() >= 2,
            "{shape:?}: the free account and a plan: {v}"
        );
        assert!(
            rows[0].starts_with("Free account"),
            "{shape:?}: the free account first: {v}"
        );
        assert!(
            rows[1].contains(PAID_WORDS),
            "{shape:?}: then the cheapest plan: {v}"
        );

        let (smallest, ..) = LADDER[0];
        for (who, base) in [
            ("a visitor", visitor.clone()),
            ("signed in", site(shape, &fake, Some("alica")).await),
        ] {
            let v = seen(&chrome, &format!("{base}{}", shape.pages().private)).await;
            assert_eq!(
                v["step"].as_str(),
                Some(smallest),
                "{shape:?} {who}: the new instance opens on the smallest step: {v}"
            );
        }
    }
}

/// The Chromium profile directory of one page load: this process's, numbered, so no two loads
/// share one (it was named from a sum over the URL, which two loads can share).
fn profile_dir(_url: &str) -> std::path::PathBuf {
    use std::sync::atomic::{AtomicU32, Ordering};
    static N: AtomicU32 = AtomicU32::new(0);
    std::env::temp_dir().join(format!(
        "site-buy-order-{}-{}",
        std::process::id(),
        N.fetch_add(1, Ordering::SeqCst)
    ))
}

/// Two page loads never share a Chromium profile: the tests of this file run in parallel, and a
/// second Chromium on a held profile exits at once (GROW-ORDER's flake, RESUME 13).
#[test]
fn two_page_loads_never_share_a_profile() {
    assert_ne!(
        profile_dir("http://127.0.0.1:1/a"),
        profile_dir("http://127.0.0.1:1/a"),
        "the same page twice"
    );
    assert_ne!(
        profile_dir("http://127.0.0.1:1/ab"),
        profile_dir("http://127.0.0.1:1/ba"),
        "two pages with the same length and byte sum"
    );
}