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