playwright_rs/protocol/browser.rs
1// Browser protocol object
2//
3// Represents a browser instance created by BrowserType.launch()
4
5use crate::error::Result;
6use crate::protocol::{BrowserContext, BrowserType, Page};
7use crate::server::channel::Channel;
8use crate::server::channel_owner::{ChannelOwner, ChannelOwnerImpl, ParentOrConnection};
9use crate::server::connection::ConnectionExt;
10use serde::Deserialize;
11use serde_json::Value;
12use std::any::Any;
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16
17use std::sync::Mutex;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20/// Type alias for the future returned by a disconnected handler.
21type DisconnectedHandlerFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
22
23/// Type alias for a registered disconnected event handler.
24type DisconnectedHandler = Arc<dyn Fn() -> DisconnectedHandlerFuture + Send + Sync>;
25
26/// Type alias for the future returned by a context handler.
27type ContextHandlerFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
28
29/// Type alias for a registered `context` event handler.
30type ContextHandler = Arc<dyn Fn(BrowserContext) -> ContextHandlerFuture + Send + Sync>;
31
32/// Options for `Browser::bind()`.
33///
34/// See: <https://playwright.dev/docs/api/class-browser#browser-bind>
35#[derive(Debug, Default, Clone, serde::Serialize)]
36#[serde(rename_all = "camelCase")]
37#[non_exhaustive]
38pub struct BindOptions {
39 /// Working directory for the server, used by CLI tooling and MCP clients.
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub workspace_dir: Option<String>,
42 /// Arbitrary JSON metadata the server attaches to the bound session.
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub metadata: Option<serde_json::Value>,
45 /// Host to listen on (e.g. `"127.0.0.1"`). When unset and `port` is also
46 /// unset, the server listens on a local pipe rather than a TCP port.
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub host: Option<String>,
49 /// Port to listen on. Pass `0` to request an OS-assigned port.
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub port: Option<u16>,
52}
53
54impl BindOptions {
55 /// Workspace directory the bound browser should use.
56 pub fn workspace_dir(mut self, workspace_dir: impl Into<String>) -> Self {
57 self.workspace_dir = Some(workspace_dir.into());
58 self
59 }
60 /// Arbitrary metadata attached to the binding.
61 pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
62 self.metadata = Some(metadata);
63 self
64 }
65 /// Host to bind on.
66 pub fn host(mut self, host: impl Into<String>) -> Self {
67 self.host = Some(host.into());
68 self
69 }
70 /// Port to bind on (0 picks a free port).
71 pub fn port(mut self, port: u16) -> Self {
72 self.port = Some(port);
73 self
74 }
75}
76
77/// Result of `Browser::bind()` — the endpoint other clients can connect to.
78#[derive(Debug, Clone, Deserialize)]
79#[non_exhaustive]
80pub struct BindResult {
81 /// WebSocket URL (e.g. `"ws://127.0.0.1:PORT/GUID"`) or pipe endpoint
82 /// that an MCP client, `playwright-cli`, or third-party agent tool can
83 /// attach to with `BrowserType::connect()`.
84 pub endpoint: String,
85}
86
87/// Options for `Browser::start_tracing()`.
88///
89/// See: <https://playwright.dev/docs/api/class-browser#browser-start-tracing>
90#[derive(Debug, Default, Clone)]
91#[non_exhaustive]
92pub struct StartTracingOptions {
93 /// If specified, tracing captures screenshots for this page.
94 /// Pass `Some(page)` to associate the trace with a specific page.
95 pub page: Option<Page>,
96 /// Whether to capture screenshots during tracing. Default false.
97 pub screenshots: Option<bool>,
98 /// Trace categories to enable. If omitted, uses a default set.
99 pub categories: Option<Vec<String>>,
100}
101
102impl StartTracingOptions {
103 /// Page whose tracing should be captured.
104 pub fn page(mut self, page: Page) -> Self {
105 self.page = Some(page);
106 self
107 }
108 /// Capture screenshots in the trace.
109 pub fn screenshots(mut self, screenshots: bool) -> Self {
110 self.screenshots = Some(screenshots);
111 self
112 }
113 /// Chromium tracing categories to include.
114 pub fn categories(mut self, categories: Vec<String>) -> Self {
115 self.categories = Some(categories);
116 self
117 }
118}
119
120/// Browser represents a browser instance.
121///
122/// A Browser is created when you call `BrowserType::launch()`. It provides methods
123/// to create browser contexts and pages.
124///
125/// # Runtime binding
126///
127/// A `Browser` (and every protocol object descended from it — `BrowserContext`,
128/// `Page`, `Frame`, `Locator`, …) is **bound to the tokio runtime that
129/// launched it**. The underlying JSON-RPC channels are owned by that
130/// runtime; using a `Browser` from a different runtime silently deadlocks
131/// because the channels can't deliver responses back.
132///
133/// In particular, **do not share a `Browser` across `#[tokio::test]`
134/// invocations** via `OnceCell<Browser>` or similar caching — each
135/// `#[tokio::test]` spins up a fresh runtime that exits when the test
136/// returns, leaving any cached `Browser` pointing at dead channels.
137/// Launch a fresh `Playwright` + `Browser` per test.
138///
139/// Debug builds (`cfg(debug_assertions)`) panic with a clear message
140/// when a cross-runtime use is detected on the wire path. Release
141/// builds skip the check.
142///
143/// # Example
144///
145/// ```no_run
146/// use playwright_rs::protocol::Playwright;
147///
148/// #[tokio::main]
149/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
150/// let playwright = Playwright::launch().await?;
151/// let chromium = playwright.chromium();
152///
153/// let browser = chromium.launch().await?;
154/// println!("Browser: {} version {}", browser.name(), browser.version());
155/// assert!(browser.is_connected());
156///
157/// let bt = browser.browser_type();
158/// assert_eq!(bt.name(), "chromium");
159///
160/// let context = browser.new_context().await?;
161/// let _page = context.new_page().await?;
162/// assert_eq!(browser.contexts().len(), 1);
163///
164/// browser.on_disconnected(|| async { Ok(()) }).await?;
165///
166/// browser.start_tracing(None).await?;
167/// let _trace_bytes = browser.stop_tracing().await?;
168///
169/// browser.close().await?;
170/// Ok(())
171/// }
172/// ```
173///
174/// See: <https://playwright.dev/docs/api/class-browser>
175#[derive(Clone)]
176pub struct Browser {
177 base: ChannelOwnerImpl,
178 version: String,
179 name: String,
180 is_connected: Arc<AtomicBool>,
181 /// Registered handlers for the "disconnected" event.
182 disconnected_handlers: Arc<Mutex<Vec<DisconnectedHandler>>>,
183 /// Registered handlers for the "context" event (new context created).
184 context_handlers: Arc<Mutex<Vec<ContextHandler>>>,
185}
186
187impl Browser {
188 /// Creates a new Browser from protocol initialization
189 ///
190 /// This is called by the object factory when the server sends a `__create__` message
191 /// for a Browser object.
192 ///
193 /// # Arguments
194 ///
195 /// * `parent` - The parent BrowserType object
196 /// * `type_name` - The protocol type name ("Browser")
197 /// * `guid` - The unique identifier for this browser instance
198 /// * `initializer` - The initialization data from the server
199 ///
200 /// # Errors
201 ///
202 /// Returns error if initializer is missing required fields (version, name)
203 pub fn new(
204 parent: Arc<dyn ChannelOwner>,
205 type_name: String,
206 guid: Arc<str>,
207 initializer: Value,
208 ) -> Result<Self> {
209 let base = ChannelOwnerImpl::new(
210 ParentOrConnection::Parent(parent),
211 type_name,
212 guid,
213 initializer.clone(),
214 );
215
216 let version = initializer["version"]
217 .as_str()
218 .ok_or_else(|| {
219 crate::error::Error::ProtocolError(
220 "Browser initializer missing 'version' field".to_string(),
221 )
222 })?
223 .to_string();
224
225 let name = initializer["name"]
226 .as_str()
227 .ok_or_else(|| {
228 crate::error::Error::ProtocolError(
229 "Browser initializer missing 'name' field".to_string(),
230 )
231 })?
232 .to_string();
233
234 Ok(Self {
235 base,
236 version,
237 name,
238 is_connected: Arc::new(AtomicBool::new(true)),
239 disconnected_handlers: Arc::new(Mutex::new(Vec::new())),
240 context_handlers: Arc::new(Mutex::new(Vec::new())),
241 })
242 }
243
244 /// Returns the browser version string.
245 ///
246 /// See: <https://playwright.dev/docs/api/class-browser#browser-version>
247 pub fn version(&self) -> &str {
248 &self.version
249 }
250
251 /// Returns the browser name (e.g., "chromium", "firefox", "webkit").
252 ///
253 /// See: <https://playwright.dev/docs/api/class-browser#browser-name>
254 pub fn name(&self) -> &str {
255 &self.name
256 }
257
258 /// Returns true if the browser is connected.
259 ///
260 /// The browser is connected when it is launched and becomes disconnected when:
261 /// - `browser.close()` is called
262 /// - The browser process crashes
263 /// - The browser is closed by the user
264 ///
265 /// See: <https://playwright.dev/docs/api/class-browser#browser-is-connected>
266 pub fn is_connected(&self) -> bool {
267 self.is_connected.load(Ordering::SeqCst)
268 }
269
270 /// Returns the channel for sending protocol messages
271 ///
272 /// Used internally for sending RPC calls to the browser.
273 fn channel(&self) -> &Channel {
274 self.base.channel()
275 }
276
277 /// Creates a new browser context.
278 ///
279 /// A browser context is an isolated session within the browser instance,
280 /// similar to an incognito profile. Each context has its own cookies,
281 /// cache, and local storage.
282 ///
283 /// # Errors
284 ///
285 /// Returns error if:
286 /// - Browser has been closed
287 /// - Communication with browser process fails
288 ///
289 /// See: <https://playwright.dev/docs/api/class-browser#browser-new-context>
290 #[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
291 pub async fn new_context(&self) -> Result<BrowserContext> {
292 #[derive(Deserialize)]
293 struct NewContextResponse {
294 context: GuidRef,
295 }
296
297 #[derive(Deserialize)]
298 struct GuidRef {
299 #[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
300 guid: Arc<str>,
301 }
302
303 let response: NewContextResponse = self
304 .channel()
305 .send("newContext", serde_json::json!({}))
306 .await?;
307
308 let context: BrowserContext = self
309 .connection()
310 .get_typed::<BrowserContext>(&response.context.guid)
311 .await?;
312
313 let selectors = self.connection().selectors();
314 if let Err(e) = selectors.add_context(context.channel().clone()).await {
315 tracing::warn!("Failed to register BrowserContext with Selectors: {}", e);
316 }
317
318 Ok(context)
319 }
320
321 /// Creates a new browser context with custom options.
322 ///
323 /// A browser context is an isolated session within the browser instance,
324 /// similar to an incognito profile. Each context has its own cookies,
325 /// cache, and local storage.
326 ///
327 /// This method allows customizing viewport, user agent, locale, timezone,
328 /// and other settings.
329 ///
330 /// # Errors
331 ///
332 /// Returns error if:
333 /// - Browser has been closed
334 /// - Communication with browser process fails
335 /// - Invalid options provided
336 /// - Storage state file cannot be read or parsed
337 ///
338 /// See: <https://playwright.dev/docs/api/class-browser#browser-new-context>
339 #[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
340 pub async fn new_context_with_options(
341 &self,
342 mut options: crate::protocol::BrowserContextOptions,
343 ) -> Result<BrowserContext> {
344 // Response contains the GUID of the created BrowserContext
345 #[derive(Deserialize)]
346 struct NewContextResponse {
347 context: GuidRef,
348 }
349
350 #[derive(Deserialize)]
351 struct GuidRef {
352 #[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
353 guid: Arc<str>,
354 }
355
356 // Handle storage_state_path: read file and convert to inline storage_state
357 if let Some(path) = &options.storage_state_path {
358 let file_content = tokio::fs::read_to_string(path).await.map_err(|e| {
359 crate::error::Error::ProtocolError(format!(
360 "Failed to read storage state file '{}': {}",
361 path, e
362 ))
363 })?;
364
365 let storage_state: crate::protocol::StorageState = serde_json::from_str(&file_content)
366 .map_err(|e| {
367 crate::error::Error::ProtocolError(format!(
368 "Failed to parse storage state file '{}': {}",
369 path, e
370 ))
371 })?;
372
373 options.storage_state = Some(storage_state);
374 options.storage_state_path = None; // Clear path since we've converted to inline
375 }
376
377 // Convert options to JSON
378 let options_json = serde_json::to_value(options).map_err(|e| {
379 crate::error::Error::ProtocolError(format!(
380 "Failed to serialize context options: {}",
381 e
382 ))
383 })?;
384
385 // Send newContext RPC to server with options
386 let response: NewContextResponse = self.channel().send("newContext", options_json).await?;
387
388 // Retrieve and downcast the BrowserContext object from the connection registry
389 let context: BrowserContext = self
390 .connection()
391 .get_typed::<BrowserContext>(&response.context.guid)
392 .await?;
393
394 // Register new context with the Selectors coordinator.
395 let selectors = self.connection().selectors();
396 if let Err(e) = selectors.add_context(context.channel().clone()).await {
397 tracing::warn!("Failed to register BrowserContext with Selectors: {}", e);
398 }
399
400 Ok(context)
401 }
402
403 /// Creates a new page in a new browser context.
404 ///
405 /// This is a convenience method that creates a default context and then
406 /// creates a page in it. This is equivalent to calling `browser.new_context().await?.new_page().await?`.
407 ///
408 /// The created context is not directly accessible, but will be cleaned up
409 /// when the page is closed.
410 ///
411 /// # Errors
412 ///
413 /// Returns error if:
414 /// - Browser has been closed
415 /// - Communication with browser process fails
416 ///
417 /// See: <https://playwright.dev/docs/api/class-browser#browser-new-page>
418 #[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
419 pub async fn new_page(&self) -> Result<Page> {
420 // Create a default context and then create a page in it
421 let context = self.new_context().await?;
422 context.new_page().await
423 }
424
425 /// Returns all open browser contexts.
426 ///
427 /// A new browser starts with no contexts. Contexts are created via
428 /// `new_context()` and cleaned up when they are closed.
429 ///
430 /// See: <https://playwright.dev/docs/api/class-browser#browser-contexts>
431 pub fn contexts(&self) -> Vec<BrowserContext> {
432 let my_guid = self.guid();
433 self.connection()
434 .all_objects_sync()
435 .into_iter()
436 .filter_map(|obj| {
437 let ctx = obj.as_any().downcast_ref::<BrowserContext>()?.clone();
438 let parent_guid = ctx.parent().map(|p| p.guid().to_string());
439 if parent_guid.as_deref() == Some(my_guid) {
440 Some(ctx)
441 } else {
442 None
443 }
444 })
445 .collect()
446 }
447
448 /// Returns the `BrowserType` that was used to launch this browser.
449 ///
450 /// See: <https://playwright.dev/docs/api/class-browser#browser-browser-type>
451 pub fn browser_type(&self) -> BrowserType {
452 self.base
453 .parent()
454 .expect("Browser always has a BrowserType parent")
455 .as_any()
456 .downcast_ref::<BrowserType>()
457 .expect("Browser parent is always a BrowserType")
458 .clone()
459 }
460
461 /// Registers a handler that fires when the browser is disconnected.
462 ///
463 /// The browser can become disconnected when it is closed, crashes, or
464 /// the process is killed. The handler is called with no arguments.
465 ///
466 /// # Arguments
467 ///
468 /// * `handler` - Async closure called when the browser disconnects.
469 ///
470 /// # Errors
471 ///
472 /// Returns an error only if the mutex is poisoned (practically never).
473 ///
474 /// Creates a browser-level Chrome DevTools Protocol session.
475 ///
476 /// Unlike [`BrowserContext::new_cdp_session`](crate::protocol::BrowserContext::new_cdp_session)
477 /// which is scoped to a page, this session is attached to the browser itself.
478 /// Chromium only.
479 ///
480 /// See: <https://playwright.dev/docs/api/class-browser#browser-new-browser-cdp-session>
481 #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
482 pub async fn new_browser_cdp_session(&self) -> Result<crate::protocol::CDPSession> {
483 #[derive(Deserialize)]
484 struct Response {
485 session: GuidRef,
486 }
487 #[derive(Deserialize)]
488 struct GuidRef {
489 #[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
490 guid: Arc<str>,
491 }
492
493 let response: Response = self
494 .channel()
495 .send("newBrowserCDPSession", serde_json::json!({}))
496 .await?;
497
498 self.connection()
499 .get_typed::<crate::protocol::CDPSession>(&response.session.guid)
500 .await
501 }
502
503 /// See: <https://playwright.dev/docs/api/class-browser#browser-event-disconnected>
504 #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
505 pub async fn on_disconnected<F, Fut>(&self, handler: F) -> Result<()>
506 where
507 F: Fn() -> Fut + Send + Sync + 'static,
508 Fut: Future<Output = Result<()>> + Send + 'static,
509 {
510 let handler = Arc::new(move || -> DisconnectedHandlerFuture { Box::pin(handler()) });
511 self.disconnected_handlers.lock().unwrap().push(handler);
512 Ok(())
513 }
514
515 /// Adds a listener for the `context` event, fired when a new browser
516 /// context is created on this browser (including via
517 /// [`new_context`](Self::new_context)). Lets framework code observe context
518 /// creation without threading the `new_context()` return value through.
519 ///
520 /// See: <https://playwright.dev/docs/api/class-browser#browser-event-context>
521 #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
522 pub async fn on_context<F, Fut>(&self, handler: F) -> Result<()>
523 where
524 F: Fn(BrowserContext) -> Fut + Send + Sync + 'static,
525 Fut: Future<Output = Result<()>> + Send + 'static,
526 {
527 let handler =
528 Arc::new(move |ctx: BrowserContext| -> ContextHandlerFuture { Box::pin(handler(ctx)) });
529 self.context_handlers.lock().unwrap().push(handler);
530 Ok(())
531 }
532
533 /// Exposes this browser over a local WebSocket or pipe endpoint so external
534 /// clients (Playwright CLI, `@playwright/mcp`, other agent tooling) can
535 /// attach to it.
536 ///
537 /// The returned [`BindResult::endpoint`] is a connect string consumable by
538 /// `BrowserType::connect()` from any Playwright language binding.
539 ///
540 /// # Arguments
541 ///
542 /// * `title` — human-readable label for the session (shown in dashboards).
543 /// * `options` — optional host/port, workspace directory, or metadata.
544 /// Pass `None` to listen on a local pipe.
545 ///
546 /// # Errors
547 ///
548 /// Returns error if a server is already bound to this browser, or if the
549 /// requested host/port is unavailable.
550 ///
551 /// See: <https://playwright.dev/docs/api/class-browser#browser-bind>
552 #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name, title = %title))]
553 pub async fn bind(&self, title: &str, options: Option<BindOptions>) -> Result<BindResult> {
554 let mut params = serde_json::to_value(options.unwrap_or_default())
555 .unwrap_or_else(|_| serde_json::json!({}));
556 params["title"] = serde_json::json!(title);
557 let result: BindResult = self.channel().send("startServer", params).await?;
558 Ok(result)
559 }
560
561 /// Stops the server previously started by [`Self::bind`], disconnecting
562 /// any clients attached to it.
563 ///
564 /// Calling `unbind()` when no server is bound is a no-op.
565 ///
566 /// See: <https://playwright.dev/docs/api/class-browser#browser-unbind>
567 #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
568 pub async fn unbind(&self) -> Result<()> {
569 self.channel()
570 .send_no_result("stopServer", serde_json::json!({}))
571 .await
572 }
573
574 /// Starts CDP tracing on this browser (Chromium only).
575 ///
576 /// Only one trace may be active at a time per browser instance.
577 ///
578 /// # Arguments
579 ///
580 /// * `options` - Optional tracing configuration (screenshots, categories, page).
581 ///
582 /// # Errors
583 ///
584 /// Returns error if:
585 /// - Tracing is already active
586 /// - Called on a non-Chromium browser
587 /// - Communication with the browser fails
588 ///
589 /// See: <https://playwright.dev/docs/api/class-browser#browser-start-tracing>
590 #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
591 pub async fn start_tracing(&self, options: Option<StartTracingOptions>) -> Result<()> {
592 #[derive(serde::Serialize)]
593 struct StartTracingParams {
594 #[serde(skip_serializing_if = "Option::is_none")]
595 page: Option<serde_json::Value>,
596 #[serde(skip_serializing_if = "Option::is_none")]
597 screenshots: Option<bool>,
598 #[serde(skip_serializing_if = "Option::is_none")]
599 categories: Option<Vec<String>>,
600 }
601
602 let opts = options.unwrap_or_default();
603
604 let page_ref = opts
605 .page
606 .as_ref()
607 .map(|p| serde_json::json!({ "guid": p.guid() }));
608
609 let params = StartTracingParams {
610 page: page_ref,
611 screenshots: opts.screenshots,
612 categories: opts.categories,
613 };
614
615 self.channel()
616 .send_no_result(
617 "startTracing",
618 serde_json::to_value(params).map_err(|e| {
619 crate::error::Error::ProtocolError(format!(
620 "serialize startTracing params: {e}"
621 ))
622 })?,
623 )
624 .await
625 }
626
627 /// Stops CDP tracing and returns the raw trace data.
628 ///
629 /// The returned bytes can be written to a `.json` file and loaded in
630 /// `chrome://tracing` or [Perfetto](https://ui.perfetto.dev).
631 ///
632 /// # Errors
633 ///
634 /// Returns error if no tracing was started or communication fails.
635 ///
636 /// See: <https://playwright.dev/docs/api/class-browser#browser-stop-tracing>
637 #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name, bytes_len = tracing::field::Empty))]
638 pub async fn stop_tracing(&self) -> Result<Vec<u8>> {
639 #[derive(Deserialize)]
640 struct StopTracingResponse {
641 artifact: ArtifactRef,
642 }
643
644 #[derive(Deserialize)]
645 struct ArtifactRef {
646 #[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
647 guid: Arc<str>,
648 }
649
650 let response: StopTracingResponse = self
651 .channel()
652 .send("stopTracing", serde_json::json!({}))
653 .await?;
654
655 // save_as() rather than streaming because Stream protocol is not yet implemented
656 let artifact: crate::protocol::artifact::Artifact = self
657 .connection()
658 .get_typed::<crate::protocol::artifact::Artifact>(&response.artifact.guid)
659 .await?;
660
661 let tmp_path = std::env::temp_dir().join(format!(
662 "playwright-trace-{}.json",
663 response.artifact.guid.replace('@', "-")
664 ));
665 let tmp_str = tmp_path
666 .to_str()
667 .ok_or_else(|| {
668 crate::error::Error::ProtocolError(
669 "Temporary path contains non-UTF-8 characters".to_string(),
670 )
671 })?
672 .to_string();
673
674 artifact.save_as(&tmp_str).await?;
675
676 let bytes = tokio::fs::read(&tmp_path).await.map_err(|e| {
677 crate::error::Error::ProtocolError(format!(
678 "Failed to read tracing artifact from '{}': {}",
679 tmp_str, e
680 ))
681 })?;
682
683 let _ = tokio::fs::remove_file(&tmp_path).await;
684
685 tracing::Span::current().record("bytes_len", bytes.len());
686 Ok(bytes)
687 }
688
689 /// Closes the browser and all of its pages (if any were opened).
690 ///
691 /// This is a graceful operation that sends a close command to the browser
692 /// and waits for it to shut down properly.
693 ///
694 /// # Errors
695 ///
696 /// Returns error if:
697 /// - Browser has already been closed
698 /// - Communication with browser process fails
699 ///
700 /// See: <https://playwright.dev/docs/api/class-browser#browser-close>
701 #[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
702 pub async fn close(&self) -> Result<()> {
703 // Send close RPC to server
704 // The protocol expects an empty object as params
705 let result = self
706 .channel()
707 .send_no_result("close", serde_json::json!({}))
708 .await;
709
710 // Add delay on Windows CI to ensure browser process fully terminates
711 // This prevents subsequent browser launches from hanging
712 #[cfg(windows)]
713 {
714 let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok();
715 if is_ci {
716 tracing::debug!("[playwright-rust] Adding Windows CI browser cleanup delay");
717 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
718 }
719 }
720
721 result
722 }
723}
724
725impl ChannelOwner for Browser {
726 fn guid(&self) -> &str {
727 self.base.guid()
728 }
729
730 fn type_name(&self) -> &str {
731 self.base.type_name()
732 }
733
734 fn parent(&self) -> Option<Arc<dyn ChannelOwner>> {
735 self.base.parent()
736 }
737
738 fn connection(&self) -> Arc<dyn crate::server::connection::ConnectionLike> {
739 self.base.connection()
740 }
741
742 fn initializer(&self) -> &Value {
743 self.base.initializer()
744 }
745
746 fn channel(&self) -> &Channel {
747 self.base.channel()
748 }
749
750 fn dispose(&self, reason: crate::server::channel_owner::DisposeReason) {
751 // Use compare_exchange so handlers fire exactly once across both the
752 // "disconnected" event path and the __dispose__ path.
753 if self
754 .is_connected
755 .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
756 .is_ok()
757 {
758 let handlers = self.disconnected_handlers.lock().unwrap().clone();
759 tokio::spawn(async move {
760 for handler in handlers {
761 if let Err(e) = handler().await {
762 tracing::warn!("Browser disconnected handler error (from dispose): {}", e);
763 }
764 }
765 });
766 }
767 self.base.dispose(reason)
768 }
769
770 fn adopt(&self, child: Arc<dyn ChannelOwner>) {
771 self.base.adopt(child)
772 }
773
774 fn add_child(&self, guid: Arc<str>, child: Arc<dyn ChannelOwner>) {
775 self.base.add_child(guid, child)
776 }
777
778 fn remove_child(&self, guid: &str) {
779 self.base.remove_child(guid)
780 }
781
782 fn on_event(&self, method: &str, params: Value) {
783 if method == "disconnected" {
784 // Use compare_exchange to fire handlers exactly once (guards against
785 // both the "disconnected" event and the __dispose__ path firing them).
786 if self
787 .is_connected
788 .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
789 .is_ok()
790 {
791 let handlers = self.disconnected_handlers.lock().unwrap().clone();
792 tokio::spawn(async move {
793 for handler in handlers {
794 if let Err(e) = handler().await {
795 tracing::warn!("Browser disconnected handler error: {}", e);
796 }
797 }
798 });
799 }
800 } else if method == "context" {
801 let handlers = self.context_handlers.lock().unwrap().clone();
802 if !handlers.is_empty()
803 && let Some(guid) = params
804 .get("context")
805 .and_then(|c| c.get("guid"))
806 .and_then(|g| g.as_str())
807 {
808 let guid = guid.to_string();
809 let connection = self.connection();
810 tokio::spawn(async move {
811 use crate::server::connection::ConnectionExt;
812 if let Ok(ctx) = connection.get_typed::<BrowserContext>(&guid).await {
813 for handler in handlers {
814 if let Err(e) = handler(ctx.clone()).await {
815 tracing::warn!("Browser context handler error: {}", e);
816 }
817 }
818 }
819 });
820 }
821 }
822 self.base.on_event(method, params)
823 }
824
825 fn was_collected(&self) -> bool {
826 self.base.was_collected()
827 }
828
829 fn as_any(&self) -> &dyn Any {
830 self
831 }
832}
833
834impl std::fmt::Debug for Browser {
835 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
836 f.debug_struct("Browser")
837 .field("guid", &self.guid())
838 .field("name", &self.name)
839 .field("version", &self.version)
840 .finish()
841 }
842}
843
844// Note: Browser is exercised by integration tests rather than unit tests,
845// because it requires a real Connection with an object registry, protocol
846// messages from the server, and BrowserType::launch() to create the object.
847// See crates/playwright/tests/integration/browser.rs.