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('&', "&").replace('<', "<").replace('"', """);
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("<a>&"") && !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);
});
}
}