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)