browser_control/launch/
mod.rs1use std::path::PathBuf;
5
6use anyhow::Result;
7
8use crate::detect::{Engine, Installed};
9
10pub mod chromium;
11pub mod firefox;
12
13#[derive(Debug, Clone)]
14pub struct LaunchOpts {
15 pub headless: bool,
16 pub profile_dir: PathBuf,
17}
18
19#[derive(Debug)]
20pub struct LaunchedHandle {
21 pub pid: u32,
22 pub port: u16,
23 pub endpoint: String,
25 pub engine: Engine,
26 pub profile_dir: PathBuf,
27 pub(crate) child: Option<tokio::process::Child>,
31}
32
33impl LaunchedHandle {
34 pub fn forget(mut self) -> u32 {
38 let pid = self.pid;
39 if let Some(child) = self.child.take() {
40 drop(child);
43 }
44 pid
45 }
46
47 pub async fn kill(mut self) -> Result<()> {
49 if let Some(mut c) = self.child.take() {
50 let _ = c.kill().await;
51 }
52 Ok(())
53 }
54}
55
56pub fn allocate_free_port() -> Result<u16> {
58 let l = std::net::TcpListener::bind("127.0.0.1:0")?;
59 Ok(l.local_addr()?.port())
60}
61
62pub async fn launch(installed: &Installed, opts: LaunchOpts) -> Result<LaunchedHandle> {
64 if installed.kind.is_chromium() {
65 chromium::launch(installed, opts).await
66 } else {
67 firefox::launch(installed, opts).await
68 }
69}
70
71pub(crate) async fn wait_for_endpoint(
77 port: u16,
78 child: &mut tokio::process::Child,
79) -> Result<String> {
80 use anyhow::{bail, Context};
81 use std::time::Duration;
82 use tokio::io::AsyncReadExt;
83
84 let client = reqwest::Client::builder()
85 .timeout(Duration::from_millis(500))
86 .build()
87 .context("building reqwest client")?;
88 let url = format!("http://127.0.0.1:{port}/json/version");
89
90 let deadline = std::time::Instant::now() + Duration::from_secs(15);
91 loop {
92 if let Some(status) = child.try_wait().context("polling child status")? {
93 let mut stderr_buf = String::new();
94 if let Some(mut s) = child.stderr.take() {
95 let _ = s.read_to_string(&mut stderr_buf).await;
96 }
97 let mut stdout_buf = String::new();
98 if let Some(mut s) = child.stdout.take() {
99 let _ = s.read_to_string(&mut stdout_buf).await;
100 }
101 bail!(
102 "browser process exited before endpoint came up (status: {status}); \
103 stderr: {stderr_buf}; stdout: {stdout_buf}"
104 );
105 }
106
107 if let Ok(resp) = client.get(&url).send().await {
108 if resp.status().is_success() {
109 if let Ok(json) = resp.json::<serde_json::Value>().await {
110 if let Some(ws) = json.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
111 return Ok(ws.to_string());
112 }
113 }
114 }
115 }
116
117 if std::time::Instant::now() >= deadline {
118 let _ = child.start_kill();
120 bail!("timed out waiting for browser endpoint on port {port}");
121 }
122 tokio::time::sleep(Duration::from_millis(50)).await;
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::detect::{Engine, Installed, Kind};
130 use tempfile::TempDir;
131
132 fn build_fake_browser() -> std::path::PathBuf {
133 let status = std::process::Command::new(env!("CARGO"))
134 .args(["build", "--example", "fake_browser", "--quiet"])
135 .status()
136 .expect("invoke cargo build");
137 assert!(status.success(), "failed to build fake_browser example");
138 let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
139 p.push("target");
140 p.push("debug");
141 p.push("examples");
142 #[cfg(windows)]
143 p.push("fake_browser.exe");
144 #[cfg(not(windows))]
145 p.push("fake_browser");
146 assert!(
147 p.exists(),
148 "fake_browser binary not found at {}",
149 p.display()
150 );
151 p
152 }
153
154 #[tokio::test]
155 async fn allocate_free_port_returns_nonzero() {
156 let p = allocate_free_port().unwrap();
157 assert!(p > 0);
158 }
159
160 #[tokio::test]
161 async fn chromium_launch_against_fake() {
162 let exe = build_fake_browser();
163 let tmp = TempDir::new().unwrap();
164 let installed = Installed {
165 kind: Kind::Chrome,
166 executable: exe,
167 version: "fake".into(),
168 engine: Engine::Cdp,
169 };
170 let opts = LaunchOpts {
171 headless: true,
172 profile_dir: tmp.path().join("profile"),
173 };
174 let h = launch(&installed, opts).await.expect("launch chromium");
175 assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
176 assert!(h.port > 0);
177 assert_eq!(h.engine, Engine::Cdp);
178 h.kill().await.unwrap();
179 }
180
181 #[tokio::test]
182 async fn firefox_launch_against_fake() {
183 let exe = build_fake_browser();
184 let tmp = TempDir::new().unwrap();
185 let installed = Installed {
186 kind: Kind::Firefox,
187 executable: exe,
188 version: "fake".into(),
189 engine: Engine::Bidi,
190 };
191 let opts = LaunchOpts {
192 headless: true,
193 profile_dir: tmp.path().join("profile"),
194 };
195 let h = launch(&installed, opts).await.expect("launch firefox");
196 assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
197 assert!(h.port > 0);
198 assert_eq!(h.engine, Engine::Bidi);
199 h.kill().await.unwrap();
200 }
201
202 #[tokio::test]
203 async fn launch_fails_when_process_exits_immediately() {
204 #[cfg(unix)]
207 {
208 let tmp = TempDir::new().unwrap();
209 let installed = Installed {
210 kind: Kind::Chrome,
211 executable: std::path::PathBuf::from("/usr/bin/true"),
212 version: "fake".into(),
213 engine: Engine::Cdp,
214 };
215 let opts = LaunchOpts {
216 headless: true,
217 profile_dir: tmp.path().join("profile"),
218 };
219 let err = launch(&installed, opts).await.unwrap_err();
220 let msg = format!("{err:#}");
221 assert!(
222 msg.contains("exited") || msg.contains("timed out"),
223 "unexpected error: {msg}"
224 );
225 }
226 }
227}