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