Skip to main content

logicaffeine_cli/browser/
mod.rs

1//! A real browser as a page tier: headless Chrome, driven over the DevTools protocol.
2//!
3//! [`Browser::launch`] starts Chrome with `--remote-debugging-pipe` — the protocol travels over
4//! two pipes the harness owns, so no port is opened and nothing else can attach. [`Browser::open`]
5//! serves a [`PageBundle`] from a loopback HTTP server and loads it in a fresh target inside a
6//! browser context of its own (no cookies, storage or cache shared with any other page), then
7//! returns a [`Page`] once the page's module has run `main` and committed its first paint.
8//!
9//! What a [`Page`] offers is the whole driving surface a page tier needs:
10//!
11//! * real input — [`Page::click`], [`Page::type_into`], [`Page::press`] dispatch trusted mouse and
12//!   keyboard events through the browser's input pipeline, so default actions (a submit button
13//!   submitting, Enter submitting a form) happen exactly as they do for a visitor; [`Page::choose`]
14//!   and [`Page::submit`] use the DOM's own `change`/`requestSubmit` paths;
15//! * [`Page::snapshot`] — the document as a canonical tree, title, URL and focused element;
16//! * [`Page::import_trace`] — every host import the module called and every export the host
17//!   called, in order, recorded by wrapping `WebAssembly.instantiate` (the page's own runtime is
18//!   untouched);
19//! * [`Page::advance`] — the page's clock under virtual time: paused from the moment the page is
20//!   ready, moving only when a test moves it;
21//! * [`Page::answer`] — network stubs: every request outside the bundle is intercepted, answered
22//!   when a stub matches and otherwise HELD, so an unanswered request stays pending as it would
23//!   on a network that has not replied;
24//! * navigation — a document the page asks the browser for (a link it follows, a form it submits,
25//!   a `location` it assigns) is let in only once the document that asked has reported everything
26//!   it will, and every act, settle and read is made in the document the page came to rest in,
27//!   never in one half-replaced;
28//! * [`Page::eval`] — anything else, as JavaScript.
29//!
30//! One browser serves a whole test binary ([`shared`]); every [`Page`] is its own target and is
31//! closed when dropped. Every wait has a deadline and fails loudly, naming what it waited for.
32
33mod bundle;
34mod cdp;
35mod page;
36#[cfg(unix)]
37mod process;
38mod remote_page;
39mod server;
40mod snapshot;
41
42pub use bundle::PageBundle;
43pub use page::{
44    Answer, CallKind, ConsoleLine, DomCounters, HostCall, OpenOptions, Page, PageRequest,
45};
46pub use remote_page::ChromeLauncher;
47pub use server::RelayHub;
48pub use snapshot::{DomNode, Snapshot};
49
50use std::path::{Path, PathBuf};
51use std::sync::{Arc, Mutex, OnceLock};
52use std::time::Duration;
53
54/// How long any single protocol command may take before the harness gives up on it.
55pub(crate) const COMMAND_TIMEOUT: Duration = Duration::from_secs(20);
56
57/// How long a command that hands control to the page's own code may take: a trusted input event, or a harness
58/// action that fires one, returns only once the page has handled it, so its wait is the page's work, which takes as
59/// long as the program's handler runs and the machine allows. Still bounded, so a page that never returns fails.
60pub(crate) const PAGE_WORK_TIMEOUT: Duration = Duration::from_secs(300);
61
62/// Why a browser operation failed.
63#[derive(Debug)]
64pub enum BrowserError {
65    /// Chrome could not be started.
66    Launch(String),
67    /// The DevTools pipe closed; the browser is gone.
68    Closed(String),
69    /// A command came back with an error.
70    Protocol { method: String, message: String },
71    /// Something did not happen in time.
72    Timeout { what: String, after: Duration },
73    /// The page threw, or a script the harness ran against it did.
74    Page(String),
75    /// The page or its bundle could not be prepared.
76    Setup(String),
77}
78
79impl std::fmt::Display for BrowserError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            BrowserError::Launch(m) => write!(f, "Chrome did not start: {m}"),
83            BrowserError::Closed(m) => write!(f, "the browser is gone: {m}"),
84            BrowserError::Protocol { method, message } => write!(f, "`{method}` failed: {message}"),
85            BrowserError::Timeout { what, after } => {
86                write!(
87                    f,
88                    "timed out after {:.1}s waiting for {what}",
89                    after.as_secs_f64()
90                )
91            }
92            BrowserError::Page(m) => write!(f, "{m}"),
93            BrowserError::Setup(m) => write!(f, "{m}"),
94        }
95    }
96}
97
98impl std::error::Error for BrowserError {}
99
100/// The Chrome (or Chromium) binary this machine has: `CHROME_BIN` when it names a file, otherwise
101/// the first of the usual names found on `PATH`.
102pub fn locate_chrome() -> Option<PathBuf> {
103    if let Some(bin) = std::env::var_os("CHROME_BIN").map(PathBuf::from) {
104        if bin.is_file() {
105            return Some(bin);
106        }
107    }
108    const NAMES: &[&str] = &[
109        "google-chrome",
110        "google-chrome-stable",
111        "chromium",
112        "chromium-browser",
113        "chrome",
114    ];
115    let path = std::env::var_os("PATH")?;
116    for dir in std::env::split_paths(&path) {
117        for name in NAMES {
118            let candidate = dir.join(name);
119            if candidate.is_file() {
120                return Some(candidate);
121            }
122        }
123    }
124    #[cfg(target_os = "macos")]
125    {
126        let app = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
127        if app.is_file() {
128            return Some(app);
129        }
130    }
131    None
132}
133
134/// A running headless Chrome.
135pub struct Browser {
136    wire: Arc<cdp::Wire>,
137    child: Mutex<Option<std::process::Child>>,
138    profile: PathBuf,
139    version: String,
140}
141
142impl Browser {
143    /// Start `chrome` with a fresh profile and connect to it.
144    #[cfg(unix)]
145    pub fn launch(chrome: &Path) -> Result<Browser, BrowserError> {
146        let launched = process::launch(chrome)
147            .map_err(|e| BrowserError::Launch(format!("{}: {e}", chrome.display())))?;
148        let wire = cdp::Wire::start(launched.to_browser, launched.from_browser);
149        let mut browser = Browser {
150            wire,
151            child: Mutex::new(Some(launched.child)),
152            profile: launched.profile,
153            version: String::new(),
154        };
155        let version = browser
156            .command("Browser.getVersion", serde_json::json!({}))
157            .map_err(|e| {
158                BrowserError::Launch(format!("{e}\n{}", process::log_tail(&browser.profile)))
159            })?;
160        browser.version = version["product"].as_str().unwrap_or("Chrome").to_string();
161        Ok(browser)
162    }
163
164    /// The harness drives Chrome over pipes it passes as fds 3 and 4, which this platform's
165    /// process model does not offer.
166    #[cfg(not(unix))]
167    pub fn launch(chrome: &Path) -> Result<Browser, BrowserError> {
168        Err(BrowserError::Launch(format!(
169            "{}: the browser harness drives Chrome over `--remote-debugging-pipe` (fds 3 and 4), \
170             which it implements for unix hosts only",
171            chrome.display()
172        )))
173    }
174
175    /// `Chrome/154.0.8037.57`.
176    pub fn version(&self) -> &str {
177        &self.version
178    }
179
180    /// Open `bundle` in a new page.
181    pub fn open(&self, bundle: &PageBundle) -> Result<Page, BrowserError> {
182        Page::open(Arc::clone(&self.wire), bundle, &OpenOptions::default())
183    }
184
185    /// Open `bundle` with extra setup — scripts that run before the page's own.
186    pub fn open_with(
187        &self,
188        bundle: &PageBundle,
189        options: &OpenOptions,
190    ) -> Result<Page, BrowserError> {
191        Page::open(Arc::clone(&self.wire), bundle, options)
192    }
193
194    fn command(
195        &self,
196        method: &str,
197        params: serde_json::Value,
198    ) -> Result<serde_json::Value, BrowserError> {
199        self.wire.send(None, method, params, COMMAND_TIMEOUT)
200    }
201}
202
203impl Drop for Browser {
204    fn drop(&mut self) {
205        self.wire
206            .send_detached(None, "Browser.close", serde_json::json!({}));
207        if let Some(mut child) = self.child.lock().unwrap().take() {
208            let deadline = std::time::Instant::now() + Duration::from_secs(5);
209            loop {
210                match child.try_wait() {
211                    Ok(Some(_)) => break,
212                    Ok(None) if std::time::Instant::now() < deadline => {
213                        std::thread::sleep(Duration::from_millis(20))
214                    }
215                    _ => {
216                        let _ = child.kill();
217                        let _ = child.wait();
218                        break;
219                    }
220                }
221            }
222        }
223        let _ = std::fs::remove_dir_all(&self.profile);
224    }
225}
226
227/// The one browser this process shares, launched from `chrome` on first use.
228///
229/// A test binary opens many pages and needs one browser; every [`Page`] is an isolated target in
230/// a browser context of its own, so sharing the process shares nothing a page can observe. The
231/// browser lives until the process exits, and Chrome exits when its pipe closes.
232pub fn shared(chrome: &Path) -> Result<&'static Browser, BrowserError> {
233    static SHARED: OnceLock<Result<Browser, String>> = OnceLock::new();
234    match SHARED.get_or_init(|| Browser::launch(chrome).map_err(|e| e.to_string())) {
235        Ok(b) => Ok(b),
236        Err(e) => Err(BrowserError::Launch(e.clone())),
237    }
238}