Skip to main content

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.