browser_commander/playwright/
driver.rs1use std::collections::VecDeque;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex as StdMutex};
12use std::time::Duration;
13
14use command_stream::{quote::quote, ProcessRunner, RunOptions, StdinOption};
15use tokio::io::{AsyncBufReadExt, BufReader};
16use tokio::sync::Mutex;
17
18use super::connection::{Connection, ProtocolError};
19use super::protocol::{Playwright, PROTOCOL_VERSION};
20use crate::utilities::subprocess::kill_owned_process_tree;
21
22pub const DRIVER_ENV: &str = "BROWSER_COMMANDER_PLAYWRIGHT_DRIVER";
25
26const STDERR_LINES: usize = 200;
27const EXIT_GRACE: Duration = Duration::from_secs(5);
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct DriverLocation {
32 pub cli: PathBuf,
34 pub version: String,
36}
37
38impl DriverLocation {
39 pub fn resolve(working_dir: Option<&Path>) -> Result<Self, ProtocolError> {
45 if let Some(configured) = std::env::var_os(DRIVER_ENV) {
46 let configured = PathBuf::from(configured);
47 let cli = if configured.is_dir() {
48 configured.join("cli.js")
49 } else {
50 configured
51 };
52 return Self::at(&cli);
53 }
54
55 let start = working_dir
56 .map(Path::to_path_buf)
57 .or_else(|| std::env::current_dir().ok());
58 let mut candidates = Vec::new();
59 if let Some(start) = start {
60 for directory in start.ancestors() {
61 let modules = directory.join("node_modules");
62 candidates.push(modules.join("playwright-core/cli.js"));
63 candidates.push(modules.join("playwright/node_modules/playwright-core/cli.js"));
64 }
65 }
66 candidates.push(
67 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
68 .join("../js/node_modules/playwright-core/cli.js"),
69 );
70
71 let mut mismatches = Vec::new();
72 for cli in candidates.iter().filter(|cli| cli.is_file()) {
73 match Self::at(cli) {
74 Ok(location) => return Ok(location),
75 Err(err) => mismatches.push(err.to_string()),
76 }
77 }
78 Err(ProtocolError::Driver(if mismatches.is_empty() {
79 format!(
80 "playwright-core was not found; install it with npm or set {DRIVER_ENV} to its cli.js"
81 )
82 } else {
83 mismatches.join("; ")
84 }))
85 }
86
87 pub fn at(cli: &Path) -> Result<Self, ProtocolError> {
90 if !cli.is_file() {
91 return Err(ProtocolError::Driver(format!(
92 "{} does not exist",
93 cli.display()
94 )));
95 }
96 let manifest = cli.with_file_name("package.json");
97 let version = std::fs::read_to_string(&manifest)
98 .ok()
99 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
100 .and_then(|json| json["version"].as_str().map(str::to_owned))
101 .ok_or_else(|| {
102 ProtocolError::Driver(format!("cannot read the version in {}", manifest.display()))
103 })?;
104 if !same_minor(&version, PROTOCOL_VERSION) {
105 return Err(ProtocolError::Driver(format!(
106 "{} is playwright-core {version}, but the bindings were generated for {PROTOCOL_VERSION}",
107 cli.display()
108 )));
109 }
110 Ok(Self {
111 cli: cli.to_path_buf(),
112 version,
113 })
114 }
115}
116
117fn same_minor(left: &str, right: &str) -> bool {
118 let minor = |version: &str| version.split('.').take(2).collect::<Vec<_>>().join(".");
119 minor(left) == minor(right)
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct DriverOptions {
125 pub node: Option<PathBuf>,
127 pub working_dir: Option<PathBuf>,
129 pub verbose: bool,
131}
132
133pub struct PlaywrightDriver {
135 connection: Connection,
136 playwright: Playwright,
137 location: DriverLocation,
138 runner: Mutex<Option<ProcessRunner>>,
139 pid: Option<u32>,
140 stderr: Arc<StdMutex<VecDeque<String>>>,
141}
142
143impl std::fmt::Debug for PlaywrightDriver {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("PlaywrightDriver")
146 .field("location", &self.location)
147 .field("pid", &self.pid)
148 .finish()
149 }
150}
151
152impl PlaywrightDriver {
153 pub async fn launch(options: DriverOptions) -> Result<Self, ProtocolError> {
155 let location = DriverLocation::resolve(options.working_dir.as_deref())?;
156 Self::launch_at(location, options).await
157 }
158
159 pub async fn launch_at(
161 location: DriverLocation,
162 options: DriverOptions,
163 ) -> Result<Self, ProtocolError> {
164 let node = options
165 .node
166 .clone()
167 .or_else(|| std::env::var_os("BROWSER_COMMANDER_NODE").map(PathBuf::from))
168 .unwrap_or_else(|| PathBuf::from("node"));
169 let node = node.to_string_lossy().into_owned();
170 let cli = location.cli.to_string_lossy().into_owned();
171 let command = [node.as_str(), cli.as_str(), "run-driver"]
172 .into_iter()
173 .map(quote)
174 .collect::<Vec<_>>()
175 .join(" ");
176
177 let mut runner = ProcessRunner::new(
178 command,
179 RunOptions {
180 mirror: false,
181 capture: true,
182 stdin: StdinOption::Pipe,
183 cwd: options.working_dir.clone(),
184 shell_operators: false,
185 trace: false,
186 ..RunOptions::default()
187 },
188 );
189 runner
190 .start()
191 .await
192 .map_err(|err| ProtocolError::Driver(format!("failed to start {node}: {err}")))?;
193 let pid = runner.pid();
194 let (stdin, stdout, stderr) = {
195 let mut child = runner.child().ok_or_else(|| {
196 ProtocolError::Driver("the driver process did not start".to_string())
197 })?;
198 let native = child.native_mut();
199 (
200 native.stdin.take(),
201 native.stdout.take(),
202 native.stderr.take(),
203 )
204 };
205 let (Some(stdin), Some(stdout)) = (stdin, stdout) else {
206 if let Some(pid) = pid {
207 kill_owned_process_tree(pid);
208 }
209 return Err(ProtocolError::Driver(
210 "the driver's stdin and stdout were not piped".to_string(),
211 ));
212 };
213
214 let stderr_lines = Arc::new(StdMutex::new(VecDeque::new()));
215 if let Some(stderr) = stderr {
216 let lines = Arc::clone(&stderr_lines);
217 let verbose = options.verbose;
218 tokio::spawn(async move {
219 let mut reader = BufReader::new(stderr).lines();
220 while let Ok(Some(line)) = reader.next_line().await {
221 if verbose {
222 eprintln!("[playwright driver] {line}");
223 }
224 tracing::debug!(target: "browser_commander::playwright_driver", "{line}");
225 if let Ok(mut lines) = lines.lock() {
226 if lines.len() == STDERR_LINES {
227 lines.pop_front();
228 }
229 lines.push_back(line);
230 }
231 }
232 });
233 }
234
235 let connection = Connection::new(stdout, stdin);
236 let playwright = match connection.initialize().await {
237 Ok(playwright) => playwright,
238 Err(err) => {
239 if let Some(pid) = pid {
240 kill_owned_process_tree(pid);
241 }
242 let log = stderr_lines
243 .lock()
244 .map(|lines| lines.iter().cloned().collect::<Vec<_>>().join("\n"))
245 .unwrap_or_default();
246 return Err(ProtocolError::Driver(format!(
247 "the driver did not initialize: {err}{}",
248 if log.is_empty() {
249 String::new()
250 } else {
251 format!("\n{log}")
252 }
253 )));
254 }
255 };
256
257 Ok(Self {
258 connection,
259 playwright,
260 location,
261 runner: Mutex::new(Some(runner)),
262 pid,
263 stderr: stderr_lines,
264 })
265 }
266
267 pub fn connection(&self) -> &Connection {
269 &self.connection
270 }
271
272 pub fn playwright(&self) -> &Playwright {
274 &self.playwright
275 }
276
277 pub fn location(&self) -> &DriverLocation {
279 &self.location
280 }
281
282 pub fn pid(&self) -> Option<u32> {
284 self.pid
285 }
286
287 pub fn stderr_tail(&self) -> Vec<String> {
289 self.stderr
290 .lock()
291 .map(|lines| lines.iter().cloned().collect())
292 .unwrap_or_default()
293 }
294
295 pub async fn close(&self) {
298 self.connection.close_input().await;
299 let Some(mut runner) = self.runner.lock().await.take() else {
300 return;
301 };
302 let deadline = tokio::time::Instant::now() + EXIT_GRACE;
303 loop {
304 let exited = runner
305 .child()
306 .map(|mut child| matches!(child.native_mut().try_wait(), Ok(Some(_))))
307 .unwrap_or(true);
308 if exited || tokio::time::Instant::now() >= deadline {
309 break;
310 }
311 tokio::time::sleep(Duration::from_millis(50)).await;
312 }
313 if let Some(pid) = self.pid {
314 kill_owned_process_tree(pid);
315 }
316 }
317}
318
319impl Drop for PlaywrightDriver {
320 fn drop(&mut self) {
321 let still_owned = self
322 .runner
323 .try_lock()
324 .map(|runner| runner.is_some())
325 .unwrap_or(true);
326 if still_owned {
327 if let Some(pid) = self.pid {
328 kill_owned_process_tree(pid);
329 }
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn versions_match_on_major_and_minor() {
340 assert!(same_minor("1.62.1", "1.62.0"));
341 assert!(same_minor("1.62.1-beta", "1.62.1"));
342 assert!(!same_minor("1.61.9", "1.62.1"));
343 }
344
345 #[test]
346 fn a_driver_with_another_protocol_version_is_rejected() {
347 let directory = std::env::temp_dir().join(format!(
348 "browser-commander-driver-version-{}",
349 std::process::id()
350 ));
351 std::fs::create_dir_all(&directory).unwrap();
352 let cli = directory.join("cli.js");
353 std::fs::write(&cli, "").unwrap();
354 std::fs::write(
355 directory.join("package.json"),
356 r#"{"name":"playwright-core","version":"0.1.0"}"#,
357 )
358 .unwrap();
359 let error = DriverLocation::at(&cli).unwrap_err().to_string();
360 assert!(error.contains("0.1.0"), "{error}");
361
362 std::fs::write(
363 directory.join("package.json"),
364 format!(r#"{{"name":"playwright-core","version":"{PROTOCOL_VERSION}"}}"#),
365 )
366 .unwrap();
367 assert_eq!(DriverLocation::at(&cli).unwrap().version, PROTOCOL_VERSION);
368 let _ = std::fs::remove_dir_all(&directory);
369 }
370}