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