Skip to main content

browser_commander/playwright/
driver.rs

1//! Locating and starting the official Playwright driver.
2//!
3//! Every non-JavaScript Playwright binding talks to the same process:
4//! `playwright-core`'s `cli.js run-driver`, which speaks the protocol in
5//! [`crate::playwright::protocol`] over its stdin and stdout. The driver is
6//! started through command-stream, like the other long-running processes the
7//! Rust crate owns.
8
9use 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
22/// Environment variable naming the driver: `playwright-core`'s `cli.js`, or
23/// the `playwright-core` package directory.
24pub const DRIVER_ENV: &str = "BROWSER_COMMANDER_PLAYWRIGHT_DRIVER";
25
26const STDERR_LINES: usize = 200;
27const EXIT_GRACE: Duration = Duration::from_secs(5);
28
29/// Where a usable driver lives.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct DriverLocation {
32    /// `playwright-core/cli.js`.
33    pub cli: PathBuf,
34    /// The `playwright-core` version found next to it.
35    pub version: String,
36}
37
38impl DriverLocation {
39    /// Find a driver whose protocol matches the generated bindings.
40    ///
41    /// Looks at [`DRIVER_ENV`], then `node_modules/playwright-core` (directly
42    /// or under `playwright`) in `working_dir` and its ancestors, then the
43    /// npm package checked out next to this crate.
44    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    /// Check one `cli.js`: it must exist and belong to a `playwright-core`
88    /// release with the same major.minor version as [`PROTOCOL_VERSION`].
89    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/// Options for starting a driver.
123#[derive(Debug, Clone, Default)]
124pub struct DriverOptions {
125    /// Node.js executable. Defaults to `BROWSER_COMMANDER_NODE`, then `node`.
126    pub node: Option<PathBuf>,
127    /// Working directory, also the first place searched for `playwright-core`.
128    pub working_dir: Option<PathBuf>,
129    /// Echo the driver's stderr as it arrives.
130    pub verbose: bool,
131}
132
133/// A running `playwright run-driver` process and its connection.
134pub 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    /// Find the driver, start it and run the `initialize` handshake.
154    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    /// Start a driver that has already been located.
160    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    /// The protocol connection.
268    pub fn connection(&self) -> &Connection {
269        &self.connection
270    }
271
272    /// The `Playwright` root object (browser types, selectors, devices…).
273    pub fn playwright(&self) -> &Playwright {
274        &self.playwright
275    }
276
277    /// The driver that was started.
278    pub fn location(&self) -> &DriverLocation {
279        &self.location
280    }
281
282    /// Process id of the driver.
283    pub fn pid(&self) -> Option<u32> {
284        self.pid
285    }
286
287    /// The driver's most recent stderr lines.
288    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    /// Stop the driver: end its input, give it time to exit (it closes the
296    /// browsers it launched), then kill whatever is left of its process tree.
297    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}