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(
554        &self,
555        title: &str,
556        options: impl Into<Option<BindOptions>>,
557    ) -> Result<BindResult> {
558        let options = options.into();
559        let mut params = serde_json::to_value(options.unwrap_or_default())
560            .unwrap_or_else(|_| serde_json::json!({}));
561        params["title"] = serde_json::json!(title);
562        let result: BindResult = self.channel().send("startServer", params).await?;
563        Ok(result)
564    }
565
566    /// Stops the server previously started by [`Self::bind`], disconnecting
567    /// any clients attached to it.
568    ///
569    /// Calling `unbind()` when no server is bound is a no-op.
570    ///
571    /// See: <https://playwright.dev/docs/api/class-browser#browser-unbind>
572    #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
573    pub async fn unbind(&self) -> Result<()> {
574        self.channel()
575            .send_no_result("stopServer", serde_json::json!({}))
576            .await
577    }
578
579    /// Starts CDP tracing on this browser (Chromium only).
580    ///
581    /// Only one trace may be active at a time per browser instance.
582    ///
583    /// # Arguments
584    ///
585    /// * `options` - Optional tracing configuration (screenshots, categories, page).
586    ///
587    /// # Errors
588    ///
589    /// Returns error if:
590    /// - Tracing is already active
591    /// - Called on a non-Chromium browser
592    /// - Communication with the browser fails
593    ///
594    /// See: <https://playwright.dev/docs/api/class-browser#browser-start-tracing>
595    #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
596    pub async fn start_tracing(
597        &self,
598        options: impl Into<Option<StartTracingOptions>>,
599    ) -> Result<()> {
600        let options = options.into();
601        #[derive(serde::Serialize)]
602        struct StartTracingParams {
603            #[serde(skip_serializing_if = "Option::is_none")]
604            page: Option<serde_json::Value>,
605            #[serde(skip_serializing_if = "Option::is_none")]
606            screenshots: Option<bool>,
607            #[serde(skip_serializing_if = "Option::is_none")]
608            categories: Option<Vec<String>>,
609        }
610
611        let opts = options.unwrap_or_default();
612
613        let page_ref = opts
614            .page
615            .as_ref()
616            .map(|p| serde_json::json!({ "guid": p.guid() }));
617
618        let params = StartTracingParams {
619            page: page_ref,
620            screenshots: opts.screenshots,
621            categories: opts.categories,
622        };
623
624        self.channel()
625            .send_no_result(
626                "startTracing",
627                serde_json::to_value(params).map_err(|e| {
628                    crate::error::Error::ProtocolError(format!(
629                        "serialize startTracing params: {e}"
630                    ))
631                })?,
632            )
633            .await
634    }
635
636    /// Stops CDP tracing and returns the raw trace data.
637    ///
638    /// The returned bytes can be written to a `.json` file and loaded in
639    /// `chrome://tracing` or [Perfetto](https://ui.perfetto.dev).
640    ///
641    /// # Errors
642    ///
643    /// Returns error if no tracing was started or communication fails.
644    ///
645    /// See: <https://playwright.dev/docs/api/class-browser#browser-stop-tracing>
646    #[tracing::instrument(level = "debug", skip_all, fields(name = %self.name, bytes_len = tracing::field::Empty))]
647    pub async fn stop_tracing(&self) -> Result<Vec<u8>> {
648        #[derive(Deserialize)]
649        struct StopTracingResponse {
650            artifact: ArtifactRef,
651        }
652
653        #[derive(Deserialize)]
654        struct ArtifactRef {
655            #[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
656            guid: Arc<str>,
657        }
658
659        let response: StopTracingResponse = self
660            .channel()
661            .send("stopTracing", serde_json::json!({}))
662            .await?;
663
664        // save_as() rather than streaming because Stream protocol is not yet implemented
665        let artifact: crate::protocol::artifact::Artifact = self
666            .connection()
667            .get_typed::<crate::protocol::artifact::Artifact>(&response.artifact.guid)
668            .await?;
669
670        let tmp_path = std::env::temp_dir().join(format!(
671            "playwright-trace-{}.json",
672            response.artifact.guid.replace('@', "-")
673        ));
674        let tmp_str = tmp_path
675            .to_str()
676            .ok_or_else(|| {
677                crate::error::Error::ProtocolError(
678                    "Temporary path contains non-UTF-8 characters".to_string(),
679                )
680            })?
681            .to_string();
682
683        artifact.save_as(&tmp_str).await?;
684
685        let bytes = tokio::fs::read(&tmp_path).await.map_err(|e| {
686            crate::error::Error::ProtocolError(format!(
687                "Failed to read tracing artifact from '{}': {}",
688                tmp_str, e
689            ))
690        })?;
691
692        let _ = tokio::fs::remove_file(&tmp_path).await;
693
694        tracing::Span::current().record("bytes_len", bytes.len());
695        Ok(bytes)
696    }
697
698    /// Closes the browser and all of its pages (if any were opened).
699    ///
700    /// This is a graceful operation that sends a close command to the browser
701    /// and waits for it to shut down properly.
702    ///
703    /// # Errors
704    ///
705    /// Returns error if:
706    /// - Browser has already been closed
707    /// - Communication with browser process fails
708    ///
709    /// See: <https://playwright.dev/docs/api/class-browser#browser-close>
710    #[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
711    pub async fn close(&self) -> Result<()> {
712        // Send close RPC to server
713        // The protocol expects an empty object as params
714        let result = self
715            .channel()
716            .send_no_result("close", serde_json::json!({}))
717            .await;
718
719        // Add delay on Windows CI to ensure browser process fully terminates
720        // This prevents subsequent browser launches from hanging
721        #[cfg(windows)]
722        {
723            let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok();
724            if is_ci {
725                tracing::debug!("[playwright-rust] Adding Windows CI browser cleanup delay");
726                tokio::time::sleep(std::time::Duration::from_millis(500)).await;
727            }
728        }
729
730        result
731    }
732}
733
734impl ChannelOwner for Browser {
735    fn guid(&self) -> &str {
736        self.base.guid()
737    }
738
739    fn type_name(&self) -> &str {
740        self.base.type_name()
741    }
742
743    fn parent(&self) -> Option<Arc<dyn ChannelOwner>> {
744        self.base.parent()
745    }
746
747    fn connection(&self) -> Arc<dyn crate::server::connection::ConnectionLike> {
748        self.base.connection()
749    }
750
751    fn initializer(&self) -> &Value {
752        self.base.initializer()
753    }
754
755    fn channel(&self) -> &Channel {
756        self.base.channel()
757    }
758
759    fn dispose(&self, reason: crate::server::channel_owner::DisposeReason) {
760        // Use compare_exchange so handlers fire exactly once across both the
761        // "disconnected" event path and the __dispose__ path.
762        if self
763            .is_connected
764            .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
765            .is_ok()
766        {
767            let handlers = self.disconnected_handlers.lock().unwrap().clone();
768            tokio::spawn(async move {
769                for handler in handlers {
770                    if let Err(e) = handler().await {
771                        tracing::warn!("Browser disconnected handler error (from dispose): {}", e);
772                    }
773                }
774            });
775        }
776        self.base.dispose(reason)
777    }
778
779    fn adopt(&self, child: Arc<dyn ChannelOwner>) {
780        self.base.adopt(child)
781    }
782
783    fn add_child(&self, guid: Arc<str>, child: Arc<dyn ChannelOwner>) {
784        self.base.add_child(guid, child)
785    }
786
787    fn remove_child(&self, guid: &str) {
788        self.base.remove_child(guid)
789    }
790
791    fn on_event(&self, method: &str, params: Value) {
792        if method == "disconnected" {
793            // Use compare_exchange to fire handlers exactly once (guards against
794            // both the "disconnected" event and the __dispose__ path firing them).
795            if self
796                .is_connected
797                .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
798                .is_ok()
799            {
800                let handlers = self.disconnected_handlers.lock().unwrap().clone();
801                tokio::spawn(async move {
802                    for handler in handlers {
803                        if let Err(e) = handler().await {
804                            tracing::warn!("Browser disconnected handler error: {}", e);
805                        }
806                    }
807                });
808            }
809        } else if method == "context" {
810            let handlers = self.context_handlers.lock().unwrap().clone();
811            if !handlers.is_empty()
812                && let Some(guid) = params
813                    .get("context")
814                    .and_then(|c| c.get("guid"))
815                    .and_then(|g| g.as_str())
816            {
817                let guid = guid.to_string();
818                let connection = self.connection();
819                tokio::spawn(async move {
820                    use crate::server::connection::ConnectionExt;
821                    if let Ok(ctx) = connection.get_typed::<BrowserContext>(&guid).await {
822                        for handler in handlers {
823                            if let Err(e) = handler(ctx.clone()).await {
824                                tracing::warn!("Browser context handler error: {}", e);
825                            }
826                        }
827                    }
828                });
829            }
830        }
831        self.base.on_event(method, params)
832    }
833
834    fn was_collected(&self) -> bool {
835        self.base.was_collected()
836    }
837
838    fn as_any(&self) -> &dyn Any {
839        self
840    }
841}
842
843impl std::fmt::Debug for Browser {
844    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
845        f.debug_struct("Browser")
846            .field("guid", &self.guid())
847            .field("name", &self.name)
848            .field("version", &self.version)
849            .finish()
850    }
851}
852
853// Note: Browser is exercised by integration tests rather than unit tests,
854// because it requires a real Connection with an object registry, protocol
855// messages from the server, and BrowserType::launch() to create the object.
856// See crates/playwright/tests/integration/browser.rs.