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

nordisk/edda

crates/web-journey/src/lib.rs

//! # web-journey — a product's console, driven as a customer drives it
//!
//! An HTTP functional test reads the JSON; it cannot see what the page DRAWS: a
//! pay link drawn as plain text, a meter id on a customer page, a "0 B" where
//! nothing was measured. This crate drives a real headless Chromium over the
//! DevTools Protocol ([`chromiumoxide`]) and gives a product's journey the
//! moves it needs; the product keeps its STEPS.
//!
//! * [`Journey::launch`] — the browser, trusting ONLY what it is handed
//!   (`--ignore-certificate-errors-spki-list` with the certificate's SPKI pin,
//!   every other certificate error still fatal; [`spki_pins`]) and reaching the
//!   console's name through a mapped address (`--host-resolver-rules`,
//!   [`browser_args`]). Nothing turns certificate checking off.
//! * the request interceptor — a break-glass bearer on requests to the
//!   console's OWN origin, and only while the journey asks for it
//!   ([`Journey::bearer`]); a fake payment host answered with a stand-in page
//!   ([`stand_in_page`]); every top-level document recorded
//!   ([`Journey::documents`]).
//! * a virtual WebAuthn authenticator — the customer's passkey lives in the
//!   browser, user verification answered yes.
//! * [`Journey::eval`] / [`Journey::wait_for`] / [`Journey::goto`] /
//!   [`Journey::settle`] / [`Journey::shoot`] (full-page PNG) /
//!   [`Journey::read`] (URL + `innerText`, kept for the lint) /
//!   [`find_js`] + [`looks_like_button_js`].
//! * [`lint`] — the text lint over every page read, with the product's
//!   [`LintRules`]: internal ids (`<word>_bytes`…), operator sentences, and a
//!   raw zero printed on a figure the product does not measure.
//!
//! Pattern: private-gunnar-ops `estate journey` (2026-09-28); written for
//! holger's estate (private-holger-ops `estate journey`) as the shared half.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

pub use chromiumoxide;

/// Pure: the host of an absolute URL (`https://h:1/x` → `h`), lower-cased.
pub fn host_of(url: &str) -> Option<String> {
    let rest = url.split_once("://")?.1;
    let authority = rest.split(['/', '?', '#']).next()?;
    let authority = authority.rsplit_once('@').map(|(_, a)| a).unwrap_or(authority);
    let host = if authority.starts_with('[') { authority.split(']').next().map(|h| format!("{h}]"))? } else { authority.split(':').next()?.to_string() };
    (!host.is_empty()).then(|| host.to_ascii_lowercase())
}

/// Pure: the Stripe Checkout session id a checkout URL names
/// (`…/c/pay/cs_test_x#frag` → `cs_test_x`).
pub fn session_of(url: &str) -> Option<String> {
    let path = url.split(['?', '#']).next()?;
    path.rsplit('/').find(|s| s.starts_with("cs_test_") || s.starts_with("cs_live_")).map(str::to_string)
}

/// Pure: the SPKI pins Chrome's `--ignore-certificate-errors-spki-list` takes —
/// base64(SHA-256(SubjectPublicKeyInfo DER)) of every certificate in `pem`.
pub fn spki_pins(pem: &str) -> Result<Vec<String>, String> {
    use base64::Engine as _;
    use rustls_pki_types::pem::PemObject as _;
    use sha2::Digest as _;
    let mut pins = Vec::new();
    for der in rustls_pki_types::CertificateDer::pem_slice_iter(pem.as_bytes()) {
        let der = der.map_err(|e| format!("the handed-over PEM: {e}"))?;
        let (_, cert) = x509_parser::parse_x509_certificate(der.as_ref()).map_err(|e| format!("the handed-over certificate: {e}"))?;
        pins.push(base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(cert.tbs_certificate.subject_pki.raw)));
    }
    if pins.is_empty() {
        return Err("the handed-over PEM holds no certificate".into());
    }
    Ok(pins)
}

/// Pure: the browser's own flags. `map` = `(name, host:port)` sends the
/// console's name to an address (a mock's forward); `pins` trusts exactly the
/// handed-over certificate. No flag turns certificate checking off.
pub fn browser_args(map: Option<(&str, &str)>, pins: &[String]) -> Vec<String> {
    let mut a = vec!["--no-first-run".to_string(), "--no-default-browser-check".to_string(), "--disable-sync".to_string(), "--lang=en-US".to_string()];
    if let Some((host, at)) = map {
        a.push(format!("--host-resolver-rules=MAP {host} {at}"));
    }
    if !pins.is_empty() {
        a.push(format!("--ignore-certificate-errors-spki-list={}", pins.join(",")));
    }
    a
}

/// Which browser binary: `env` if set, else the first installed of the usual
/// Chromium-family paths.
pub fn browser_binary(env: &str) -> Result<PathBuf, String> {
    if let Some(p) = std::env::var_os(env).filter(|v| !v.is_empty()) {
        return Ok(PathBuf::from(p));
    }
    for p in ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/chrome-headless-shell"] {
        if Path::new(p).is_file() {
            return Ok(PathBuf::from(p));
        }
    }
    Err(format!("no Chromium-family browser found (/usr/bin/google-chrome, /usr/bin/chromium, /usr/bin/chromium-browser, /usr/bin/chrome-headless-shell); install one or set {env}"))
}

/// The stand-in page a FAKE payment host is answered with (a fake vendor is an
/// API and serves no page; a customer's browser needs one to land on).
pub fn stand_in_page(url: &str) -> String {
    let esc = url.replace('&', "&amp;").replace('<', "&lt;").replace('"', "&quot;");
    format!(
        "<!doctype html><html><head><meta charset=utf-8><title>Checkout (fake Stripe)</title></head>\
         <body style=\"font-family:sans-serif;margin:3rem\"><h1>Fake Stripe checkout</h1>\
         <p>The mock's stand-in for the payment page. The journey pays through the fake's control port.</p>\
         <p><code>{esc}</code></p></body></html>"
    )
}

// ── the text lint ───────────────────────────────────────────────────────────

/// What a product forbids on its customer pages.
#[derive(Debug, Clone, Default)]
pub struct LintRules {
    /// Word suffixes that make an internal id (`_bytes` → `disk_bytes`).
    pub id_suffixes: &'static [&'static str],
    /// Phrases (lower case) that are internal names or operator sentences, each with why.
    pub phrases: &'static [(&'static str, &'static str)],
    /// Labels (lower case) of figures the product does not measure: a raw `0`
    /// value on one of their lines (or the line just below) is a finding.
    pub unmeasured: &'static [&'static str],
}

/// Pure: **the text lint** over one page's rendered text (`innerText`): one
/// finding per fault.
pub fn lint(text: &str, rules: &LintRules) -> Vec<String> {
    let mut found = Vec::new();
    let lower = text.to_lowercase();
    let mut ids: Vec<String> = Vec::new();
    for word in text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) {
        for suffix in rules.id_suffixes {
            if let Some(stem) = word.strip_suffix(suffix) {
                if !stem.is_empty() && stem.chars().all(|c| c.is_ascii_lowercase() || c == '_') && !ids.iter().any(|w| w == word) {
                    ids.push(word.to_string());
                }
            }
        }
    }
    for id in ids {
        found.push(format!("internal name `{id}`"));
    }
    for (phrase, why) in rules.phrases {
        if lower.contains(phrase) {
            found.push(format!("\"{phrase}\" ({why})"));
        }
    }
    let lines: Vec<&str> = text.lines().map(str::trim).collect();
    for (i, line) in lines.iter().enumerate() {
        if !has_raw_zero(line) {
            continue;
        }
        let context = format!("{} {}", if i > 0 { lines[i - 1] } else { "" }, line).to_lowercase();
        if let Some(label) = rules.unmeasured.iter().find(|t| context.contains(*t)) {
            found.push(format!("a raw 0 for the unmeasured \"{label}\": {:?}", context.trim()));
        }
    }
    found
}

/// Pure: does a line carry a bare zero figure (`0`, `0 B`, `0.0 %`, `0 B/s`,
/// `0 ops/s`) as its own token — not `10 B`, not `0.5`?
pub fn has_raw_zero(line: &str) -> bool {
    let b = line.as_bytes();
    for (i, c) in b.iter().enumerate() {
        if *c != b'0' {
            continue;
        }
        let before_ok = i == 0 || !(b[i - 1].is_ascii_digit() || b[i - 1] == b'.' || b[i - 1] == b',' || b[i - 1].is_ascii_alphabetic());
        if !before_ok {
            continue;
        }
        // `0`, `0.0`, `0.00` … then the end, a space, or a unit.
        let mut j = i + 1;
        if j < b.len() && b[j] == b'.' {
            j += 1;
            let start = j;
            while j < b.len() && b[j] == b'0' {
                j += 1;
            }
            if j == start || (j < b.len() && b[j].is_ascii_digit()) {
                continue;
            }
        } else if j < b.len() && (b[j].is_ascii_digit() || b[j] == b',') {
            continue;
        }
        if j >= b.len() || b[j] == b' ' || b[j] == b'\t' {
            return true;
        }
    }
    false
}

// ── the browser ─────────────────────────────────────────────────────────────

/// How to launch one journey's browser.
#[derive(Debug, Clone)]
pub struct Launch {
    pub binary: PathBuf,
    /// The console's host name (the bearer's origin).
    pub host: String,
    /// `Some(host:port)` maps [`Launch::host`] to that address.
    pub map_to: Option<String>,
    /// The handed-over certificate(s), PEM; `None` trusts the public roots.
    pub pem: Option<String>,
    /// The break-glass bearer the interceptor may put on the console's origin.
    pub bearer: Option<String>,
    /// A payment host answered with [`stand_in_page`] (a fake vendor's).
    pub fake_host: Option<String>,
    /// The browser profile directory (removed at [`Journey::close`]).
    pub profile: PathBuf,
}

struct Wire {
    host: String,
    bearer_on: AtomicBool,
    bearer: Option<String>,
    fake_host: Option<String>,
    documents: Mutex<Vec<String>>,
}

/// One page's rendered text, as a step read it.
#[derive(Debug, Clone)]
pub struct Seen {
    pub what: String,
    pub url: String,
    pub text: String,
}

/// **One running journey**: the browser, its one page, the interceptor.
pub struct Journey {
    browser: chromiumoxide::Browser,
    pub page: chromiumoxide::Page,
    wire: Arc<Wire>,
    pump: tokio::task::JoinHandle<()>,
    interceptor: tokio::task::JoinHandle<()>,
    profile: PathBuf,
    /// Every page [`Journey::read`] read, in order (the lint's input).
    pub seen: Vec<Seen>,
}

impl Journey {
    /// Launch the browser (headless, certificate errors respected, requests
    /// intercepted) with a virtual passkey authenticator.
    pub async fn launch(l: Launch) -> Result<Journey, String> {
        use chromiumoxide::browser::{Browser, BrowserConfig};
        use futures::StreamExt as _;
        let pins = match &l.pem {
            Some(pem) => spki_pins(pem)?,
            None => Vec::new(),
        };
        let cfg = BrowserConfig::builder()
            .chrome_executable(&l.binary)
            .new_headless_mode()
            .respect_https_errors()
            .enable_request_intercept()
            .user_data_dir(&l.profile)
            .window_size(1280, 1000)
            .viewport(None)
            .launch_timeout(Duration::from_secs(60))
            .request_timeout(Duration::from_secs(60))
            .args(browser_args(l.map_to.as_deref().map(|at| (l.host.as_str(), at)), &pins))
            .build()?;
        let (browser, mut handler) = Browser::launch(cfg).await.map_err(|e| format!("the browser did not start: {e}"))?;
        let pump = tokio::spawn(async move { while handler.next().await.is_some() {} });
        let page = browser.new_page("about:blank").await.map_err(|e| e.to_string())?;
        let wire = Arc::new(Wire { host: l.host.to_ascii_lowercase(), bearer_on: AtomicBool::new(false), bearer: l.bearer.clone(), fake_host: l.fake_host.clone(), documents: Mutex::new(Vec::new()) });
        let interceptor = intercept(&page, wire.clone()).await?;
        authenticator(&page).await?;
        Ok(Journey { browser, page, wire, pump, interceptor, profile: l.profile, seen: Vec::new() })
    }

    /// Put the bearer on the console's origin (true) or stop (false).
    pub fn bearer(&self, on: bool) {
        self.wire.bearer_on.store(on && self.wire.bearer.is_some(), Ordering::SeqCst);
    }

    /// Every top-level document the browser asked for so far.
    pub fn documents(&self) -> Vec<String> {
        self.wire.documents.lock().map(|d| d.clone()).unwrap_or_default()
    }

    /// Evaluate `js` (an expression; a promise is awaited) and answer its JSON.
    pub async fn eval(&self, js: &str) -> Result<serde_json::Value, String> {
        use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
        let p = EvaluateParams::builder().expression(js).await_promise(true).return_by_value(true).build().map_err(|e| e.to_string())?;
        let r = self.page.evaluate_expression(p).await.map_err(|e| format!("{e}"))?;
        Ok(r.value().cloned().unwrap_or(serde_json::Value::Null))
    }

    /// Poll `js` until it is truthy, or `limit` passes. Answers the value.
    pub async fn wait_for(&self, js: &str, limit: Duration) -> Result<serde_json::Value, String> {
        let t = Instant::now();
        loop {
            let v = self.eval(js).await.unwrap_or(serde_json::Value::Null);
            if truthy(&v) {
                return Ok(v);
            }
            if t.elapsed() >= limit {
                return Err(format!("not within {} s", limit.as_secs()));
            }
            tokio::time::sleep(Duration::from_millis(300)).await;
        }
    }

    pub async fn goto(&self, url: &str) -> Result<(), String> {
        self.page.goto(url).await.map(|_| ()).map_err(|e| format!("{url}: {e}"))
    }

    pub async fn url(&self) -> String {
        self.eval("location.href").await.ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
    }

    pub async fn text(&self) -> String {
        self.eval("document.body ? document.body.innerText : ''").await.ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
    }

    /// Let the page settle: no `aria-busy`, no "loading…" line (soft), then a
    /// beat for the paint.
    pub async fn settle(&self) {
        let _ = self.wait_for("document.querySelectorAll('[aria-busy=true], .pending[role=status]').length === 0", Duration::from_secs(15)).await;
        tokio::time::sleep(Duration::from_millis(600)).await;
    }

    /// A full-page PNG as `<dir>/NN-name.png`.
    pub async fn shoot(&self, dir: &Path, n: usize, name: &str) -> Option<PathBuf> {
        use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
        use chromiumoxide::page::ScreenshotParams;
        self.settle().await;
        let path = dir.join(format!("{n:02}-{name}.png"));
        let p = ScreenshotParams::builder().format(CaptureScreenshotFormat::Png).full_page(true).build();
        self.page.save_screenshot(p, &path).await.ok().map(|_| path)
    }

    /// Read the page (settled) into [`Journey::seen`] and answer its text.
    pub async fn read(&mut self, what: &str) -> String {
        self.settle().await;
        let (url, text) = (self.url().await, self.text().await);
        self.seen.push(Seen { what: what.to_string(), url, text: text.clone() });
        text
    }

    /// Click the element tagged `data-journey=<tag>` (a real CDP mouse click).
    pub async fn click_tagged(&self, tag: &str) -> Result<(), String> {
        let el = self.page.find_element(format!("[data-journey={tag}]")).await.map_err(|e| format!("[data-journey={tag}]: {e}"))?;
        el.click().await.map(|_| ()).map_err(|e| format!("clicking [data-journey={tag}]: {e}"))
    }

    /// Close the browser and remove its profile.
    pub async fn close(mut self) {
        self.interceptor.abort();
        let _ = self.browser.close().await;
        let _ = self.browser.wait().await;
        self.pump.abort();
        let _ = std::fs::remove_dir_all(&self.profile);
    }
}

fn truthy(v: &serde_json::Value) -> bool {
    match v {
        serde_json::Value::Null => false,
        serde_json::Value::Bool(b) => *b,
        serde_json::Value::String(s) => !s.is_empty(),
        serde_json::Value::Number(n) => n.as_f64().is_some_and(|x| x != 0.0),
        _ => true,
    }
}

/// The request interceptor: requests to the console carry the bearer while
/// (and only while) it is on; the fake payment host is answered with the
/// stand-in page; everything else continues untouched.
async fn intercept(page: &chromiumoxide::Page, wire: Arc<Wire>) -> Result<tokio::task::JoinHandle<()>, String> {
    use base64::Engine as _;
    use chromiumoxide::cdp::browser_protocol::fetch::{ContinueRequestParams, EventRequestPaused, FulfillRequestParams, HeaderEntry};
    use chromiumoxide::cdp::browser_protocol::network::ResourceType;
    use futures::StreamExt as _;
    let mut paused = page.event_listener::<EventRequestPaused>().await.map_err(|e| e.to_string())?;
    let page = page.clone();
    Ok(tokio::spawn(async move {
        while let Some(ev) = paused.next().await {
            let url = ev.request.url.clone();
            let host = host_of(&url).unwrap_or_default();
            if ev.resource_type == ResourceType::Document {
                if let Ok(mut d) = wire.documents.lock() {
                    d.push(url.clone());
                }
            }
            if wire.fake_host.as_deref() == Some(host.as_str()) {
                let body = base64::engine::general_purpose::STANDARD.encode(stand_in_page(&url));
                let mut f = FulfillRequestParams::new(ev.request_id.clone(), 200);
                f.response_headers = Some(vec![HeaderEntry::new("content-type", "text/html; charset=utf-8")]);
                f.body = Some(body.into());
                let _ = page.execute(f).await;
                continue;
            }
            let mut go = ContinueRequestParams::new(ev.request_id.clone());
            if host == wire.host && wire.bearer_on.load(Ordering::SeqCst) {
                if let Some(bearer) = &wire.bearer {
                    let mut headers: Vec<HeaderEntry> = ev
                        .request
                        .headers
                        .inner()
                        .as_object()
                        .map(|m| m.iter().filter(|(k, _)| !k.eq_ignore_ascii_case("authorization")).map(|(k, v)| HeaderEntry::new(k.clone(), v.as_str().unwrap_or_default().to_string())).collect())
                        .unwrap_or_default();
                    headers.push(HeaderEntry::new("Authorization", format!("Bearer {bearer}")));
                    go.headers = Some(headers);
                }
            }
            let _ = page.execute(go).await;
        }
    }))
}

/// A virtual platform authenticator (WebAuthn over CDP): user verification yes.
async fn authenticator(page: &chromiumoxide::Page) -> Result<(), String> {
    use chromiumoxide::cdp::browser_protocol::web_authn::{AddVirtualAuthenticatorParams, AuthenticatorProtocol, AuthenticatorTransport, EnableParams, VirtualAuthenticatorOptions};
    page.execute(EnableParams::default()).await.map_err(|e| format!("WebAuthn.enable: {e}"))?;
    let mut o = VirtualAuthenticatorOptions::new(AuthenticatorProtocol::Ctap2, AuthenticatorTransport::Internal);
    o.has_resident_key = Some(true);
    o.has_user_verification = Some(true);
    o.is_user_verified = Some(true);
    o.automatic_presence_simulation = Some(true);
    page.execute(AddVirtualAuthenticatorParams::new(o)).await.map_err(|e| format!("WebAuthn.addVirtualAuthenticator: {e}"))?;
    Ok(())
}

/// JS: the first visible element matching `selector` whose own text is exactly
/// `text`, tagged `data-journey=<tag>`; answers `{tag, cls}` or null.
pub fn find_js(selector: &str, text: &str, tag: &str) -> String {
    format!(
        "(() => {{ const want = {text}; for (const e of document.querySelectorAll({sel})) {{ \
           const r = e.getBoundingClientRect(); const s = getComputedStyle(e); \
           if ((e.innerText || '').trim() === want && r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none') {{ \
             e.setAttribute('data-journey', {tag}); return {{tag: e.tagName, cls: e.className}}; }} }} return null; }})()",
        text = serde_json::Value::String(text.into()),
        sel = serde_json::Value::String(selector.into()),
        tag = serde_json::Value::String(tag.into()),
    )
}

/// JS: does the element tagged `data-journey=<tag>` LOOK like a button — a
/// `<button>` or `.btn`, visible, filled or bordered (a link drawn as plain
/// text is the fault this catches)?
pub fn looks_like_button_js(tag: &str) -> String {
    format!(
        "(() => {{ const e = document.querySelector('[data-journey={tag}]'); if (!e) return {{ok:false, why:'gone'}}; \
           const s = getComputedStyle(e); const r = e.getBoundingClientRect(); \
           const isButton = e.tagName === 'BUTTON' || e.classList.contains('btn'); \
           const filled = s.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.backgroundColor !== 'transparent'; \
           const bordered = parseFloat(s.borderTopWidth) > 0 && s.borderTopStyle !== 'none'; \
           const visible = r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity) > 0.2; \
           return {{ok: isButton && visible && (filled || bordered), tag: e.tagName, cls: e.className, bg: s.backgroundColor, border: s.borderTopWidth + ' ' + s.borderTopStyle, w: r.width, h: r.height, visible}}; }})()"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    const RULES: LintRules = LintRules {
        id_suffixes: &["_bytes"],
        phrases: &[("provisioned against a link", "an operator log sentence"), ("grpc", "an internal name")],
        unmeasured: &["disk speed", "cache"],
    };

    #[test]
    fn the_lint_catches_ids_sentences_and_a_raw_zero_on_an_unmeasured_figure() {
        let f = lint("More room\npack_bytes +10 GiB\nnothing is provisioned against a link\nvia gRPC\nDisk speed\n0 B/s", &RULES);
        assert!(f.iter().any(|l| l.contains("`pack_bytes`")), "{f:?}");
        assert!(f.iter().any(|l| l.contains("provisioned against a link")), "{f:?}");
        assert!(f.iter().any(|l| l.contains("grpc")), "{f:?}");
        assert!(f.iter().any(|l| l.contains("disk speed")), "{f:?}");
        assert!(lint("Disk speed\nN/A\nCache\nN/A\n+10 GiB · 190.00 SEK + VAT per month", &RULES).is_empty());
        assert!(lint("Storage used\n0 B", &RULES).is_empty(), "a measured zero on a measured figure is fine");
    }

    #[test]
    fn a_raw_zero_is_its_own_token() {
        for yes in ["0", "0 B", "0.0 %", "0 B/s", "0 ops/s", "cache 0"] {
            assert!(has_raw_zero(yes), "{yes}");
        }
        for no in ["10 B", "0.5 GB", "1,0", "v0", "100", "2.0 GB", "N/A"] {
            assert!(!has_raw_zero(no), "{no}");
        }
    }

    #[test]
    fn the_host_and_the_session_of_a_checkout_url() {
        assert_eq!(host_of("https://checkout.fake-stripe.test/c/pay/cs_test_a1#fid").as_deref(), Some("checkout.fake-stripe.test"));
        assert_eq!(host_of("https://Checkout.Stripe.com:443/c/pay/x").as_deref(), Some("checkout.stripe.com"));
        assert_eq!(host_of("about:blank"), None);
        assert_eq!(session_of("https://checkout.stripe.com/c/pay/cs_live_b2?x=1").as_deref(), Some("cs_live_b2"));
        assert_eq!(session_of("https://test2.holger.rs/purchases"), None);
    }

    #[test]
    fn the_browser_trusts_only_what_it_is_handed() {
        let mock = browser_args(Some(("test2.holger.rs", "127.81.99.12:443")), &["AAAA".to_string()]);
        assert!(mock.contains(&"--host-resolver-rules=MAP test2.holger.rs 127.81.99.12:443".to_string()));
        assert!(mock.contains(&"--ignore-certificate-errors-spki-list=AAAA".to_string()));
        let real = browser_args(None, &[]);
        assert!(!real.iter().any(|a| a.contains("host-resolver") || a.contains("spki")));
        for a in mock.iter().chain(real.iter()) {
            assert_ne!(a, "--ignore-certificate-errors");
        }
    }

    #[test]
    fn the_spki_pin_is_the_sha256_of_the_public_key() {
        use base64::Engine as _;
        use sha2::Digest as _;
        let key = rcgen::KeyPair::generate().unwrap();
        let cert = rcgen::CertificateParams::new(vec!["test2.holger.rs".to_string()]).unwrap().self_signed(&key).unwrap();
        let pins = spki_pins(&cert.pem()).unwrap();
        assert_eq!(pins, vec![base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(key.public_key_der()))]);
        assert!(spki_pins("not a pem").is_err());
    }

    #[test]
    fn the_stand_in_page_escapes_the_url() {
        let p = stand_in_page("https://checkout.fake-stripe.test/c/pay/cs_test_x?<a>&\"");
        assert!(p.contains("&lt;a>&amp;&quot;") && !p.contains("<a>"), "{p}");
    }

    /// **The driver on this box's browser**: launched as a journey launches it,
    /// the fake host answered with the stand-in, a screenshot taken.
    /// `WEB_JOURNEY_BROWSER=… cargo test -p web-journey -- --ignored`.
    #[test]
    #[ignore = "needs a Chromium-family browser"]
    fn a_real_browser_lands_on_the_stand_in_and_shoots_it() {
        let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
        rt.block_on(async {
            let dir = std::env::temp_dir().join(format!("web-journey-smoke-{}", std::process::id()));
            std::fs::create_dir_all(&dir).unwrap();
            let mut j = Journey::launch(Launch {
                binary: browser_binary("WEB_JOURNEY_BROWSER").unwrap(),
                host: "console.invalid".into(),
                map_to: None,
                pem: None,
                bearer: None,
                fake_host: Some("checkout.fake-stripe.test".into()),
                profile: dir.join("profile"),
            })
            .await
            .unwrap();
            j.goto("https://checkout.fake-stripe.test/c/pay/cs_test_smoke").await.unwrap();
            assert_eq!(session_of(&j.url().await).as_deref(), Some("cs_test_smoke"));
            assert!(j.read("smoke").await.contains("Fake Stripe checkout"));
            assert!(j.documents().iter().any(|d| d.contains("cs_test_smoke")));
            assert!(j.shoot(&dir, 1, "smoke").await.is_some_and(|p| p.is_file()));
            j.close().await;
            let _ = std::fs::remove_dir_all(&dir);
        });
    }
}