Skip to main content

playwright_rs/protocol/
browser_type.rs

1// Copyright 2026 Paul Adamson
2// Licensed under the Apache License, Version 2.0
3//
4// BrowserType - Represents a browser type (Chromium, Firefox, WebKit)
5//
6// Reference:
7// - Python: playwright-python/playwright/_impl/_browser_type.py
8// - Protocol: protocol.yml (BrowserType interface)
9
10use crate::api::{ConnectOptions, ConnectOverCdpOptions, LaunchOptions};
11use crate::error::Result;
12use crate::protocol::har_options::OptionHar;
13use crate::protocol::{Browser, BrowserContext, BrowserContextOptions};
14use crate::server::channel::Channel;
15use crate::server::channel_owner::{ChannelOwner, ChannelOwnerImpl, ParentOrConnection};
16use crate::server::connection::{ConnectionExt, ConnectionLike};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::any::Any;
20use std::sync::Arc;
21
22/// BrowserType represents a browser engine (Chromium, Firefox, or WebKit).
23///
24/// Each Playwright instance provides three BrowserType objects accessible via:
25/// - `playwright.chromium()`
26/// - `playwright.firefox()`
27/// - `playwright.webkit()`
28///
29/// BrowserType provides three main modes:
30/// 1. **Launch**: Creates a new browser instance
31/// 2. **Launch Persistent Context**: Creates browser + context with persistent storage
32/// 3. **Connect**: Connects to an existing remote browser instance
33///
34/// # Example
35///
36/// ```no_run
37/// # use playwright_rs::protocol::Playwright;
38/// # use playwright_rs::api::LaunchOptions;
39/// # use playwright_rs::protocol::BrowserContextOptions;
40/// # #[tokio::main]
41/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
42/// let playwright = Playwright::launch().await?;
43/// let chromium = playwright.chromium();
44///
45/// // Verify browser type info
46/// assert_eq!(chromium.name(), "chromium");
47/// assert!(!chromium.executable_path().is_empty());
48///
49/// // === Standard Launch ===
50/// // Launch with default options
51/// let browser1 = chromium.launch().await?;
52/// assert_eq!(browser1.name(), "chromium");
53/// assert!(!browser1.version().is_empty());
54/// browser1.close().await?;
55///
56/// // === Remote Connection ===
57/// // Connect to a remote browser (e.g., started with `npx playwright launch-server`)
58/// // let browser3 = chromium.connect("ws://localhost:3000", None).await?;
59/// // browser3.close().await?;
60///
61/// // === CDP Connection (Chromium only) ===
62/// // Connect to a Chrome instance with remote debugging enabled
63/// // let browser4 = chromium.connect_over_cdp("http://localhost:9222", None).await?;
64/// // browser4.close().await?;
65///
66/// // === Persistent Context Launch ===
67/// // Launch with persistent storage (cookies, local storage, etc.)
68/// let context = chromium
69///     .launch_persistent_context("/tmp/user-data")
70///     .await?;
71/// let page = context.new_page().await?;
72/// page.goto("https://example.com", None).await?;
73/// context.close().await?; // Closes browser too
74///
75/// // === App Mode (Standalone Window) ===
76/// // Launch as a standalone application window
77/// let app_options = BrowserContextOptions::builder()
78///     .args(vec!["--app=https://example.com".to_string()])
79///     .headless(true) // Set to true for CI, but app mode is typically headed
80///     .build();
81///
82/// let app_context = chromium
83///     .launch_persistent_context_with_options("/tmp/app-data", app_options)
84///     .await?;
85/// // Browser opens directly to URL without address bar
86/// app_context.close().await?;
87/// # Ok(())
88/// # }
89/// ```
90///
91/// See: <https://playwright.dev/docs/api/class-browsertype>
92#[derive(Clone)]
93pub struct BrowserType {
94    /// Base ChannelOwner implementation
95    base: ChannelOwnerImpl,
96    /// Browser name ("chromium", "firefox", or "webkit")
97    name: String,
98    /// Path to browser executable
99    executable_path: String,
100}
101
102impl BrowserType {
103    /// Creates a new BrowserType object from protocol initialization.
104    ///
105    /// Called by the object factory when server sends __create__ message.
106    ///
107    /// # Arguments
108    /// * `parent` - Parent (Connection for root objects, or another ChannelOwner)
109    /// * `type_name` - Protocol type name ("BrowserType")
110    /// * `guid` - Unique GUID from server (e.g., "browserType@chromium")
111    /// * `initializer` - Initial state with name and executablePath
112    pub fn new(
113        parent: ParentOrConnection,
114        type_name: String,
115        guid: Arc<str>,
116        initializer: Value,
117    ) -> Result<Self> {
118        let base = ChannelOwnerImpl::new(parent, type_name, guid, initializer.clone());
119
120        // Extract fields from initializer
121        let name = initializer["name"]
122            .as_str()
123            .ok_or_else(|| {
124                crate::error::Error::ProtocolError(
125                    "BrowserType initializer missing 'name'".to_string(),
126                )
127            })?
128            .to_string();
129
130        let executable_path = initializer["executablePath"]
131            .as_str()
132            .unwrap_or_default() // executablePath might be optional/empty for remote connection objects
133            .to_string();
134
135        Ok(Self {
136            base,
137            name,
138            executable_path,
139        })
140    }
141
142    /// Returns the browser name ("chromium", "firefox", or "webkit").
143    pub fn name(&self) -> &str {
144        &self.name
145    }
146
147    /// Returns the path to the browser executable.
148    pub fn executable_path(&self) -> &str {
149        &self.executable_path
150    }
151
152    /// Launches a browser instance with default options.
153    ///
154    /// This is equivalent to calling `launch_with_options(LaunchOptions::default())`.
155    ///
156    /// # Errors
157    ///
158    /// Returns error if:
159    /// - Browser executable not found
160    /// - Launch timeout (default three minutes, [`DEFAULT_LAUNCH_TIMEOUT_MS`](crate::DEFAULT_LAUNCH_TIMEOUT_MS))
161    /// - Browser process fails to start
162    ///
163    /// See: <https://playwright.dev/docs/api/class-browsertype#browser-type-launch>
164    #[tracing::instrument(level = "info", skip_all, fields(name = %self.name()))]
165    pub async fn launch(&self) -> Result<Browser> {
166        self.launch_with_options(LaunchOptions::default()).await
167    }
168
169    /// Launches a browser instance with custom options.
170    ///
171    /// # Arguments
172    ///
173    /// * `options` - Launch options (headless, args, etc.)
174    ///
175    /// # Errors
176    ///
177    /// Returns error if:
178    /// - Browser executable not found
179    /// - Launch timeout
180    /// - Invalid options
181    /// - Browser process fails to start
182    ///
183    /// See: <https://playwright.dev/docs/api/class-browsertype#browser-type-launch>
184    #[tracing::instrument(level = "info", skip_all, fields(name = %self.name()))]
185    pub async fn launch_with_options(&self, options: LaunchOptions) -> Result<Browser> {
186        // Add Windows CI-specific browser args to prevent hanging
187        let options = {
188            #[cfg(windows)]
189            {
190                let mut options = options;
191                // Check if we're in a CI environment (GitHub Actions, Jenkins, etc.)
192                let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok();
193
194                if is_ci {
195                    tracing::debug!(
196                        "[playwright-rust] Detected Windows CI environment, adding stability flags"
197                    );
198
199                    // Get existing args or create empty vec
200                    let mut args = options.args.unwrap_or_default();
201
202                    // Add Windows CI stability flags if not already present
203                    let ci_flags = vec![
204                        "--no-sandbox",            // Disable sandboxing (often problematic in CI)
205                        "--disable-dev-shm-usage", // Overcome limited /dev/shm resources
206                        "--disable-gpu",           // Disable GPU hardware acceleration
207                        "--disable-web-security",  // Avoid CORS issues in CI
208                        "--disable-features=IsolateOrigins,site-per-process", // Reduce process overhead
209                    ];
210
211                    for flag in ci_flags {
212                        if !args.iter().any(|a| a == flag) {
213                            args.push(flag.to_string());
214                        }
215                    }
216
217                    // Update options with enhanced args
218                    options.args = Some(args);
219                }
220                options
221            }
222
223            #[cfg(not(windows))]
224            {
225                options
226            }
227        };
228
229        // Normalize options for protocol transmission
230        let params = options.normalize();
231
232        // Send launch RPC to server
233        let response: LaunchResponse = self.base.channel().send("launch", params).await?;
234
235        // Get browser object from registry and downcast
236        let browser: Browser = self
237            .connection()
238            .get_typed::<Browser>(&response.browser.guid)
239            .await?;
240
241        Ok(browser)
242    }
243
244    /// Launches a browser with persistent storage using default options.
245    ///
246    /// Returns a persistent browser context. Closing this context will automatically
247    /// close the browser.
248    ///
249    /// This method is useful for:
250    /// - Preserving authentication state across sessions
251    /// - Testing with real user profiles
252    /// - Creating standalone applications with app mode
253    /// - Simulating real user behavior with cookies and storage
254    ///
255    /// # Arguments
256    ///
257    /// * `user_data_dir` - Path to a user data directory (stores cookies, local storage)
258    ///
259    /// # Errors
260    ///
261    /// Returns error if:
262    /// - Browser executable not found
263    /// - Launch timeout (default three minutes, [`DEFAULT_LAUNCH_TIMEOUT_MS`](crate::DEFAULT_LAUNCH_TIMEOUT_MS))
264    /// - Browser process fails to start
265    /// - User data directory cannot be created
266    ///
267    /// See: <https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context>
268    #[tracing::instrument(level = "info", skip_all, fields(name = %self.name()))]
269    pub async fn launch_persistent_context(
270        &self,
271        user_data_dir: impl Into<String>,
272    ) -> Result<BrowserContext> {
273        self.launch_persistent_context_with_options(user_data_dir, BrowserContextOptions::default())
274            .await
275    }
276
277    /// Launches a browser with persistent storage and custom options.
278    ///
279    /// Returns a persistent browser context with the specified configuration.
280    /// Closing this context will automatically close the browser.
281    ///
282    /// This method accepts both launch options (headless, args, etc.) and context
283    /// options (viewport, locale, etc.) in a single BrowserContextOptions struct.
284    ///
285    /// # Arguments
286    ///
287    /// * `user_data_dir` - Path to a user data directory (stores cookies, local storage)
288    /// * `options` - Combined launch and context options
289    ///
290    /// # Errors
291    ///
292    /// Returns error if:
293    /// - Browser executable not found
294    /// - Launch timeout
295    /// - Invalid options
296    /// - Browser process fails to start
297    /// - User data directory cannot be created
298    ///
299    /// See: <https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context>
300    #[tracing::instrument(level = "info", skip_all, fields(name = %self.name()))]
301    pub async fn launch_persistent_context_with_options(
302        &self,
303        user_data_dir: impl Into<String>,
304        mut options: BrowserContextOptions,
305    ) -> Result<BrowserContext> {
306        // Add Windows CI-specific browser args to prevent hanging
307        #[cfg(windows)]
308        {
309            let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok();
310
311            if is_ci {
312                tracing::debug!(
313                    "[playwright-rust] Detected Windows CI environment, adding stability flags"
314                );
315
316                // Get existing args or create empty vec
317                let mut args = options.args.unwrap_or_default();
318
319                // Add Windows CI stability flags if not already present
320                let ci_flags = vec![
321                    "--no-sandbox",            // Disable sandboxing (often problematic in CI)
322                    "--disable-dev-shm-usage", // Overcome limited /dev/shm resources
323                    "--disable-gpu",           // Disable GPU hardware acceleration
324                    "--disable-web-security",  // Avoid CORS issues in CI
325                    "--disable-features=IsolateOrigins,site-per-process", // Reduce process overhead
326                ];
327
328                for flag in ci_flags {
329                    if !args.iter().any(|a| a == flag) {
330                        args.push(flag.to_string());
331                    }
332                }
333
334                // Update options with enhanced args
335                options.args = Some(args);
336            }
337        }
338
339        // `recordHar` is not a driver parameter. As in the other language
340        // bindings, the HAR starts once the context exists and is written
341        // when it closes.
342        let option_har = options.record_har.take().map(OptionHar::new).transpose()?;
343
344        // Handle storage_state_path: read file and convert to inline storage_state
345        if let Some(path) = &options.storage_state_path {
346            let file_content = tokio::fs::read_to_string(path).await.map_err(|e| {
347                crate::error::Error::ProtocolError(format!(
348                    "Failed to read storage state file '{}': {}",
349                    path, e
350                ))
351            })?;
352
353            let storage_state: crate::protocol::StorageState = serde_json::from_str(&file_content)
354                .map_err(|e| {
355                    crate::error::Error::ProtocolError(format!(
356                        "Failed to parse storage state file '{}': {}",
357                        path, e
358                    ))
359                })?;
360
361            options.storage_state = Some(storage_state);
362            options.storage_state_path = None; // Clear path since we've converted to inline
363        }
364
365        // Convert options to JSON with userDataDir
366        let mut params = serde_json::to_value(&options).map_err(|e| {
367            crate::error::Error::ProtocolError(format!(
368                "Failed to serialize context options: {}",
369                e
370            ))
371        })?;
372
373        // Add userDataDir to params
374        params["userDataDir"] = serde_json::json!(user_data_dir.into());
375
376        // Set default timeout if not specified (required in Playwright 1.56.1+)
377        if params.get("timeout").is_none() {
378            params["timeout"] = serde_json::json!(crate::DEFAULT_LAUNCH_TIMEOUT_MS);
379        }
380
381        // Convert bool ignoreDefaultArgs to ignoreAllDefaultArgs
382        // (matches playwright-python's parameter normalization)
383        if let Some(ignore) = params.get("ignoreDefaultArgs")
384            && let Some(b) = ignore.as_bool()
385        {
386            if b {
387                params["ignoreAllDefaultArgs"] = serde_json::json!(true);
388            }
389            if let Some(obj) = params.as_object_mut() {
390                obj.remove("ignoreDefaultArgs");
391            }
392        }
393
394        // Send launchPersistentContext RPC to server
395        let response: LaunchPersistentContextResponse = self
396            .base
397            .channel()
398            .send("launchPersistentContext", params)
399            .await?;
400
401        // Get context object from registry and downcast
402        let context: BrowserContext = self
403            .connection()
404            .get_typed::<BrowserContext>(&response.context.guid)
405            .await?;
406
407        // Register with Selectors coordinator
408        let selectors = self.connection().selectors();
409        if let Err(e) = selectors.add_context(context.channel().clone()).await {
410            tracing::warn!("Failed to register BrowserContext with Selectors: {}", e);
411        }
412        if let Some(har) = option_har {
413            context.start_option_har(har).await?;
414        }
415
416        Ok(context)
417    }
418    /// Connects to an existing browser instance.
419    ///
420    /// # Arguments
421    /// * `ws_endpoint` - A WebSocket endpoint to connect to.
422    /// * `options` - Connection options.
423    ///
424    /// # Errors
425    /// Returns error if connection fails or handshake fails.
426    #[tracing::instrument(level = "info", skip_all, fields(name = %self.name(), url = %ws_endpoint))]
427    pub async fn connect(
428        &self,
429        ws_endpoint: &str,
430        options: impl Into<Option<ConnectOptions>>,
431    ) -> Result<Browser> {
432        use crate::server::connection::Connection;
433        use crate::server::transport::WebSocketTransport;
434
435        let options = options.into();
436        let options = options.unwrap_or_default();
437
438        // Get timeout (default 30 seconds, 0 = no timeout)
439        let timeout_ms = options.timeout.unwrap_or(30000.0);
440
441        // 1. Connect to WebSocket
442        tracing::debug!("Connecting to remote browser at {}", ws_endpoint);
443
444        let connect_future = WebSocketTransport::connect(ws_endpoint, options.headers);
445        let (transport, message_rx) = if timeout_ms > 0.0 {
446            let timeout = std::time::Duration::from_millis(timeout_ms as u64);
447            tokio::time::timeout(timeout, connect_future)
448                .await
449                .map_err(|_| {
450                    crate::error::Error::Timeout(format!(
451                        "Connection to {} timed out after {} ms",
452                        ws_endpoint, timeout_ms
453                    ))
454                })??
455        } else {
456            connect_future.await?
457        };
458        let (sender, receiver) = transport.into_parts();
459
460        // 2. Create Connection
461        let connection = Arc::new(Connection::new(sender, receiver, message_rx));
462
463        // 3. Start message loop
464        let conn_for_loop = Arc::clone(&connection);
465        crate::task::spawn_detached(async move {
466            conn_for_loop.run().await;
467        });
468
469        // 4. Initialize Playwright
470        // This exchanges the "initialize" message and returns the root Playwright object
471        let playwright_obj = connection.initialize_playwright().await?;
472
473        // 5. Get pre-launched browser from initializer
474        // The server sends a "preLaunchedBrowser" field in the Playwright object's initializer
475        let initializer = playwright_obj.initializer();
476
477        let browser_guid = initializer["preLaunchedBrowser"]["guid"]
478            .as_str()
479            .ok_or_else(|| {
480                 crate::error::Error::ProtocolError(
481                     "Remote server did not return a pre-launched browser. Ensure server was launched in server mode.".to_string()
482                 )
483            })?;
484
485        // 6. Get the existing Browser object and downcast
486        let browser: Browser = connection.get_typed::<Browser>(browser_guid).await?;
487
488        Ok(browser)
489    }
490
491    /// Connects to a browser over the Chrome DevTools Protocol.
492    ///
493    /// This method is only supported for Chromium. It connects to an existing Chrome
494    /// instance that exposes a CDP endpoint (e.g., `--remote-debugging-port`), or to
495    /// CDP-compatible services like browserless.
496    ///
497    /// Unlike `connect()`, which uses Playwright's proprietary WebSocket protocol,
498    /// this method connects directly via CDP. The Playwright server manages the CDP
499    /// connection internally.
500    ///
501    /// # Arguments
502    /// * `endpoint_url` - A CDP endpoint URL (e.g., `http://localhost:9222` or
503    ///   `ws://localhost:9222/devtools/browser/...`)
504    /// * `options` - Optional connection options.
505    ///
506    /// # Errors
507    /// Returns error if:
508    /// - Called on a non-Chromium browser type
509    /// - Connection to the CDP endpoint fails
510    /// - Connection timeout
511    ///
512    /// See: <https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp>
513    #[tracing::instrument(level = "info", skip_all, fields(name = %self.name(), url = %endpoint_url))]
514    pub async fn connect_over_cdp(
515        &self,
516        endpoint_url: &str,
517        options: impl Into<Option<ConnectOverCdpOptions>>,
518    ) -> Result<Browser> {
519        let options = options.into();
520        // connect_over_cdp is Chromium-only
521        if self.name() != "chromium" {
522            return Err(crate::error::Error::ProtocolError(
523                "Connecting over CDP is only supported in Chromium.".to_string(),
524            ));
525        }
526
527        let options = options.unwrap_or_default();
528
529        // Convert headers from HashMap to array of {name, value} objects
530        let headers_array = options.headers.map(|h| {
531            h.into_iter()
532                .map(|(name, value)| HeaderEntry { name, value })
533                .collect::<Vec<_>>()
534        });
535
536        let params = ConnectOverCdpParams {
537            endpoint_url: endpoint_url.to_string(),
538            headers: headers_array,
539            slow_mo: options.slow_mo,
540            no_defaults: options.no_defaults,
541            artifacts_dir: options.artifacts_dir,
542            timeout: options.timeout.unwrap_or(crate::DEFAULT_LAUNCH_TIMEOUT_MS),
543        };
544
545        // Send connectOverCDP RPC to the local Playwright server
546        let response: ConnectOverCdpResponse =
547            self.base.channel().send("connectOverCDP", params).await?;
548
549        // Get browser object from registry and downcast
550        let browser: Browser = self
551            .connection()
552            .get_typed::<Browser>(&response.browser.guid)
553            .await?;
554
555        Ok(browser)
556    }
557}
558
559/// Response from BrowserType.launch() protocol call
560#[derive(Debug, Deserialize, Serialize)]
561struct LaunchResponse {
562    browser: BrowserRef,
563}
564
565/// Response from BrowserType.launchPersistentContext() protocol call
566#[derive(Debug, Deserialize, Serialize)]
567struct LaunchPersistentContextResponse {
568    context: ContextRef,
569}
570
571/// Reference to a Browser object in the protocol
572#[derive(Debug, Deserialize, Serialize)]
573struct BrowserRef {
574    #[serde(
575        serialize_with = "crate::server::connection::serialize_arc_str",
576        deserialize_with = "crate::server::connection::deserialize_arc_str"
577    )]
578    guid: Arc<str>,
579}
580
581/// Reference to a BrowserContext object in the protocol
582#[derive(Debug, Deserialize, Serialize)]
583struct ContextRef {
584    #[serde(
585        serialize_with = "crate::server::connection::serialize_arc_str",
586        deserialize_with = "crate::server::connection::deserialize_arc_str"
587    )]
588    guid: Arc<str>,
589}
590
591/// Parameters for BrowserType.connectOverCDP() protocol call
592#[derive(Debug, Serialize)]
593struct ConnectOverCdpParams {
594    #[serde(rename = "endpointURL")]
595    endpoint_url: String,
596    #[serde(skip_serializing_if = "Option::is_none")]
597    headers: Option<Vec<HeaderEntry>>,
598    #[serde(rename = "slowMo", skip_serializing_if = "Option::is_none")]
599    slow_mo: Option<f64>,
600    #[serde(rename = "noDefaults", skip_serializing_if = "Option::is_none")]
601    no_defaults: Option<bool>,
602    #[serde(rename = "artifactsDir", skip_serializing_if = "Option::is_none")]
603    artifacts_dir: Option<String>,
604    timeout: f64,
605}
606
607/// A single HTTP header as {name, value} for the connectOverCDP protocol
608#[derive(Debug, Serialize)]
609struct HeaderEntry {
610    name: String,
611    value: String,
612}
613
614/// Response from BrowserType.connectOverCDP() protocol call
615#[derive(Debug, Deserialize)]
616struct ConnectOverCdpResponse {
617    browser: BrowserRef,
618    #[serde(rename = "defaultContext")]
619    #[allow(dead_code)]
620    default_context: Option<ContextRef>,
621}
622
623impl ChannelOwner for BrowserType {
624    fn guid(&self) -> &str {
625        self.base.guid()
626    }
627
628    fn type_name(&self) -> &str {
629        self.base.type_name()
630    }
631
632    fn parent(&self) -> Option<Arc<dyn ChannelOwner>> {
633        self.base.parent()
634    }
635
636    fn connection(&self) -> Arc<dyn ConnectionLike> {
637        self.base.connection()
638    }
639
640    fn initializer(&self) -> &Value {
641        self.base.initializer()
642    }
643
644    fn channel(&self) -> &Channel {
645        self.base.channel()
646    }
647
648    fn dispose(&self, reason: crate::server::channel_owner::DisposeReason) {
649        self.base.dispose(reason)
650    }
651
652    fn adopt(&self, child: Arc<dyn ChannelOwner>) {
653        self.base.adopt(child)
654    }
655
656    fn add_child(&self, guid: Arc<str>, child: Arc<dyn ChannelOwner>) {
657        self.base.add_child(guid, child)
658    }
659
660    fn remove_child(&self, guid: &str) {
661        self.base.remove_child(guid)
662    }
663
664    fn on_event(&self, method: &str, params: Value) {
665        self.base.on_event(method, params)
666    }
667
668    fn was_collected(&self) -> bool {
669        self.base.was_collected()
670    }
671
672    fn as_any(&self) -> &dyn Any {
673        self
674    }
675}
676
677impl std::fmt::Debug for BrowserType {
678    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
679        f.debug_struct("BrowserType")
680            .field("guid", &self.guid())
681            .field("name", &self.name)
682            .field("executable_path", &self.executable_path)
683            .finish()
684    }
685}
686
687// Note: BrowserType testing is done via integration tests since it requires:
688// - A real Connection with object registry
689// - Protocol messages from the server
690// See: crates/playwright-core/tests/connection_integration.rs
691
692#[cfg(test)]
693mod connect_over_cdp_params_tests {
694    use super::ConnectOverCdpParams;
695
696    #[test]
697    fn serializes_artifacts_dir_as_camelcase_when_set() {
698        let params = ConnectOverCdpParams {
699            endpoint_url: "http://localhost:9222".to_string(),
700            headers: None,
701            slow_mo: None,
702            no_defaults: None,
703            artifacts_dir: Some("/tmp/artifacts".to_string()),
704            timeout: 30000.0,
705        };
706        let v = serde_json::to_value(&params).unwrap();
707        assert_eq!(v["artifactsDir"], "/tmp/artifacts");
708        assert_eq!(v["endpointURL"], "http://localhost:9222");
709    }
710
711    #[test]
712    fn omits_artifacts_dir_when_unset() {
713        let params = ConnectOverCdpParams {
714            endpoint_url: "http://localhost:9222".to_string(),
715            headers: None,
716            slow_mo: None,
717            no_defaults: None,
718            artifacts_dir: None,
719            timeout: 30000.0,
720        };
721        let v = serde_json::to_value(&params).unwrap();
722        assert!(v.get("artifactsDir").is_none());
723    }
724}