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