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