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