Skip to main content

playwright_cdp/
page.rs

1//! `Page` — a browser tab. Tracks its main execution context, drives
2//! navigation, evaluation, screenshots, and produces locators.
3
4use crate::accessibility::Accessibility;
5use crate::browser::Browser;
6use crate::api_request::APIRequestContext;
7use crate::cdp::session::CdpSession;
8use crate::clock::Clock;
9use crate::coverage::Coverage;
10use crate::js_handle::JSHandle;
11use crate::video::Video;
12use crate::web_socket::{WebSocket, WebSocketRegistry};
13use crate::web_storage::WebStorage;
14use crate::download::{Download, DownloadState, DownloadStateCell};
15use crate::element_handle::ElementHandle;
16use crate::error::{Error, Result};
17use crate::file_chooser::FileChooser;
18use crate::frame::Frame;
19use crate::frame_locator::FrameLocator;
20use crate::keyboard::Keyboard;
21use crate::locator::Locator;
22use crate::mouse::Mouse;
23use crate::network::{network_tracker, NetworkStore, RequestHandler, ResponseHandler};
24use crate::options::{
25    DragToOptions, EmulateMediaOptions, GotoOptions, ScreenshotOptions, WaitForFunctionOptions, WaitUntil,
26};
27use crate::request::Request;
28use crate::response::Response;
29use crate::route::{route_listener, Route, RouteEntry};
30use crate::selectors;
31use crate::touchscreen::Touchscreen;
32use crate::types::{AriaRole, ConsoleMessage, Headers, Viewport};
33use crate::worker::Worker;
34use base64::Engine;
35use parking_lot::Mutex;
36use serde::de::DeserializeOwned;
37use serde_json::{json, Value};
38use std::collections::HashMap;
39use std::future::Future;
40use std::path::PathBuf;
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::Arc;
43use std::time::Duration;
44use tokio::sync::broadcast;
45
46/// A page (tab) in a browser.
47#[derive(Clone)]
48pub struct Page {
49    inner: Arc<PageInner>,
50}
51
52type CloseHandler =
53    Arc<dyn Fn() -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
54
55/// An erased, callable binding: takes the JS args and returns a JSON value.
56type ExposedBindingHandler =
57    Arc<dyn Fn(Vec<Value>) -> std::pin::Pin<Box<dyn Future<Output = Value> + Send>> + Send + Sync>;
58
59struct PageInner {
60    browser: Browser,
61    session: Arc<CdpSession>,
62    target_id: String,
63    main_world_ctx: Arc<Mutex<Option<i64>>>,
64    /// Per-frame main-world execution contexts: `frame_id → context id`.
65    /// Populated by the `context_tracker` from
66    /// `Runtime.executionContextCreated { auxData.frameId }`. Used to scope
67    /// `Frame` evaluate/locator resolution to a child frame's own context.
68    frame_contexts: Arc<Mutex<HashMap<String, i64>>>,
69    default_timeout_ms: AtomicU64,
70    default_navigation_timeout_ms: AtomicU64,
71    viewport: Mutex<Option<Viewport>>,
72    mouse_pos: Arc<Mutex<(f64, f64)>>,
73    network_store: Arc<NetworkStore>,
74    on_request_handlers: Arc<Mutex<Vec<RequestHandler>>>,
75    on_response_handlers: Arc<Mutex<Vec<ResponseHandler>>>,
76    on_requestfailed_handlers: Arc<Mutex<Vec<RequestHandler>>>,
77    frames: Arc<Mutex<HashMap<String, FrameData>>>,
78    main_frame_id: Arc<Mutex<Option<String>>>,
79    route_handlers: Arc<Mutex<Vec<RouteEntry>>>,
80    route_started: AtomicBool,
81    on_close_handlers: Mutex<Vec<CloseHandler>>,
82    closed: Mutex<bool>,
83    // Download capture: the temp dir (kept alive for the page's lifetime), its
84    // path, per-guid progress state, and a one-shot flag for behavior setup.
85    download_dir: Mutex<Option<Arc<tempfile::TempDir>>>,
86    download_path: Mutex<Option<PathBuf>>,
87    download_states: Arc<Mutex<HashMap<String, DownloadStateCell>>>,
88    download_started: AtomicBool,
89    // File-chooser interception: a one-shot flag for enabling interception.
90    filechooser_started: AtomicBool,
91    // Worker capture: a one-shot flag for enabling page-session auto-attach.
92    worker_started: AtomicBool,
93    // Touchscreen: a one-shot flag for enabling touch emulation. Arc-wrapped so
94    // it can be shared across `Touchscreen` clones produced from this page.
95    touch_emulation_started: Arc<AtomicBool>,
96    // WebSocket capture: a registry that subscribes to the page session and
97    // accumulates one `WebSocket` per `Network.webSocket*` connection. Attached
98    // in `Page::attach` BEFORE `Network.enable` so no event is missed.
99    web_socket_registry: Arc<WebSocketRegistry>,
100    // Optional pre-configured video output path (Playwright configures the video
101    // path up front at context/page creation time).
102    video_path: Mutex<Option<PathBuf>>,
103}
104
105/// Cached frame-tree data for one frame.
106#[derive(Clone)]
107pub(crate) struct FrameData {
108    pub url: String,
109    pub name: String,
110    pub parent_id: Option<String>,
111    pub detached: bool,
112}
113
114impl Page {
115    /// Attach to an existing target by session/target id. Enables domains,
116    /// injects the selector engine, and applies context defaults.
117    #[allow(clippy::too_many_arguments)]
118    pub(crate) async fn attach(
119        browser: Browser,
120        session_id: String,
121        target_id: String,
122        init_scripts: &[String],
123        default_timeout_ms: u64,
124        extra_headers: Option<&Headers>,
125        user_agent: Option<&str>,
126        viewport: Option<Viewport>,
127    ) -> Result<Page> {
128        let session = Arc::new(CdpSession::target(
129            browser.connection().clone(),
130            session_id,
131        ));
132        session.set_default_timeout_ms(default_timeout_ms);
133
134        let main_world_ctx: Arc<Mutex<Option<i64>>> = Arc::new(Mutex::new(None));
135        let frame_contexts: Arc<Mutex<HashMap<String, i64>>> =
136            Arc::new(Mutex::new(HashMap::new()));
137        // The main frame id and frame store are shared with the context tracker
138        // (so it can tell the main frame's default context apart from a child
139        // frame's) and the frame tracker.
140        let frames_store: Arc<Mutex<HashMap<String, FrameData>>> =
141            Arc::new(Mutex::new(HashMap::new()));
142        let main_frame_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
143        let ctx_rx = session.subscribe();
144        // Context-tracking + engine re-injection task. Subscribe before
145        // enabling Runtime so the initial executionContextCreated is captured.
146        {
147            let session_for_task = Arc::clone(&session);
148            let ctx_cell = Arc::clone(&main_world_ctx);
149            let frame_ctx_cell = Arc::clone(&frame_contexts);
150            let main_id_cell = Arc::clone(&main_frame_id);
151            tokio::spawn(async move {
152                context_tracker(
153                    ctx_rx,
154                    session_for_task,
155                    ctx_cell,
156                    frame_ctx_cell,
157                    main_id_cell,
158                )
159                .await;
160            });
161        }
162
163        let network_store = NetworkStore::new();
164        // Handler lists shared between the page and the network tracker task.
165        let on_request_handlers: Arc<Mutex<Vec<RequestHandler>>> = Arc::new(Mutex::new(Vec::new()));
166        let on_response_handlers: Arc<Mutex<Vec<ResponseHandler>>> =
167            Arc::new(Mutex::new(Vec::new()));
168        let on_requestfailed_handlers: Arc<Mutex<Vec<RequestHandler>>> =
169            Arc::new(Mutex::new(Vec::new()));
170
171        // Network capture task. Subscribe before Network.enable so no event is missed.
172        {
173            let net_rx = session.subscribe();
174            let session_for_task = Arc::clone(&session);
175            let store_for_task = Arc::clone(&network_store);
176            let on_request = Arc::clone(&on_request_handlers);
177            let on_response = Arc::clone(&on_response_handlers);
178            let on_failed = Arc::clone(&on_requestfailed_handlers);
179            tokio::spawn(async move {
180                network_tracker(
181                    net_rx,
182                    session_for_task,
183                    store_for_task,
184                    on_request,
185                    on_response,
186                    on_failed,
187                )
188                .await;
189            });
190        }
191
192        let page = Page {
193            inner: Arc::new(PageInner {
194                browser,
195                session: Arc::clone(&session),
196                target_id,
197                main_world_ctx: Arc::clone(&main_world_ctx),
198                frame_contexts: Arc::clone(&frame_contexts),
199                default_timeout_ms: AtomicU64::new(default_timeout_ms),
200                default_navigation_timeout_ms: AtomicU64::new(default_timeout_ms),
201                viewport: Mutex::new(viewport),
202                mouse_pos: Arc::new(Mutex::new((0.0, 0.0))),
203                network_store,
204                on_request_handlers,
205                on_response_handlers,
206                on_requestfailed_handlers,
207                frames: Arc::clone(&frames_store),
208                main_frame_id: Arc::clone(&main_frame_id),
209                route_handlers: Arc::new(Mutex::new(Vec::new())),
210                route_started: AtomicBool::new(false),
211                on_close_handlers: Mutex::new(Vec::new()),
212                closed: Mutex::new(false),
213                download_dir: Mutex::new(None),
214                download_path: Mutex::new(None),
215                download_states: Arc::new(Mutex::new(HashMap::new())),
216                download_started: AtomicBool::new(false),
217                filechooser_started: AtomicBool::new(false),
218                worker_started: AtomicBool::new(false),
219                touch_emulation_started: Arc::new(AtomicBool::new(false)),
220                web_socket_registry: Arc::new(WebSocketRegistry::new()),
221                video_path: Mutex::new(None),
222            }),
223        };
224
225        // WebSocket capture: attach the registry's subscriber BEFORE
226        // Network.enable is sent so no `Network.webSocket*` event is missed.
227        page.inner.web_socket_registry.attach(&page.inner.session);
228
229        // Live frame-tree tracking (urls/names/children) for `Frame`.
230        {
231            let rx = session.subscribe();
232            let frames_store = Arc::clone(&page.inner.frames);
233            let main_id = Arc::clone(&page.inner.main_frame_id);
234            tokio::spawn(async move {
235                frame_tracker(rx, frames_store, main_id).await;
236            });
237        }
238        // Populate the frame tree eagerly so `main_frame()` is always valid.
239        let _ = page.refresh_frame_tree().await;
240
241        // Domain enablement. Order matters only in that Runtime must be last
242        // (after the context receiver exists) so contexts are captured.
243        let _ = session.send("Page.enable", json!({})).await;
244        let _ = session.send("Runtime.enable", json!({})).await;
245        let _ = session.send("Network.enable", json!({})).await;
246        let _ = session.send("Log.enable", json!({})).await;
247
248        // Inject selector engine + user init scripts for all future documents.
249        let mut source = String::from(selectors::INJECTED_SCRIPT);
250        for s in init_scripts {
251            source.push_str("\n");
252            source.push_str(s);
253        }
254        let _ = session
255            .send(
256                "Page.addScriptToEvaluateOnNewDocument",
257                json!({ "source": source }),
258            )
259            .await;
260
261        // Ensure the engine is present in whatever context exists right now.
262        page.ensure_engine_in_current_context().await;
263
264        if let Some(headers) = extra_headers {
265            let _ = page.set_extra_http_headers(headers.clone()).await;
266        }
267        if let Some(ua) = user_agent {
268            let _ = session
269                .send("Emulation.setUserAgentOverride", json!({ "userAgent": ua }))
270                .await;
271        }
272        if let Some(vp) = page.inner.viewport.lock().as_ref().copied() {
273            let _ = page.set_viewport_size(vp).await;
274        }
275
276        Ok(page)
277    }
278
279    // --- accessors ---
280
281    pub(crate) fn session(&self) -> &CdpSession {
282        &self.inner.session
283    }
284
285    /// The owning CDP session as a cloned `Arc`, for building a [`JSHandle`]
286    /// (which retains its own `Arc<CdpSession>`).
287    pub(crate) fn session_arc(&self) -> Arc<CdpSession> {
288        Arc::clone(&self.inner.session)
289    }
290
291    pub(crate) fn context_id(&self) -> Option<i64> {
292        self.inner.main_world_ctx.lock().as_ref().copied()
293    }
294
295    /// The execution context to use for page-level evaluation.
296    ///
297    /// Returns `None` intentionally: omitting `contextId` from
298    /// `Runtime.evaluate` lets CDP resolve the page's default (main-world)
299    /// context itself, which is always fresh and avoids the stale-context race
300    /// that occurs right after a navigation (when the old context is destroyed
301    /// before the new one is reported and our tracker has not caught up).
302    ///
303    /// The tracked id ([`context_id`](Self::context_id)) is still maintained
304    /// for callers that need a concrete handle (e.g. [`JSHandle`] context
305    /// tagging) and to seed per-frame lookups.
306    pub(crate) async fn ctx(&self) -> Option<i64> {
307        None
308    }
309
310    /// The execution-context id to use when targeting `frame_id`.
311    ///
312    /// Returns `None` for the page's main frame (so its ops run in CDP's
313    /// default, race-free context) and `Some(child_ctx)` for a child frame's
314    /// own context. If a child frame's context has not been reported yet,
315    /// returns `None` as a safe fallback (default context).
316    pub(crate) fn context_for_frame(&self, frame_id: &str) -> Option<i64> {
317        // The main frame uses the default context (no id) to stay race-free.
318        if self
319            .inner
320            .main_frame_id
321            .lock()
322            .as_deref()
323            .map(|m| m == frame_id)
324            .unwrap_or(false)
325        {
326            return None;
327        }
328        self.inner
329            .frame_contexts
330            .lock()
331            .get(frame_id)
332            .copied()
333            // Unknown child frame: fall back to the default context.
334            .or(None)
335    }
336
337    /// Like [`context_for_frame`](Self::context_for_frame), but waits briefly
338    /// for a child frame's context to arrive (the context-tracker is
339    /// asynchronous and may lag a freshly-attached frame). The main frame
340    /// returns `None` immediately.
341    pub(crate) async fn ctx_for_frame(&self, frame_id: &str) -> Option<i64> {
342        // Main frame: default context, no waiting.
343        if self
344            .inner
345            .main_frame_id
346            .lock()
347            .as_deref()
348            .map(|m| m == frame_id)
349            .unwrap_or(false)
350        {
351            return None;
352        }
353        let deadline = tokio::time::Instant::now() + Duration::from_millis(2000);
354        loop {
355            if let Some(id) = self.inner.frame_contexts.lock().get(frame_id).copied() {
356                return Some(id);
357            }
358            if tokio::time::Instant::now() >= deadline {
359                // Unknown child frame: fall back to the default context rather
360                // than erroring.
361                return None;
362            }
363            tokio::time::sleep(Duration::from_millis(20)).await;
364        }
365    }
366
367    pub fn browser(&self) -> Browser {
368        self.inner.browser.clone()
369    }
370
371    pub fn target_id(&self) -> &str {
372        &self.inner.target_id
373    }
374
375    pub fn is_closed(&self) -> bool {
376        *self.inner.closed.lock()
377    }
378
379    /// Set the default timeout (ms) for all actions on this page, mirroring
380    /// Playwright's `page.setDefaultTimeout`. The value is stored on the page
381    /// ([`PageInner::default_timeout_ms`]) and propagated to the underlying
382    /// CDP session's command timeout.
383    pub fn set_default_timeout(&self, ms: u64) {
384        self.inner.default_timeout_ms.store(ms, Ordering::Relaxed);
385        self.inner.session.set_default_timeout_ms(ms);
386    }
387
388    pub(crate) fn default_timeout(&self) -> Duration {
389        Duration::from_millis(self.inner.default_timeout_ms.load(Ordering::Relaxed))
390    }
391
392    pub(crate) fn default_navigation_timeout(&self) -> Duration {
393        Duration::from_millis(self.inner.default_navigation_timeout_ms.load(Ordering::Relaxed))
394    }
395
396    async fn ensure_engine_in_current_context(&self) {
397        // Idempotent: the bundle early-returns if already installed.
398        let _ = selectors::eval_context(
399            &self.inner.session,
400            self.ctx().await,
401            "(arg) => { void arg; }",
402            Value::Null,
403        )
404        .await;
405    }
406
407    // --- navigation ---
408
409    /// Navigate to `url`.
410    pub async fn goto(
411        &self,
412        url: &str,
413        opts: Option<GotoOptions>,
414    ) -> Result<Option<Response>> {
415        let opts = opts.unwrap_or_default();
416        let wait = opts.wait_until_or_default();
417
418        // Subscribe before navigating so lifecycle events aren't missed.
419        let mut rx = self.inner.session.subscribe();
420        let mut params = json!({ "url": url });
421        if let Some(referer) = &opts.referer {
422            params["referer"] = json!(referer);
423        }
424        let nav = self.inner.session.send("Page.navigate", params).await?;
425        if let Some(err) = nav.get("errorText").and_then(|v| v.as_str()) {
426            return Err(Error::ProtocolError(format!("navigation to {url} failed: {err}")));
427        }
428        let loader_id = nav.get("loaderId").and_then(|v| v.as_str()).map(String::from);
429
430        let timeout = opts.timeout.unwrap_or_else(|| self.default_timeout());
431        self.wait_lifecycle(&mut rx, loader_id.as_deref(), wait, timeout, url)
432            .await?;
433
434        // Correlate the main-frame document response for this navigation.
435        let response = match &loader_id {
436            Some(lid) => self.wait_for_nav_response(lid).await,
437            None => None,
438        };
439        Ok(response)
440    }
441
442    /// Poll the network store briefly for the document response of `loader_id`.
443    async fn wait_for_nav_response(&self, loader_id: &str) -> Option<Response> {
444        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
445        loop {
446            if let Some(r) = self.inner.network_store.response_for_loader(loader_id) {
447                return Some(r);
448            }
449            if tokio::time::Instant::now() >= deadline {
450                return None;
451            }
452            tokio::time::sleep(Duration::from_millis(50)).await;
453        }
454    }
455
456    /// Wait until the in-flight request count stays at 0 for ~500ms (or deadline).
457    async fn wait_network_idle(&self, deadline: tokio::time::Instant) {
458        let window = Duration::from_millis(500);
459        loop {
460            if self.inner.network_store.inflight() == 0 {
461                let settled_at = tokio::time::Instant::now();
462                loop {
463                    if self.inner.network_store.inflight() != 0 {
464                        break; // a new request started; restart
465                    }
466                    if tokio::time::Instant::now().duration_since(settled_at) >= window {
467                        return; // stayed idle for the window
468                    }
469                    if tokio::time::Instant::now() >= deadline {
470                        return; // overall nav timeout
471                    }
472                    tokio::time::sleep(Duration::from_millis(50)).await;
473                }
474            }
475            if tokio::time::Instant::now() >= deadline {
476                return;
477            }
478            tokio::time::sleep(Duration::from_millis(50)).await;
479        }
480    }
481
482    async fn wait_lifecycle(
483        &self,
484        rx: &mut broadcast::Receiver<crate::cdp::CdpEvent>,
485        loader_id: Option<&str>,
486        wait: WaitUntil,
487        timeout: Duration,
488        url: &str,
489    ) -> Result<()> {
490        if matches!(wait, WaitUntil::Commit) {
491            return Ok(()); // Page.navigate returned => committed.
492        }
493        let target_name = match wait {
494            WaitUntil::Load => "load",
495            WaitUntil::DomContentLoaded => "DOMContentLoaded",
496            WaitUntil::NetworkIdle => "load", // then settle below
497            WaitUntil::Commit => "load",
498        };
499
500        let deadline = tokio::time::Instant::now() + timeout;
501        loop {
502            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
503            match tokio::time::timeout(remaining, rx.recv()).await {
504                Err(_) => {
505                    return Err(Error::NavigationTimeout {
506                        url: url.to_string(),
507                        duration_ms: timeout.as_millis() as u64,
508                    })
509                }
510                Ok(Err(broadcast::error::RecvError::Closed)) => {
511                    return Err(Error::ChannelClosed);
512                }
513                Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue,
514                Ok(Ok(ev)) => {
515                    // Some Chrome builds emit modern `Page.lifecycleEvent`
516                    // (carrying `name`), others emit the legacy discrete events
517                    // (`Page.loadEventFired`, `Page.domContentEventFired`).
518                    // Handle both.
519                    let matched = match ev.method.as_str() {
520                        "Page.lifecycleEvent" => {
521                            let same_loader = loader_id
522                                .map(|l| {
523                                    ev.params.get("loaderId").and_then(|v| v.as_str()) == Some(l)
524                                })
525                                .unwrap_or(true);
526                            let name = ev.params.get("name").and_then(|v| v.as_str()).unwrap_or("");
527                            same_loader && name == target_name
528                        }
529                        "Page.loadEventFired" => target_name == "load",
530                        "Page.domContentEventFired" => target_name == "DOMContentLoaded",
531                        _ => false,
532                    };
533                    if matched {
534                        if matches!(wait, WaitUntil::NetworkIdle) {
535                            // Settle: wait until in-flight requests hit 0 and stay there
536                            // for ~500ms (Playwright's networkidle window).
537                            self.wait_network_idle(deadline).await;
538                        }
539                        return Ok(());
540                    }
541                }
542            }
543        }
544    }
545
546    /// Reload the page.
547    pub async fn reload(&self, opts: Option<GotoOptions>) -> Result<Option<Response>> {
548        let opts = opts.unwrap_or_default();
549        let wait = opts.wait_until_or_default();
550        let mut rx = self.inner.session.subscribe();
551        let _ = self.inner.session.send("Page.reload", json!({})).await?;
552        let timeout = opts.timeout.unwrap_or_else(|| self.default_timeout());
553        self.wait_lifecycle(&mut rx, None, wait, timeout, "reload").await?;
554        Ok(None)
555    }
556
557    /// Wait for a given document lifecycle state.
558    pub async fn wait_for_load_state(&self, state: Option<WaitUntil>) -> Result<()> {
559        let state = state.unwrap_or_default();
560        let mut rx = self.inner.session.subscribe();
561        self.wait_lifecycle(&mut rx, None, state, self.default_timeout(), "wait_for_load_state")
562            .await
563    }
564
565    /// Navigate one entry back in history (best-effort; returns no response).
566    pub async fn go_back(&self, opts: Option<GotoOptions>) -> Result<Option<Response>> {
567        self.history_nav("history.back()", opts).await
568    }
569
570    /// Navigate one entry forward in history (best-effort; returns no response).
571    pub async fn go_forward(&self, opts: Option<GotoOptions>) -> Result<Option<Response>> {
572        self.history_nav("history.forward()", opts).await
573    }
574
575    async fn history_nav(
576        &self,
577        expr: &str,
578        opts: Option<GotoOptions>,
579    ) -> Result<Option<Response>> {
580        let opts = opts.unwrap_or_default();
581        // `history.back()/forward()` returns true iff a navigation occurred.
582        let navigated: bool = self.evaluate(expr).await.unwrap_or(false);
583        if navigated {
584            let wait = opts.wait_until_or_default();
585            let mut rx = self.inner.session.subscribe();
586            let timeout = opts
587                .timeout
588                .unwrap_or_else(|| self.default_navigation_timeout());
589            let _ = self
590                .wait_lifecycle(&mut rx, None, wait, timeout, "history navigation")
591                .await;
592        }
593        Ok(None)
594    }
595
596    /// Wait until the page URL matches `url` (exact, or `*` glob), then optionally
597    /// wait for a lifecycle state.
598    pub async fn wait_for_url(&self, url: &str, opts: Option<GotoOptions>) -> Result<()> {
599        let opts = opts.unwrap_or_default();
600        let timeout = opts
601            .timeout
602            .unwrap_or_else(|| self.default_navigation_timeout());
603        let deadline = tokio::time::Instant::now() + timeout;
604        loop {
605            let cur = self.url().await.unwrap_or_default();
606            if glob_matches(url, &cur) {
607                break;
608            }
609            if tokio::time::Instant::now() >= deadline {
610                return Err(Error::Timeout(format!(
611                    "wait_for_url '{url}' timed out after {}ms (last: '{cur}')",
612                    timeout.as_millis()
613                )));
614            }
615            tokio::time::sleep(Duration::from_millis(100)).await;
616        }
617        if let Some(state) = opts.wait_until {
618            self.wait_for_load_state(Some(state)).await?;
619        }
620        Ok(())
621    }
622
623    /// Set the default navigation timeout (ms), separate from action timeout.
624    pub fn set_default_navigation_timeout(&self, ms: u64) {
625        self.inner
626            .default_navigation_timeout_ms
627            .store(ms, Ordering::Relaxed);
628    }
629
630    /// Emulate CSS media (media / color-scheme / reduced-motion).
631    pub async fn emulate_media(&self, opts: Option<EmulateMediaOptions>) -> Result<()> {
632        use crate::options::{ColorScheme, Media, ReducedMotion};
633        let opts = opts.unwrap_or_default();
634        let mut params = json!({});
635        if let Some(m) = opts.media {
636            params["media"] = json!(match m {
637                Media::Screen => "screen",
638                Media::Print => "print",
639            });
640        }
641        let mut features = Vec::new();
642        if let Some(cs) = opts.color_scheme {
643            features.push(json!({
644                "name": "prefers-color-scheme",
645                "value": match cs {
646                    ColorScheme::Light => "light",
647                    ColorScheme::Dark => "dark",
648                    ColorScheme::NoPreference => "no-preference",
649                }
650            }));
651        }
652        if let Some(rm) = opts.reduced_motion {
653            features.push(json!({
654                "name": "prefers-reduced-motion",
655                "value": match rm {
656                    ReducedMotion::Reduce => "reduce",
657                    ReducedMotion::NoPreference => "no-preference",
658                }
659            }));
660        }
661        if !features.is_empty() {
662            params["features"] = json!(features);
663        }
664        self.inner
665            .session
666            .send("Emulation.setEmulatedMedia", params)
667            .await
668            .map(|_: Value| ())
669    }
670
671    /// Toggle network offline emulation.
672    pub async fn set_offline(&self, offline: bool) -> Result<()> {
673        let params = if offline {
674            json!({ "offline": true, "latency": 0, "downloadThroughput": 0, "uploadThroughput": 0 })
675        } else {
676            json!({ "offline": false, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1 })
677        };
678        self.inner
679            .session
680            .send("Network.emulateNetworkConditions", params)
681            .await
682            .map(|_: Value| ())
683    }
684
685    /// Drag the element matched by `source` onto the element matched by `target`.
686    pub async fn drag_and_drop(
687        &self,
688        source: &str,
689        target: &str,
690        options: Option<DragToOptions>,
691    ) -> Result<()> {
692        let src = self.locator(source);
693        let tgt = self.locator(target);
694        src.drag_to(&tgt, options).await
695    }
696
697    // --- content & evaluation ---
698
699    pub async fn url(&self) -> Result<String> {
700        self.evaluate::<String>("location.href").await
701    }
702
703    pub async fn title(&self) -> Result<String> {
704        self.evaluate::<String>("document.title").await
705    }
706
707    pub async fn content(&self) -> Result<String> {
708        self.evaluate::<String>("document.documentElement.outerHTML").await
709    }
710
711    pub async fn set_content(&self, html: &str) -> Result<()> {
712        let _ = selectors::eval_context(
713            &self.inner.session,
714            self.ctx().await,
715            "(html) => { document.open(); document.write(html); document.close(); }",
716            json!(html),
717        )
718        .await?;
719        Ok(())
720    }
721
722    /// Evaluate a JS expression in the main world, returning a typed result.
723    ///
724    /// `expression` is wrapped as `(arg) => { return (<expression>); }`.
725    pub async fn evaluate<R: DeserializeOwned>(&self, expression: &str) -> Result<R> {
726        let function = format!("(arg) => {{ return ({expression}); }}");
727        let v = selectors::eval_context(&self.inner.session, self.ctx().await, &function, Value::Null)
728            .await?;
729        serde_json::from_value::<R>(v).map_err(Error::from)
730    }
731
732    /// Evaluate a JS expression in the main world, returning a [`JSHandle`] to
733    /// the result (returned by reference, not by value). Mirrors Playwright's
734    /// `page.evaluateHandle`.
735    ///
736    /// Like [`evaluate`](Self::evaluate), `expression` is wrapped as
737    /// `(arg) => { return (<expression>); }` and run against the page's main
738    /// world. The returned `Runtime.RemoteObjectId` is captured and wrapped in
739    /// a [`JSHandle`]. Returns an error if the evaluation yields `null`/
740    /// `undefined` (no remote object to reference).
741    pub async fn evaluate_handle(&self, expression: &str) -> Result<JSHandle> {
742        let function = format!("(arg) => {{ return ({expression}); }}");
743        let object_id = selectors::eval_context_handle(
744            &self.inner.session,
745            self.ctx().await,
746            &function,
747            Value::Null,
748        )
749        .await?
750        .ok_or_else(|| {
751            Error::ProtocolError(
752                "evaluate_handle returned no remote object (null/undefined result)".into(),
753            )
754        })?;
755        Ok(JSHandle::with_context(
756            Arc::clone(&self.inner.session),
757            object_id,
758            self.context_id(),
759        ))
760    }
761
762    /// Evaluate a JS expression with a JSON-serializable argument.
763    pub async fn evaluate_with_arg<R: DeserializeOwned, T: serde::Serialize>(
764        &self,
765        expression: &str,
766        arg: &T,
767    ) -> Result<R> {
768        let function = format!("(arg) => {{ return ({expression}); }}");
769        let arg_val = serde_json::to_value(arg)?;
770        let v = selectors::eval_context(&self.inner.session, self.ctx().await, &function, arg_val)
771            .await?;
772        serde_json::from_value::<R>(v).map_err(Error::from)
773    }
774
775    /// Poll `expression` until it evaluates to a truthy value, then return the
776    /// deserialized result. Mirrors Playwright's `page.waitForFunction`.
777    ///
778    /// `expression` is wrapped as `(arg) => { return (<expression>); }` (the
779    /// same form as [`Page::evaluate`]). A value is considered truthy unless it
780    /// is `null`, `false`, `0`, or `""`. On timeout (default 30s) this returns
781    /// [`Error::Timeout`]. Polls every `polling_interval` ms (default 100).
782    pub async fn wait_for_function<R: DeserializeOwned>(
783        &self,
784        expression: &str,
785        arg: Option<Value>,
786        options: Option<WaitForFunctionOptions>,
787    ) -> Result<R> {
788        let opts = options.unwrap_or_default();
789        let timeout = Duration::from_millis(opts.timeout.unwrap_or_else(|| self.default_timeout().as_millis() as f64) as u64);
790        let poll = Duration::from_millis(opts.polling_interval.unwrap_or(100.0) as u64);
791        let function = format!("(arg) => {{ return ({expression}); }}");
792        let arg_val = arg.unwrap_or(Value::Null);
793
794        let deadline = tokio::time::Instant::now() + timeout;
795        loop {
796            let v =
797                selectors::eval_context(&self.inner.session, self.ctx().await, &function, arg_val.clone())
798                    .await?;
799            if is_truthy(&v) {
800                return serde_json::from_value::<R>(v).map_err(Error::from);
801            }
802            if tokio::time::Instant::now() >= deadline {
803                return Err(Error::Timeout(format!(
804                    "wait_for_function timed out after {}ms (expression: {expression})",
805                    timeout.as_millis()
806                )));
807            }
808            tokio::time::sleep(poll).await;
809        }
810    }
811
812    /// Evaluate `expression` against the first element matching `selector`.
813    pub async fn eval_on_selector<R: DeserializeOwned>(
814        &self,
815        selector: &str,
816        expression: &str,
817    ) -> Result<R> {
818        let object_id = self
819            .resolve_strict(selector, Some(0))
820            .await?
821            .ok_or_else(|| Error::ElementNotFound(selector.to_string()))?;
822        let function = format!("(el) => {{ return ({expression}); }}");
823        let v = selectors::eval_object(&self.inner.session, &object_id, &function, Value::Null)
824            .await?;
825        self.release_object(&object_id).await;
826        serde_json::from_value::<R>(v).map_err(Error::from)
827    }
828
829    /// Evaluate `expression` against all elements matching `selector`.
830    pub async fn eval_on_selector_all<R: DeserializeOwned>(
831        &self,
832        selector: &str,
833        expression: &str,
834    ) -> Result<Vec<R>> {
835        let n = selectors::count(&self.inner.session, self.ctx().await, selector).await?;
836        let mut out = Vec::with_capacity(n);
837        for i in 0..n {
838            if let Some(oid) = selectors::element_at(&self.inner.session, self.ctx().await, selector, i).await? {
839                let function = format!("(el) => {{ return ({expression}); }}");
840                let v = selectors::eval_object(&self.inner.session, &oid, &function, Value::Null).await?;
841                self.release_object(&oid).await;
842                out.push(serde_json::from_value::<R>(v).map_err(Error::from)?);
843            }
844        }
845        Ok(out)
846    }
847
848    async fn release_object(&self, object_id: &str) {
849        let _ = self
850            .inner
851            .session
852            .send("Runtime.releaseObject", json!({ "objectId": object_id }))
853            .await;
854    }
855
856    // --- viewport / screenshot ---
857
858    pub async fn set_viewport_size(&self, viewport: Viewport) -> Result<()> {
859        *self.inner.viewport.lock() = Some(viewport);
860        self.inner
861            .session
862            .send(
863                "Emulation.setDeviceMetricsOverride",
864                json!({
865                    "width": viewport.width,
866                    "height": viewport.height,
867                    "deviceScaleFactor": 1,
868                    "mobile": false,
869                }),
870            )
871            .await?;
872        Ok(())
873    }
874
875    pub async fn screenshot(&self, opts: Option<ScreenshotOptions>) -> Result<Vec<u8>> {
876        let opts = opts.unwrap_or_default();
877        let format = match opts.r#type.unwrap_or_default() {
878            crate::types::ScreenshotType::Png => "png",
879            crate::types::ScreenshotType::Jpeg => "jpeg",
880            crate::types::ScreenshotType::Webp => "webp",
881        };
882        let mut params = json!({ "format": format });
883        if opts.full_page.unwrap_or(false) {
884            params["captureBeyondViewport"] = json!(true);
885        }
886        if opts.omit_background.unwrap_or(false) && format == "png" {
887            params["omitBackground"] = json!(true);
888        }
889        let resp = self
890            .inner
891            .session
892            .send("Page.captureScreenshot", params)
893            .await?;
894        let data = resp
895            .get("data")
896            .and_then(|v| v.as_str())
897            .ok_or_else(|| Error::ProtocolError("screenshot missing data".into()))?;
898        let bytes = base64::engine::general_purpose::STANDARD
899            .decode(data)
900            .map_err(|e| Error::ProtocolError(format!("base64 decode: {e}")))?;
901        if let Some(path) = &opts.path {
902            tokio::fs::write(path, &bytes).await?;
903        }
904        Ok(bytes)
905    }
906
907    // --- headers / init scripts ---
908
909    /// Inject a `<script>` tag into the page (inline `content` and/or `url`).
910    pub async fn add_script_tag(
911        &self,
912        content: Option<&str>,
913        url: Option<&str>,
914    ) -> Result<()> {
915        let arg = json!({ "content": content, "url": url });
916        let _ = selectors::eval_context(
917            &self.inner.session,
918            self.ctx().await,
919            "(a) => { const s = document.createElement('script'); if (a.url) s.src = a.url; if (a.content) s.textContent = a.content; document.head.appendChild(s); }",
920            arg,
921        )
922        .await?;
923        Ok(())
924    }
925
926    /// Inject a `<style>` tag into the page.
927    pub async fn add_style_tag(&self, content: &str) -> Result<()> {
928        let _ = selectors::eval_context(
929            &self.inner.session,
930            self.ctx().await,
931            "(a) => { const s = document.createElement('style'); s.textContent = a; document.head.appendChild(s); }",
932            json!(content),
933        )
934        .await?;
935        Ok(())
936    }
937
938    /// Enable/disable the HTTP cache for this page.
939    pub async fn set_cache_disabled(&self, disabled: bool) -> Result<()> {
940        self.inner
941            .session
942            .send("Network.setCacheDisabled", json!({ "cacheDisabled": disabled }))
943            .await
944            .map(|_: Value| ())
945    }
946
947    /// Bring the page to the front (focus).
948    pub async fn bring_to_front(&self) -> Result<()> {
949        self.inner
950            .session
951            .send("Page.bringToFront", json!({}))
952            .await
953            .map(|_: Value| ())
954    }
955
956    /// Override geolocation `(latitude, longitude)` (or clear with `None`).
957    /// Requires a granted `geolocation` permission.
958    pub async fn set_geolocation(&self, geolocation: Option<(f64, f64)>) -> Result<()> {
959        let params = match geolocation {
960            Some((lat, lon)) => json!({ "latitude": lat, "longitude": lon }),
961            None => json!({}),
962        };
963        self.inner
964            .session
965            .send("Emulation.setGeolocationOverride", params)
966            .await
967            .map(|_: Value| ())
968    }
969
970    /// Return a standalone HTTP client ([`APIRequestContext`]) tied to this
971    /// page. The client shares no state with the browser tab — it is a direct
972    /// HTTP client useful for API testing.
973    ///
974    /// Note: the page does not retain its `extra_http_headers` in Rust (they
975    /// are applied directly to CDP), so the returned context starts with an
976    /// empty default header set. Use [`BrowserContext::request`] to seed
977    /// defaults from the context.
978    pub fn request(&self) -> APIRequestContext {
979        APIRequestContext::new(Headers::new())
980    }
981
982    pub async fn set_extra_http_headers(&self, headers: Headers) -> Result<()> {
983        // CDP wants an array of {name, value}.
984        let list: Vec<Value> = headers
985            .iter()
986            .map(|(k, v)| json!({ "name": k, "value": v }))
987            .collect();
988        self.inner
989            .session
990            .send("Network.setExtraHTTPHeaders", json!({ "headers": list }))
991            .await?;
992        Ok(())
993    }
994
995    pub async fn add_init_script(&self, script: &str) -> Result<()> {
996        let _ = self
997            .inner
998            .session
999            .send(
1000                "Page.addScriptToEvaluateOnNewDocument",
1001                json!({ "source": script }),
1002            )
1003            .await;
1004        // Also run once in the current context.
1005        let _ = selectors::eval_context(
1006            &self.inner.session,
1007            self.ctx().await,
1008            "(s) => { (0, eval)(s); }",
1009            json!(script),
1010        )
1011        .await;
1012        Ok(())
1013    }
1014
1015    /// Expose a Rust function to the page as `window[name]`.
1016    ///
1017    /// After this returns, page JS can call `await window.<name>(...args)` and
1018    /// receive the Rust `callback`'s return value (serialized as JSON). Mirrors
1019    /// Playwright's `page.exposeFunction`.
1020    ///
1021    /// The `callback` receives the JS arguments as a `Vec<serde_json::Value>`
1022    /// and returns a `serde_json::Value`. If the callback panics or its result
1023    /// fails to serialize, the JS promise resolves with
1024    /// `{ "__error": "<message>" }` rather than rejecting.
1025    ///
1026    /// # Mechanism
1027    /// A CDP binding is registered under the internal name `__pwcdpInvoke_<name>`
1028    /// via `Runtime.addBinding`. A JS wrapper turns `window[name]` into a
1029    /// Promise-returning function that forwards `{ id, args }` to that binding
1030    /// (as a JSON string). A per-registration listener handles
1031    /// `Runtime.bindingCalled`, invokes the callback on its own task, and
1032    /// resolves the Promise by evaluating against the pending-callback map.
1033    ///
1034    /// The wrapper is installed for future documents via
1035    /// `Page.addScriptToEvaluateOnNewDocument` and once in the current context.
1036    /// Bindings are per-execution-context and reset on navigation: the wrapper
1037    /// is re-installed on every new document, but `Runtime.addBinding` is only
1038    /// re-asserted lazily if needed (it persists across same-origin navigations
1039    /// on a live page session; call `expose_function` again after a cross-origin
1040    /// navigation if the binding goes missing).
1041    pub async fn expose_function<F, Fut>(&self, name: &str, callback: F) -> Result<()>
1042    where
1043        F: Fn(Vec<Value>) -> Fut + Send + Sync + 'static,
1044        Fut: Future<Output = Value> + Send + 'static,
1045    {
1046        // Wrap the typed callback in an erased `Arc<dyn Fn>` so the listener
1047        // task can invoke it without carrying generics.
1048        let handler: ExposedBindingHandler =
1049            Arc::new(move |args| Box::pin(callback(args)));
1050
1051        // Register the CDP binding under a distinct internal name so it does not
1052        // clash with the Promise-returning `window[name]` wrapper.
1053        let binding_name = internal_binding_name(name);
1054        let _ = self
1055            .inner
1056            .session
1057            .send("Runtime.addBinding", json!({ "name": binding_name }))
1058            .await;
1059
1060        // Install the JS wrapper for future documents...
1061        let wrapper = binding_wrapper_source(name);
1062        let _ = self
1063            .inner
1064            .session
1065            .send(
1066                "Page.addScriptToEvaluateOnNewDocument",
1067                json!({ "source": wrapper }),
1068            )
1069            .await;
1070        // ...and once in the current context (idempotent).
1071        let _ = selectors::eval_context(
1072            &self.inner.session,
1073            self.ctx().await,
1074            "(s) => { (0, eval)(s); }",
1075            json!(wrapper),
1076        )
1077        .await;
1078
1079        // Per-registration listener: dispatch bindingCalled -> callback -> resolve.
1080        let mut rx = self.inner.session.subscribe();
1081        let session = Arc::clone(&self.inner.session);
1082        let name_owned = name.to_string();
1083        tokio::spawn(async move {
1084            binding_listener(&mut rx, &session, &name_owned, &handler).await;
1085        });
1086
1087        Ok(())
1088    }
1089
1090    // --- locators ---
1091
1092    pub fn locator(&self, selector: impl Into<String>) -> Locator {
1093        Locator::new(self.clone(), selector.into(), true, None)
1094    }
1095
1096    /// Return a [`FrameLocator`] scoped to the same-origin `<iframe>` matched
1097    /// by `selector`. Element queries on the returned locator resolve inside
1098    /// the iframe's `contentDocument`.
1099    ///
1100    /// **Same-origin only.** Cross-origin iframes cannot be reached from the
1101    /// page's main world; their `contentDocument` reads as `null`, and queries
1102    /// against them will report zero matches. `srcdoc` iframes and same-origin
1103    /// `src` iframes are supported.
1104    pub fn frame_locator(&self, selector: impl Into<String>) -> FrameLocator {
1105        FrameLocator::new(self.clone(), selector.into())
1106    }
1107
1108    pub fn get_by_text(&self, text: &str, _exact: bool) -> Locator {
1109        // Minimal engine treats `text=` as case-insensitive substring.
1110        self.locator(format!("text={text}"))
1111    }
1112
1113    pub fn get_by_label(&self, text: &str) -> Locator {
1114        self.locator(format!("[aria-label=\"{}\"]", attr_escape(text)))
1115    }
1116
1117    pub fn get_by_placeholder(&self, text: &str) -> Locator {
1118        self.locator(format!("[placeholder=\"{}\"]", attr_escape(text)))
1119    }
1120
1121    pub fn get_by_alt_text(&self, text: &str) -> Locator {
1122        self.locator(format!("[alt=\"{}\"]", attr_escape(text)))
1123    }
1124
1125    pub fn get_by_title(&self, text: &str) -> Locator {
1126        self.locator(format!("[title=\"{}\"]", attr_escape(text)))
1127    }
1128
1129    pub fn get_by_test_id(&self, text: &str) -> Locator {
1130        self.locator(format!("[data-testid=\"{}\"]", attr_escape(text)))
1131    }
1132
1133    pub fn get_by_role(&self, role: AriaRole, opts: Option<crate::options::GetByRoleOptions>) -> Locator {
1134        let opts = opts.unwrap_or_default();
1135        let mut sel = format!("role={}", role.as_str());
1136        if let Some(name) = &opts.name {
1137            sel.push_str(&format!("[name=\"{name}\"]"));
1138        }
1139        if opts.exact == Some(true) {
1140            sel.push_str("[exact=\"true\"]");
1141        }
1142        Locator::new(self.clone(), sel, true, None)
1143    }
1144
1145    /// (Internal) resolve a selector to a single element RemoteObjectId,
1146    /// enforcing strict mode unless `index` was explicitly chosen.
1147    pub(crate) async fn resolve_strict(
1148        &self,
1149        selector: &str,
1150        forced_index: Option<usize>,
1151    ) -> Result<Option<String>> {
1152        let n = selectors::count(&self.inner.session, self.ctx().await, selector).await?;
1153        if n == 0 {
1154            return Ok(None);
1155        }
1156        let index = forced_index.unwrap_or_else(|| {
1157            // Strict callers (no forced index) pick 0; strict-violation is the
1158            // caller's responsibility. See Locator::resolve.
1159            0
1160        });
1161        selectors::element_at(&self.inner.session, self.ctx().await, selector, index).await
1162    }
1163
1164    /// The first element matching `selector`, if any.
1165    pub async fn query_selector(&self, selector: &str) -> Result<Option<ElementHandle>> {
1166        let oid = self.resolve_strict(selector, Some(0)).await?;
1167        Ok(oid.map(|oid| ElementHandle::new(self.clone(), oid)))
1168    }
1169
1170    /// All elements matching `selector`.
1171    pub async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementHandle>> {
1172        let n = selectors::count(&self.inner.session, self.ctx().await, selector).await?;
1173        let mut out = Vec::with_capacity(n);
1174        for i in 0..n {
1175            if let Some(oid) =
1176                selectors::element_at(&self.inner.session, self.ctx().await, selector, i).await?
1177            {
1178                out.push(ElementHandle::new(self.clone(), oid));
1179            }
1180        }
1181        Ok(out)
1182    }
1183
1184    // --- input devices & frames ---
1185
1186    pub fn keyboard(&self) -> Keyboard {
1187        Keyboard::new(self.clone())
1188    }
1189
1190    pub fn mouse(&self) -> Mouse {
1191        Mouse::with_pos(self.clone(), Arc::clone(&self.inner.mouse_pos))
1192    }
1193
1194    pub fn touchscreen(&self) -> Touchscreen {
1195        Touchscreen::with_flag(self.clone(), Arc::clone(&self.inner.touch_emulation_started))
1196    }
1197
1198    // --- feature handles (wave-1 modules) ---
1199
1200    /// The page video capture handle, mirroring Playwright's `page.video()`.
1201    ///
1202    /// If a video output path was configured for the page, it is pre-recorded on
1203    /// the handle (via [`Video::with_path`]); otherwise the handle starts with
1204    /// no path and one can be supplied at [`Video::start`].
1205    pub fn video(&self) -> Video {
1206        let session = Arc::clone(&self.inner.session);
1207        match self.inner.video_path.lock().clone() {
1208            Some(path) => Video::with_path(session, path),
1209            None => Video::new(session),
1210        }
1211    }
1212
1213    /// The page accessibility-tree handle, mirroring Playwright's
1214    /// `page.accessibility()`.
1215    pub fn accessibility(&self) -> Accessibility {
1216        Accessibility::new(self.target_session())
1217    }
1218
1219    /// The page code-coverage handle, mirroring Playwright's
1220    /// `page.coverage()`.
1221    pub fn coverage(&self) -> Coverage {
1222        Coverage::new(self.target_session())
1223    }
1224
1225    /// The page fake-timer handle, mirroring Playwright's `page.clock()`.
1226    pub fn clock(&self) -> Clock {
1227        Clock::new(self.target_session())
1228    }
1229
1230    /// The page's `localStorage` for its current security origin, mirroring
1231    /// Playwright's `page.evaluate(() => localStorage)`-backed access but
1232    /// speaking CDP's `DOMStorage` domain directly.
1233    ///
1234    /// The origin is derived from the current `location.href` (scheme://host
1235    /// with port). Returns a best-effort handle; if the URL has no parseable
1236    /// origin (e.g. `about:blank`), the empty string is used as the origin.
1237    pub async fn web_storage(&self) -> WebStorage {
1238        let origin = self.security_origin().await;
1239        WebStorage::local_storage(self.target_session(), origin)
1240    }
1241
1242    /// The page's `localStorage` for its current security origin.
1243    pub async fn local_storage(&self) -> WebStorage {
1244        let origin = self.security_origin().await;
1245        WebStorage::local_storage(self.target_session(), origin)
1246    }
1247
1248    /// The page's `sessionStorage` for its current security origin.
1249    pub async fn session_storage(&self) -> WebStorage {
1250        let origin = self.security_origin().await;
1251        WebStorage::session_storage(self.target_session(), origin)
1252    }
1253
1254    /// Build a by-value [`CdpSession`] for this page's target.
1255    ///
1256    /// The wave-1 feature modules ([`Coverage`], [`Clock`], [`WebStorage`],
1257    /// [`Accessibility`]) own their session by value, but the page holds its
1258    /// session behind an `Arc`. We rebuild an equivalent target-level session
1259    /// (same connection + session id) and re-apply the page's default timeout
1260    /// so command timeouts match the page's configured value.
1261    fn target_session(&self) -> CdpSession {
1262        let conn = self.inner.session.connection().clone();
1263        let session_id = self
1264            .inner
1265            .session
1266            .session_id()
1267            .map(|s| s.to_string())
1268            .unwrap_or_default();
1269        let session = CdpSession::target(conn, session_id);
1270        session.set_default_timeout_ms(self.inner.default_timeout_ms.load(Ordering::Relaxed));
1271        session
1272    }
1273
1274    /// Derive the security origin (`scheme://host[:port]`) from the current page
1275    /// URL. Falls back to an empty origin for opaque URLs like `about:blank`.
1276    async fn security_origin(&self) -> String {
1277        let url = self.url().await.unwrap_or_default();
1278        derive_security_origin(&url)
1279    }
1280
1281    pub fn main_frame(&self) -> Frame {
1282        Frame::main(self.clone())
1283    }
1284
1285    /// All frames in the page (refreshed from the browser).
1286    pub async fn frames(&self) -> Result<Vec<Frame>> {
1287        self.refresh_frame_tree().await?;
1288        let ids: Vec<String> = self.inner.frames.lock().keys().cloned().collect();
1289        Ok(ids.into_iter().map(|id| Frame::new(self.clone(), id)).collect())
1290    }
1291
1292    /// Refresh the cached frame tree from `Page.getFrameTree`.
1293    pub(crate) async fn refresh_frame_tree(&self) -> Result<()> {
1294        let resp = self
1295            .inner
1296            .session
1297            .send("Page.getFrameTree", json!({}))
1298            .await?;
1299        if let Some(tree) = resp.get("frameTree") {
1300            let mut frames = self.inner.frames.lock();
1301            frames.clear();
1302            walk_frame_tree(tree, &mut frames);
1303            if let Some(id) = tree
1304                .get("frame")
1305                .and_then(|f| f.get("id"))
1306                .and_then(|v| v.as_str())
1307            {
1308                *self.inner.main_frame_id.lock() = Some(id.to_string());
1309            }
1310        }
1311        Ok(())
1312    }
1313
1314    pub(crate) fn main_frame_id(&self) -> Option<String> {
1315        self.inner.main_frame_id.lock().clone()
1316    }
1317
1318    pub(crate) fn frame_data(&self, frame_id: &str) -> Option<FrameData> {
1319        self.inner.frames.lock().get(frame_id).cloned()
1320    }
1321
1322    pub(crate) fn frame_ids_with_parent(&self, parent: &str) -> Vec<String> {
1323        self.inner
1324            .frames
1325            .lock()
1326            .iter()
1327            .filter(|(_, d)| d.parent_id.as_deref() == Some(parent))
1328            .map(|(k, _)| k.clone())
1329            .collect()
1330    }
1331
1332    // --- events ---
1333
1334    pub fn on_console<F, Fut>(&self, handler: F)
1335    where
1336        F: Fn(ConsoleMessage) -> Fut + Send + Sync + 'static,
1337        Fut: std::future::Future<Output = ()> + Send + 'static,
1338    {
1339        let mut rx = self.inner.session.subscribe();
1340        tokio::spawn(async move {
1341            while let Ok(ev) = rx.recv().await {
1342                if ev.method == "Runtime.consoleAPICalled" {
1343                    let msg = parse_console(&ev.params);
1344                    handler(msg).await;
1345                }
1346            }
1347        });
1348    }
1349
1350    /// Register a handler invoked when the page fires its `load` event
1351    /// (`Page.loadEventFired`), mirroring Playwright's `page.on('load')`.
1352    pub fn on_load<F, Fut>(&self, handler: F)
1353    where
1354        F: Fn(()) -> Fut + Send + Sync + 'static,
1355        Fut: std::future::Future<Output = ()> + Send + 'static,
1356    {
1357        let mut rx = self.inner.session.subscribe();
1358        tokio::spawn(async move {
1359            while let Ok(ev) = rx.recv().await {
1360                if ev.method == "Page.loadEventFired" {
1361                    handler(()).await;
1362                }
1363            }
1364        });
1365    }
1366
1367    /// Register a handler invoked when the page crashes (`Inspector.targetCrashed`),
1368    /// mirroring Playwright's `page.on('crash')`.
1369    pub fn on_crash<F, Fut>(&self, handler: F)
1370    where
1371        F: Fn(()) -> Fut + Send + Sync + 'static,
1372        Fut: std::future::Future<Output = ()> + Send + 'static,
1373    {
1374        let mut rx = self.inner.session.subscribe();
1375        tokio::spawn(async move {
1376            while let Ok(ev) = rx.recv().await {
1377                if ev.method == "Inspector.targetCrashed" {
1378                    handler(()).await;
1379                }
1380            }
1381        });
1382    }
1383
1384    /// Register a handler invoked when a frame is attached to the page
1385    /// (`Page.frameAttached`), mirroring Playwright's `page.on('frameattached')`.
1386    pub fn on_frameattached<F, Fut>(&self, handler: F)
1387    where
1388        F: Fn(FrameEvent) -> Fut + Send + Sync + 'static,
1389        Fut: std::future::Future<Output = ()> + Send + 'static,
1390    {
1391        let mut rx = self.inner.session.subscribe();
1392        tokio::spawn(async move {
1393            while let Ok(ev) = rx.recv().await {
1394                if ev.method == "Page.frameAttached" {
1395                    if let Some(fe) = FrameEvent::from_attached(&ev.params) {
1396                        handler(fe).await;
1397                    }
1398                }
1399            }
1400        });
1401    }
1402
1403    /// Register a handler invoked when a frame is detached from the page
1404    /// (`Page.frameDetached`), mirroring Playwright's `page.on('framedetached')`.
1405    pub fn on_framedetached<F, Fut>(&self, handler: F)
1406    where
1407        F: Fn(FrameEvent) -> Fut + Send + Sync + 'static,
1408        Fut: std::future::Future<Output = ()> + Send + 'static,
1409    {
1410        let mut rx = self.inner.session.subscribe();
1411        tokio::spawn(async move {
1412            while let Ok(ev) = rx.recv().await {
1413                if ev.method == "Page.frameDetached" {
1414                    if let Some(fe) = FrameEvent::from_detached(&ev.params) {
1415                        handler(fe).await;
1416                    }
1417                }
1418            }
1419        });
1420    }
1421
1422    /// Register a handler invoked when a frame is navigated to a new URL
1423    /// (`Page.frameNavigated`), mirroring Playwright's `page.on('framenavigated')`.
1424    pub fn on_framenavigated<F, Fut>(&self, handler: F)
1425    where
1426        F: Fn(FrameEvent) -> Fut + Send + Sync + 'static,
1427        Fut: std::future::Future<Output = ()> + Send + 'static,
1428    {
1429        let mut rx = self.inner.session.subscribe();
1430        tokio::spawn(async move {
1431            while let Ok(ev) = rx.recv().await {
1432                if ev.method == "Page.frameNavigated" {
1433                    if let Some(fe) = FrameEvent::from_navigated(&ev.params) {
1434                        handler(fe).await;
1435                    }
1436                }
1437            }
1438        });
1439    }
1440
1441    pub fn on_dialog<F, Fut>(&self, handler: F)
1442    where
1443        F: Fn(Dialog) -> Fut + Send + Sync + 'static,
1444        Fut: std::future::Future<Output = ()> + Send + 'static,
1445    {
1446        let mut rx = self.inner.session.subscribe();
1447        let session = Arc::clone(&self.inner.session);
1448        tokio::spawn(async move {
1449            while let Ok(ev) = rx.recv().await {
1450                if ev.method == "Page.javascriptDialogOpening" {
1451                    let dialog = Dialog::from_event(&ev.params, &session);
1452                    handler(dialog).await;
1453                }
1454            }
1455        });
1456    }
1457
1458    /// Register a handler invoked for each network request sent.
1459    pub fn on_request<F, Fut>(&self, handler: F)
1460    where
1461        F: Fn(Request) -> Fut + Send + Sync + 'static,
1462        Fut: Future<Output = ()> + Send + 'static,
1463    {
1464        let h: RequestHandler = Arc::new(move |r| Box::pin(handler(r)));
1465        self.inner.on_request_handlers.lock().push(h);
1466    }
1467
1468    /// Register a handler invoked for each network response received.
1469    pub fn on_response<F, Fut>(&self, handler: F)
1470    where
1471        F: Fn(Response) -> Fut + Send + Sync + 'static,
1472        Fut: Future<Output = ()> + Send + 'static,
1473    {
1474        let h: ResponseHandler = Arc::new(move |r| Box::pin(handler(r)));
1475        self.inner.on_response_handlers.lock().push(h);
1476    }
1477
1478    /// Register a handler invoked when a network request fails.
1479    pub fn on_requestfailed<F, Fut>(&self, handler: F)
1480    where
1481        F: Fn(Request) -> Fut + Send + Sync + 'static,
1482        Fut: Future<Output = ()> + Send + 'static,
1483    {
1484        let h: RequestHandler = Arc::new(move |r| Box::pin(handler(r)));
1485        self.inner.on_requestfailed_handlers.lock().push(h);
1486    }
1487
1488    /// Register a handler invoked when an uncaught page error occurs.
1489    pub fn on_pageerror<F, Fut>(&self, handler: F)
1490    where
1491        F: Fn(String) -> Fut + Send + Sync + 'static,
1492        Fut: Future<Output = ()> + Send + 'static,
1493    {
1494        let mut rx = self.inner.session.subscribe();
1495        tokio::spawn(async move {
1496            while let Ok(ev) = rx.recv().await {
1497                if ev.method == "Runtime.exceptionThrown" {
1498                    let msg = ev
1499                        .params
1500                        .get("exceptionDetails")
1501                        .and_then(|d| d.get("exception"))
1502                        .and_then(|e| e.get("description"))
1503                        .and_then(|v| v.as_str())
1504                        .unwrap_or("unknown error")
1505                        .to_string();
1506                    handler(msg).await;
1507                }
1508            }
1509        });
1510    }
1511
1512    /// Register a handler invoked for each new WebSocket connection, mirroring
1513    /// Playwright's `page.on('websocket')`.
1514    ///
1515    /// Capture is always on: the page's [`WebSocketRegistry`] subscribes to the
1516    /// session in [`Page::attach`] (before `Network.enable`), so every
1517    /// `Network.webSocket*` event is already being accumulated into a
1518    /// [`WebSocket`] handle. This method registers against the registry's
1519    /// created-connection bus and invokes `handler` for each newly-created
1520    /// connection. Connections created *before* this call are not replayed —
1521    /// register the handler early (before the page opens the socket) to observe
1522    /// every one.
1523    pub fn on_websocket<F, Fut>(&self, handler: F)
1524    where
1525        F: Fn(WebSocket) -> Fut + Send + Sync + 'static,
1526        Fut: Future<Output = ()> + Send + 'static,
1527    {
1528        let mut rx = self.inner.web_socket_registry.on_created();
1529        // Wrap in an Arc<dyn Fn> so the handler can be invoked from each
1530        // spawned task without being moved out of the loop.
1531        let handler: Arc<dyn Fn(WebSocket) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync> =
1532            Arc::new(move |ws| Box::pin(handler(ws)));
1533        tokio::spawn(async move {
1534            while let Ok(ws) = rx.recv().await {
1535                let h = Arc::clone(&handler);
1536                // Invoke on its own task so a slow handler doesn't stall the
1537                // registry's broadcast bus.
1538                tokio::spawn(async move {
1539                    h(ws).await;
1540                });
1541            }
1542        });
1543    }
1544
1545    /// Register a handler invoked when a network request finishes loading.
1546    pub fn on_requestfinished<F, Fut>(&self, handler: F)
1547    where
1548        F: Fn(Request) -> Fut + Send + Sync + 'static,
1549        Fut: Future<Output = ()> + Send + 'static,
1550    {
1551        let mut rx = self.inner.session.subscribe();
1552        let store = Arc::clone(&self.inner.network_store);
1553        tokio::spawn(async move {
1554            while let Ok(ev) = rx.recv().await {
1555                if ev.method == "Network.loadingFinished" {
1556                    if let Some(rid) = ev.params.get("requestId").and_then(|v| v.as_str()) {
1557                        if let Some(req) = store.get_request(rid) {
1558                            handler(req).await;
1559                        }
1560                    }
1561                }
1562            }
1563        });
1564    }
1565
1566    /// Register a handler invoked when the page closes.
1567    pub fn on_close<F, Fut>(&self, handler: F)
1568    where
1569        F: Fn() -> Fut + Send + Sync + 'static,
1570        Fut: Future<Output = ()> + Send + 'static,
1571    {
1572        let h: CloseHandler = Arc::new(move || Box::pin(handler()));
1573        self.inner.on_close_handlers.lock().push(h);
1574    }
1575
1576    /// Register a handler invoked for each file download (`Page.downloadWillBegin`).
1577    ///
1578    /// On first registration, downloads are routed to a per-page temp directory
1579    /// via `Page.setDownloadBehavior` `allow`. Progress events update the
1580    /// download's shared state so [`Download::path`] / [`Download::save_as`]
1581    /// can await completion.
1582    pub fn on_download<F, Fut>(&self, handler: F)
1583    where
1584        F: Fn(Download) -> Fut + Send + Sync + 'static,
1585        Fut: Future<Output = ()> + Send + 'static,
1586    {
1587        // Lazily set up the download behavior + listener task exactly once.
1588        if self
1589            .inner
1590            .download_started
1591            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1592            .is_ok()
1593        {
1594            let dir = match tempfile::TempDir::new() {
1595                Ok(d) => Arc::new(d),
1596                Err(e) => {
1597                    tracing::error!("failed to create download temp dir: {e}");
1598                    return;
1599                }
1600            };
1601            let dir_path = dir.path().to_path_buf();
1602            *self.inner.download_dir.lock() = Some(Arc::clone(&dir));
1603            *self.inner.download_path.lock() = Some(dir_path.clone());
1604
1605            // Tell Chrome to save downloads into our temp dir.
1606            let session_for_setup = Arc::clone(&self.inner.session);
1607            let setup_path = dir_path.clone();
1608            tokio::spawn(async move {
1609                let _ = session_for_setup
1610                    .send(
1611                        "Page.setDownloadBehavior",
1612                        json!({ "behavior": "allow", "downloadPath": setup_path }),
1613                    )
1614                    .await;
1615            });
1616
1617            // Listener task: maps downloadWillBegin -> Download, downloadProgress -> state.
1618            let mut rx = self.inner.session.subscribe();
1619            let session = Arc::clone(&self.inner.session);
1620            let states = Arc::clone(&self.inner.download_states);
1621            let download_path = dir_path.clone();
1622            // The handler is wrapped in an Arc and invoked from the task. We
1623            // hold a Page clone so each Download can reference its page.
1624            let page = self.clone();
1625            let handler: Arc<dyn Fn(Download) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync> =
1626                Arc::new(move |d| Box::pin(handler(d)));
1627            tokio::spawn(async move {
1628                download_listener(
1629                    &mut rx,
1630                    &session,
1631                    &states,
1632                    &download_path,
1633                    page.clone(),
1634                    &handler,
1635                )
1636                .await;
1637            });
1638        }
1639    }
1640
1641    /// Register a handler invoked when a file-chooser dialog opens
1642    /// (`Page.fileChooserOpened`), mirroring Playwright's `page.on('filechooser')`.
1643    ///
1644    /// On first registration, chooser interception is enabled once via
1645    /// `Page.setInterceptFileChooserDialog { enabled: true }` (must be in place
1646    /// before the action that opens the chooser). The handler may inspect the
1647    /// [`FileChooser`] and call [`FileChooser::set_files`] to accept it.
1648    pub async fn on_filechooser<F, Fut>(&self, handler: F)
1649    where
1650        F: Fn(FileChooser) -> Fut + Send + Sync + 'static,
1651        Fut: Future<Output = ()> + Send + 'static,
1652    {
1653        // Lazily enable interception + spawn the listener task exactly once.
1654        if self
1655            .inner
1656            .filechooser_started
1657            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1658            .is_ok()
1659        {
1660            // Subscribe before enabling so the first fileChooserOpened event
1661            // is never missed.
1662            let mut rx = self.inner.session.subscribe();
1663            // Enable interception and await it inline so the caller knows it is
1664            // in place before triggering the chooser.
1665            let _ = self
1666                .inner
1667                .session
1668                .send(
1669                    "Page.setInterceptFileChooserDialog",
1670                    json!({ "enabled": true }),
1671                )
1672                .await;
1673
1674            // Wrap the handler in an Arc for invocation from the task.
1675            let page = self.clone();
1676            let handler: Arc<
1677                dyn Fn(FileChooser) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>>
1678                    + Send
1679                    + Sync,
1680            > = Arc::new(move |fc| Box::pin(handler(fc)));
1681            tokio::spawn(async move {
1682                while let Ok(ev) = rx.recv().await {
1683                    if ev.method == "Page.fileChooserOpened" {
1684                        let multiple = ev
1685                            .params
1686                            .get("mode")
1687                            .and_then(|v| v.as_str())
1688                            == Some("selectMultiple");
1689                        let backend_node_id = ev
1690                            .params
1691                            .get("backendNodeId")
1692                            .and_then(|v| v.as_i64());
1693                        let fc = FileChooser::new(page.clone(), backend_node_id, multiple);
1694                        let h = Arc::clone(&handler);
1695                        tokio::spawn(async move {
1696                            (h)(fc).await;
1697                        });
1698                    }
1699                }
1700            });
1701        }
1702    }
1703
1704    /// Register a handler invoked when a web/service/shared worker is created,
1705    /// mirroring Playwright's `page.on('worker')`.
1706    ///
1707    /// On first registration, flattened auto-attach is enabled on the page
1708    /// session (`Target.setAutoAttach { flatten: true }`) so workers show up as
1709    /// child sessions on the same connection. Each child target of type
1710    /// `worker`/`service_worker`/`shared_worker` surfaces via
1711    /// `Target.attachedToTarget` and is wrapped in a [`Worker`].
1712    ///
1713    /// Subscribe before enabling so the first `attachedToTarget` is never missed.
1714    pub async fn on_worker<F, Fut>(&self, handler: F)
1715    where
1716        F: Fn(Worker) -> Fut + Send + Sync + 'static,
1717        Fut: Future<Output = ()> + Send + 'static,
1718    {
1719        // Lazily enable page-session auto-attach + spawn the listener once.
1720        if self
1721            .inner
1722            .worker_started
1723            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1724            .is_ok()
1725        {
1726            // Subscribe BEFORE enabling auto-attach so the attach event for a
1727            // worker spawned right after is captured.
1728            let mut rx = self.inner.session.subscribe();
1729            let _ = self
1730                .inner
1731                .session
1732                .send(
1733                    "Target.setAutoAttach",
1734                    json!({ "autoAttach": true, "waitForDebuggerOnStart": false, "flatten": true }),
1735                )
1736                .await;
1737
1738            let connection = self.inner.session.connection().clone();
1739            let handler: Arc<
1740                dyn Fn(Worker) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
1741            > = Arc::new(move |w| Box::pin(handler(w)));
1742            tokio::spawn(async move {
1743                while let Ok(ev) = rx.recv().await {
1744                    if ev.method != "Target.attachedToTarget" {
1745                        continue;
1746                    }
1747                    // Defensive casing: `sessionId` (modern) then `session_id`.
1748                    let session_id = ev
1749                        .params
1750                        .get("sessionId")
1751                        .or_else(|| ev.params.get("session_id"))
1752                        .and_then(|v| v.as_str());
1753                    let target_info = match ev.params.get("targetInfo") {
1754                        Some(t) => t,
1755                        None => continue,
1756                    };
1757                    let target_type = target_info
1758                        .get("type")
1759                        .and_then(|v| v.as_str())
1760                        .unwrap_or("");
1761                    // Only workers — skip iframes, pages, popups, etc.
1762                    if !matches!(target_type, "worker" | "service_worker" | "shared_worker") {
1763                        continue;
1764                    }
1765                    let (sid, url) = match (session_id, target_info.get("url").and_then(|v| v.as_str())) {
1766                        (Some(sid), Some(url)) => (sid.to_string(), url.to_string()),
1767                        _ => continue,
1768                    };
1769                    let worker = Worker::new(connection.clone(), sid, url);
1770                    // Enable Runtime on the worker session so evaluate works.
1771                    worker.enable_runtime().await;
1772                    let h = Arc::clone(&handler);
1773                    tokio::spawn(async move {
1774                        (h)(worker).await;
1775                    });
1776                }
1777            });
1778        }
1779    }
1780
1781    // --- close ---
1782
1783    pub async fn close(&self) -> Result<()> {
1784        if *self.inner.closed.lock() {
1785            return Ok(());
1786        }
1787        *self.inner.closed.lock() = true;
1788        let handlers = std::mem::take(&mut *self.inner.on_close_handlers.lock());
1789        for h in handlers {
1790            tokio::spawn(async move { (h)().await; });
1791        }
1792        let _ = self
1793            .inner
1794            .browser
1795            .browser_session()
1796            .send("Target.closeTarget", json!({ "targetId": self.inner.target_id }))
1797            .await;
1798        Ok(())
1799    }
1800
1801    // --- network interception (Fetch domain) ---
1802
1803    /// Intercept requests matching `pattern` (a URL glob, `*`-wildcarded) and
1804    /// route them to `handler`. The handler must continue/fulfill/abort the
1805    /// [`Route`] (an unhandled route stalls the request).
1806    pub async fn route<F, Fut>(&self, pattern: &str, handler: F) -> Result<()>
1807    where
1808        F: Fn(Route) -> Fut + Send + Sync + 'static,
1809        Fut: Future<Output = ()> + Send + 'static,
1810    {
1811        let entry = RouteEntry {
1812            pattern: pattern.to_string(),
1813            handler: Arc::new(move |r| Box::pin(handler(r))),
1814        };
1815        self.inner.route_handlers.lock().push(entry);
1816        self.ensure_route_listener().await;
1817        self.refresh_fetch_patterns().await
1818    }
1819
1820    /// Remove the first route registered with exactly `pattern`.
1821    pub async fn unroute(&self, pattern: &str) -> Result<()> {
1822        let mut handlers = self.inner.route_handlers.lock();
1823        if let Some(pos) = handlers.iter().position(|e| e.pattern == pattern) {
1824            handlers.remove(pos);
1825        }
1826        drop(handlers);
1827        self.refresh_fetch_patterns().await
1828    }
1829
1830    /// Remove all routes.
1831    pub async fn unroute_all(&self) -> Result<()> {
1832        self.inner.route_handlers.lock().clear();
1833        let _ = self.inner.session.send("Fetch.disable", json!({})).await;
1834        Ok(())
1835    }
1836
1837    async fn ensure_route_listener(&self) {
1838        if self
1839            .inner
1840            .route_started
1841            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1842            .is_ok()
1843        {
1844            let rx = self.inner.session.subscribe();
1845            let session = Arc::clone(&self.inner.session);
1846            let store = Arc::clone(&self.inner.network_store);
1847            let handlers = Arc::clone(&self.inner.route_handlers);
1848            tokio::spawn(async move {
1849                route_listener(rx, session, store, handlers).await;
1850            });
1851        }
1852    }
1853
1854    async fn refresh_fetch_patterns(&self) -> Result<()> {
1855        let patterns: Vec<Value> = self
1856            .inner
1857            .route_handlers
1858            .lock()
1859            .iter()
1860            .map(|e| json!({ "urlPattern": e.pattern }))
1861            .collect();
1862        if patterns.is_empty() {
1863            let _ = self.inner.session.send("Fetch.disable", json!({})).await;
1864        } else {
1865            self.inner
1866                .session
1867                .send("Fetch.enable", json!({ "patterns": patterns }))
1868                .await?;
1869        }
1870        Ok(())
1871    }
1872
1873    // --- Tier 3 stubs (API completeness) ---
1874
1875    pub async fn pdf(&self, opts: Option<crate::options::PdfOptions>) -> Result<Vec<u8>> {
1876        let opts = opts.unwrap_or_default();
1877        let mut params = json!({ "printBackground": opts.print_background.unwrap_or(true) });
1878        if let Some(fmt) = opts.format {
1879            let (w, h) = fmt.inches();
1880            params["paperWidth"] = json!(w);
1881            params["paperHeight"] = json!(h);
1882        }
1883        if let Some(l) = opts.landscape {
1884            params["landscape"] = json!(l);
1885        }
1886        if let Some(s) = opts.scale {
1887            params["scale"] = json!(s);
1888        }
1889        if let Some(p) = opts.prefer_css_page_size {
1890            params["preferCSSPageSize"] = json!(p);
1891        }
1892        if let Some(m) = opts.margin {
1893            if let Some(v) = m.top {
1894                params["marginTop"] = json!(v);
1895            }
1896            if let Some(v) = m.bottom {
1897                params["marginBottom"] = json!(v);
1898            }
1899            if let Some(v) = m.left {
1900                params["marginLeft"] = json!(v);
1901            }
1902            if let Some(v) = m.right {
1903                params["marginRight"] = json!(v);
1904            }
1905        }
1906        let resp = self.inner.session.send("Page.printToPDF", params).await?;
1907        let data = resp
1908            .get("data")
1909            .and_then(|v| v.as_str())
1910            .ok_or_else(|| Error::ProtocolError("pdf missing data".into()))?;
1911        Ok(base64::engine::general_purpose::STANDARD
1912            .decode(data)
1913            .map_err(|e| Error::ProtocolError(format!("pdf base64 decode: {e}")))?)
1914    }
1915
1916    /// Capture an aria-snapshot (Playwright's YAML-ish accessibility-tree
1917    /// format) of the whole page.
1918    ///
1919    /// Fetches the full accessibility tree via `Accessibility.getFullAXTree`
1920    /// and serializes it with [`crate::aria_snapshot`]. The Accessibility
1921    /// domain is enabled best-effort first (some Chrome builds require it).
1922    pub async fn aria_snapshot(&self) -> Result<String> {
1923        let _ = self
1924            .inner
1925            .session
1926            .send("Accessibility.enable", json!({}))
1927            .await;
1928        let resp = self
1929            .inner
1930            .session
1931            .send("Accessibility.getFullAXTree", json!({}))
1932            .await?;
1933        let nodes = resp
1934            .get("nodes")
1935            .and_then(|v| v.as_array())
1936            .cloned()
1937            .unwrap_or_default();
1938        Ok(crate::aria_snapshot::serialize(&nodes, None))
1939    }
1940}
1941
1942fn attr_escape(s: &str) -> String {
1943    s.replace('\\', "\\\\").replace('"', "\\\"")
1944}
1945
1946/// Derive the CDP `securityOrigin` (`scheme://host[:port]`) from a URL string.
1947///
1948/// Opaque schemes (`about:`, `data:`, `blob:`) yield an empty origin, since
1949/// `DOMStorage` has nothing to scope to for them. A URL without a parseable
1950/// scheme also yields an empty origin.
1951fn derive_security_origin(url: &str) -> String {
1952    // Find the scheme delimiter.
1953    let after_scheme = match url.find("://") {
1954        Some(i) => &url[i + 3..],
1955        None => return String::new(),
1956    };
1957    let scheme = &url[..url.find("://").unwrap()];
1958    // Opaque/special schemes have no real origin.
1959    if matches!(scheme, "about" | "data" | "blob" | "javascript") {
1960        return String::new();
1961    }
1962    // The authority ends at the first `/`, `?`, or `#`.
1963    let end = after_scheme
1964        .find(|c: char| c == '/' || c == '?' || c == '#')
1965        .unwrap_or(after_scheme.len());
1966    let authority = &after_scheme[..end];
1967    if authority.is_empty() {
1968        return String::new();
1969    }
1970    format!("{scheme}://{authority}")
1971}
1972
1973/// Whether a JSON value is "truthy" for `wait_for_function`: `null`, `false`,
1974fn is_truthy(v: &Value) -> bool {
1975    match v {
1976        Value::Null => false,
1977        Value::Bool(b) => *b,
1978        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
1979        Value::String(s) => !s.is_empty(),
1980        _ => true,
1981    }
1982}
1983
1984/// Match `pattern` against `value`: exact, or a leading/trailing/both `*` glob
1985/// (good enough for `wait_for_url`).
1986fn glob_matches(pattern: &str, value: &str) -> bool {
1987    if pattern == value {
1988        return true;
1989    }
1990    // `*middle*` → contains.
1991    if pattern.len() >= 2 && pattern.starts_with('*') && pattern.ends_with('*') {
1992        return value.contains(&pattern[1..pattern.len() - 1]);
1993    }
1994    if let Some(rest) = pattern.strip_prefix('*') {
1995        if value.ends_with(rest) {
1996            return true;
1997        }
1998    }
1999    if let Some(rest) = pattern.strip_suffix('*') {
2000        if value.starts_with(rest) {
2001            return true;
2002        }
2003    }
2004    false
2005}
2006
2007/// Recursively walk a `Page.getFrameTree` response into the frame store.
2008fn walk_frame_tree(node: &Value, frames: &mut HashMap<String, FrameData>) {
2009    let frame = match node.get("frame") {
2010        Some(f) => f,
2011        None => return,
2012    };
2013    let id = match frame.get("id").and_then(|v| v.as_str()) {
2014        Some(s) => s.to_string(),
2015        None => return,
2016    };
2017    let data = FrameData {
2018        url: frame.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
2019        name: frame.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
2020        parent_id: frame
2021            .get("parentId")
2022            .and_then(|v| v.as_str())
2023            .map(String::from),
2024        detached: false,
2025    };
2026    frames.insert(id, data);
2027    if let Some(children) = node.get("childFrames").and_then(|v| v.as_array()) {
2028        for child in children {
2029            walk_frame_tree(child, frames);
2030        }
2031    }
2032}
2033
2034/// Background task: keep the frame store fresh from `Page.frame*` events.
2035async fn frame_tracker(
2036    mut rx: broadcast::Receiver<crate::cdp::CdpEvent>,
2037    frames: Arc<Mutex<HashMap<String, FrameData>>>,
2038    main_id: Arc<Mutex<Option<String>>>,
2039) {
2040    loop {
2041        match rx.recv().await {
2042            Ok(ev) => match ev.method.as_str() {
2043                "Page.frameNavigated" => {
2044                    if let Some(frame) = ev.params.get("frame") {
2045                        let id = match frame.get("id").and_then(|v| v.as_str()) {
2046                            Some(s) => s.to_string(),
2047                            None => continue,
2048                        };
2049                        let parent = frame
2050                            .get("parentId")
2051                            .and_then(|v| v.as_str())
2052                            .map(String::from);
2053                        let data = FrameData {
2054                            url: frame.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
2055                            name: frame.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
2056                            parent_id: parent.clone(),
2057                            detached: false,
2058                        };
2059                        if parent.is_none() {
2060                            *main_id.lock() = Some(id.clone());
2061                        }
2062                        frames.lock().insert(id, data);
2063                    }
2064                }
2065                "Page.frameDetached" => {
2066                    if let Some(id) = ev.params.get("frameId").and_then(|v| v.as_str()) {
2067                        if let Some(d) = frames.lock().get_mut(id) {
2068                            d.detached = true;
2069                        }
2070                    }
2071                }
2072                _ => {}
2073            },
2074            Err(broadcast::error::RecvError::Closed) => break,
2075            Err(broadcast::error::RecvError::Lagged(_)) => continue,
2076        }
2077    }
2078}
2079
2080/// Background task: dispatch `Page.downloadWillBegin`/`downloadProgress` events.
2081async fn download_listener(
2082    rx: &mut broadcast::Receiver<crate::cdp::CdpEvent>,
2083    _session: &Arc<CdpSession>,
2084    states: &Arc<Mutex<HashMap<String, DownloadStateCell>>>,
2085    download_path: &std::path::Path,
2086    page: Page,
2087    handler: &Arc<
2088        dyn Fn(Download) -> std::pin::Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
2089    >,
2090) {
2091    while let Ok(ev) = rx.recv().await {
2092        match ev.method.as_str() {
2093            "Page.downloadWillBegin" => {
2094                let guid = ev
2095                    .params
2096                    .get("guid")
2097                    .and_then(|v| v.as_str())
2098                    .unwrap_or("")
2099                    .to_string();
2100                let url = ev
2101                    .params
2102                    .get("url")
2103                    .and_then(|v| v.as_str())
2104                    .unwrap_or("")
2105                    .to_string();
2106                let suggested = ev
2107                    .params
2108                    .get("suggestedFilename")
2109                    .and_then(|v| v.as_str())
2110                    .unwrap_or("download")
2111                    .to_string();
2112                let cell = DownloadStateCell::new();
2113                states.lock().insert(guid.clone(), cell.clone());
2114                let dl = Download::new(
2115                    url,
2116                    suggested,
2117                    guid,
2118                    cell,
2119                    download_path.to_path_buf(),
2120                    page.clone(),
2121                );
2122                let h = Arc::clone(handler);
2123                tokio::spawn(async move {
2124                    (h)(dl).await;
2125                });
2126            }
2127            "Page.downloadProgress" => {
2128                let guid = match ev.params.get("guid").and_then(|v| v.as_str()) {
2129                    Some(g) => g.to_string(),
2130                    None => continue,
2131                };
2132                let state = ev
2133                    .params
2134                    .get("state")
2135                    .and_then(|v| v.as_str())
2136                    .unwrap_or("InProgress");
2137                let mapped = match state {
2138                    "Completed" => DownloadState::Completed,
2139                    "Canceled" => DownloadState::Canceled,
2140                    _ => DownloadState::InProgress,
2141                };
2142                if let Some(cell) = states.lock().get(&guid) {
2143                    *cell.state.lock() = mapped;
2144                }
2145            }
2146            _ => {}
2147        }
2148    }
2149}
2150
2151/// The distinct internal name under which the CDP binding for the public
2152/// `name` is registered (`Runtime.addBinding`). Keeping it separate avoids
2153/// clashing with the Promise-returning `window[name]` wrapper.
2154fn internal_binding_name(name: &str) -> String {
2155    format!("__pwcdpInvoke_{name}")
2156}
2157
2158/// The JS wrapper that turns `window[name]` into a Promise-returning function.
2159/// Each call assigns a monotonic id, registers its `resolve`, and forwards
2160/// `{ id, args }` (as a JSON string) to the CDP binding under
2161/// `__pwcdpInvoke_<name>`. The Rust listener resolves the promise later.
2162fn binding_wrapper_source(name: &str) -> String {
2163    // `name` is interpolated as a JSON string literal so it is safe to embed
2164    // even if it contains quotes/backslashes.
2165    let name_json = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".to_string());
2166    format!(
2167        r#"(function(){{
2168  var publicName = {name_json};
2169  var invokeName = "__pwcdpInvoke_" + publicName;
2170  var pending = (self.__pwcdpBindings = self.__pwcdpBindings || {{}});
2171  var map = pending[publicName] = pending[publicName] || {{ id: 0, cbs: {{}} }};
2172  self[publicName] = function(){{
2173    var args = Array.prototype.slice.call(arguments);
2174    return new Promise(function(resolve){{
2175      var id = ++map.id;
2176      map.cbs[id] = resolve;
2177      // The CDP binding (self[invokeName]) is installed by Runtime.addBinding;
2178      // if it is not present yet, surface an error so callers see a clear cause.
2179      if (typeof self[invokeName] !== "function") {{
2180        delete map.cbs[id];
2181        resolve({{ "__error": "binding not installed: " + invokeName }});
2182        return;
2183      }}
2184      try {{
2185        self[invokeName](JSON.stringify({{ id: id, args: args }}));
2186      }} catch (e) {{
2187        delete map.cbs[id];
2188        resolve({{ "__error": String((e && e.message) || e) }});
2189      }}
2190    }});
2191  }};
2192}})();
2193"#
2194    )
2195}
2196
2197/// Background task for one `expose_function` registration: on
2198/// `Runtime.bindingCalled` for the internal binding name, parse the payload,
2199/// run the Rust callback on its own task, then resolve the JS promise.
2200async fn binding_listener(
2201    rx: &mut broadcast::Receiver<crate::cdp::CdpEvent>,
2202    session: &Arc<CdpSession>,
2203    name: &str,
2204    handler: &ExposedBindingHandler,
2205) {
2206    let binding_name = internal_binding_name(name);
2207    loop {
2208        match rx.recv().await {
2209            Ok(ev) if ev.method == "Runtime.bindingCalled" => {
2210                let fired_name = ev
2211                    .params
2212                    .get("name")
2213                    .and_then(|v| v.as_str())
2214                    .unwrap_or("");
2215                if fired_name != binding_name {
2216                    continue;
2217                }
2218                let payload_str = match ev.params.get("payload").and_then(|v| v.as_str()) {
2219                    Some(s) => s,
2220                    None => continue,
2221                };
2222                let parsed: Value = match serde_json::from_str(payload_str) {
2223                    Ok(v) => v,
2224                    Err(_) => continue,
2225                };
2226                let id = match parsed.get("id").and_then(|v| v.as_i64()) {
2227                    Some(i) => i,
2228                    None => continue,
2229                };
2230                let args = parsed
2231                    .get("args")
2232                    .and_then(|v| v.as_array())
2233                    .cloned()
2234                    .unwrap_or_default();
2235
2236                let h = Arc::clone(handler);
2237                let session = Arc::clone(session);
2238                let name_owned = name.to_string();
2239                // Resolve on a separate task so a slow callback doesn't stall
2240                // event processing.
2241                tokio::spawn(async move {
2242                    let result = (h)(args).await;
2243                    let result_json = serde_json::to_string(&result)
2244                        .unwrap_or_else(|_| r#"{"__error":"serialize failed"}"#.to_string());
2245                    // Re-serialize as a JSON string literal so it can be passed
2246                    // to JSON.parse safely (handles quotes/newlines/etc.).
2247                    let result_str_literal = serde_json::to_string(&result_json)
2248                        .unwrap_or_else(|_| r#""{\"__error\":\"serialize failed\"}""#.to_string());
2249                    // Resolve the pending promise: look up the resolve fn by id,
2250                    // call it with the parsed result, then delete the entry.
2251                    let name_j = serde_json::to_string(&name_owned).unwrap_or_else(|_| "\"\"".into());
2252                    let expr = format!(
2253                        "(function(){{
2254  var m = (self.__pwcdpBindings && self.__pwcdpBindings[{name_j}]) || null;
2255  if (!m || !m.cbs || !m.cbs[{id}]) return;
2256  var fn_ = m.cbs[{id}]; delete m.cbs[{id}];
2257  fn_(JSON.parse({result_str_literal}));
2258}})();"
2259                    );
2260                    let _ = session
2261                        .send(
2262                            "Runtime.evaluate",
2263                            json!({ "expression": expr, "awaitPromise": false }),
2264                        )
2265                        .await;
2266                });
2267            }
2268            Ok(_) => {}
2269            Err(broadcast::error::RecvError::Closed) => break,
2270            Err(broadcast::error::RecvError::Lagged(_)) => continue,
2271        }
2272    }
2273}
2274
2275fn parse_console(params: &Value) -> ConsoleMessage {    let text = params
2276        .get("args")
2277        .and_then(|a| a.as_array())
2278        .map(|args| {
2279            args.iter()
2280                .filter_map(|a| {
2281                    a.get("value")
2282                        .and_then(|v| v.as_str())
2283                        .or_else(|| a.get("description").and_then(|v| v.as_str()))
2284                        .map(String::from)
2285                })
2286                .collect::<Vec<_>>()
2287                .join(" ")
2288        })
2289        .unwrap_or_default();
2290    let kind = params
2291        .get("type")
2292        .and_then(|v| v.as_str())
2293        .unwrap_or("log")
2294        .to_string();
2295    // Source location from the top call frame of the reported stack trace.
2296    let location = params
2297        .get("stackTrace")
2298        .and_then(|s| s.get("callFrames"))
2299        .and_then(|f| f.as_array())
2300        .and_then(|frames| frames.first())
2301        .map(|frame| {
2302            use crate::types::ConsoleMessageLocation;
2303            ConsoleMessageLocation {
2304                url: frame
2305                    .get("url")
2306                    .and_then(|v| v.as_str())
2307                    .map(String::from),
2308                line_number: frame.get("lineNumber").and_then(|v| v.as_i64()),
2309                column_number: frame.get("columnNumber").and_then(|v| v.as_i64()),
2310            }
2311        });
2312    ConsoleMessage {
2313        text,
2314        r#type: kind,
2315        location,
2316    }
2317}
2318
2319/// Background task: track the main-world execution context and keep the
2320/// selector engine installed after navigations. Also records per-frame
2321/// contexts (frame id → context id) from `auxData.frameId` so child-frame
2322/// evaluation/locator resolution can target a frame's own context.
2323///
2324/// `main_world_ctx` is set ONLY for the main (top) frame's default context —
2325/// a child iframe also has a "default" context, and accepting every default
2326/// context would let the child's context clobber the main one.
2327async fn context_tracker(
2328    mut rx: broadcast::Receiver<crate::cdp::CdpEvent>,
2329    session: Arc<CdpSession>,
2330    ctx_cell: Arc<Mutex<Option<i64>>>,
2331    frame_ctx_cell: Arc<Mutex<HashMap<String, i64>>>,
2332    main_frame_id: Arc<Mutex<Option<String>>>,
2333) {
2334    loop {
2335        match rx.recv().await {
2336            Ok(ev) => match ev.method.as_str() {
2337                "Runtime.executionContextCreated" => {
2338                    let context = match ev.params.get("context") {
2339                        Some(c) => c,
2340                        None => continue,
2341                    };
2342                    let aux = context.get("auxData");
2343                    let is_default = aux
2344                        .and_then(|a| a.get("type"))
2345                        .and_then(|t| t.as_str())
2346                        == Some("default");
2347                    let id = context.get("id").and_then(|i| i.as_i64());
2348                    let Some(id) = id else { continue };
2349
2350                    let frame_id = aux
2351                        .and_then(|a| a.get("frameId"))
2352                        .and_then(|v| v.as_str())
2353                        .map(String::from);
2354
2355                    if is_default {
2356                        // Record the frame → context mapping. Only default
2357                        // (main-world) contexts are tracked; utility/isolated
2358                        // worlds have their own ids and must not shadow a
2359                        // frame's main context.
2360                        if let Some(fid) = &frame_id {
2361                            frame_ctx_cell.lock().insert(fid.clone(), id);
2362                        }
2363
2364                        // The page-wide "main" context is the main frame's
2365                        // default context. A child iframe's default context
2366                        // must NOT overwrite it. Accept this context as main
2367                        // when (a) its frame is the known main frame, or
2368                        // (b) the main frame id is not yet known and no main
2369                        // context has been recorded yet (initial top-frame
2370                        // context typically arrives before the frame tree is
2371                        // populated).
2372                        let main_id = main_frame_id.lock().clone();
2373                        let is_main_frame = match (&main_id, &frame_id) {
2374                            (Some(m), Some(f)) => m == f,
2375                            (None, _) => ctx_cell.lock().is_none(),
2376                            // main frame known but this context has no frameId:
2377                            // treat as main only if none recorded yet.
2378                            (Some(_), None) => ctx_cell.lock().is_none(),
2379                        };
2380                        if is_main_frame {
2381                            *ctx_cell.lock() = Some(id);
2382                        }
2383                    }
2384
2385                    // Ensure the engine is installed in this context.
2386                    let s = Arc::clone(&session);
2387                    tokio::spawn(async move {
2388                        let _ = s
2389                            .send(
2390                                "Runtime.evaluate",
2391                                json!({
2392                                    "expression": selectors::INJECTED_SCRIPT,
2393                                    "contextId": id,
2394                                }),
2395                            )
2396                            .await;
2397                    });
2398                }
2399                "Runtime.executionContextsCleared" => {
2400                    *ctx_cell.lock() = None;
2401                    frame_ctx_cell.lock().clear();
2402                }
2403                "Runtime.executionContextDestroyed" => {
2404                    let id = ev
2405                        .params
2406                        .get("executionContextId")
2407                        .and_then(|v| v.as_i64());
2408                    {
2409                        let mut cell = ctx_cell.lock();
2410                        if id.is_some() && *cell == id {
2411                            *cell = None;
2412                        }
2413                    }
2414                    if let Some(id) = id {
2415                        let mut frames = frame_ctx_cell.lock();
2416                        // Drop any frame→context entries that pointed at the
2417                        // destroyed context (a new contextCreated will repopulate).
2418                        frames.retain(|_, v| *v != id);
2419                    }
2420                }
2421                _ => {}
2422            },
2423            Err(broadcast::error::RecvError::Closed) => break,
2424            Err(broadcast::error::RecvError::Lagged(_)) => continue,
2425        }
2426    }
2427}
2428
2429/// A frame lifecycle event payload for `on_frameattached` /
2430/// `on_framedetached` / `on_framenavigated`. Carries the frame id, its
2431/// parent's id (if any), and — for navigations — the frame's new URL.
2432#[derive(Debug, Clone)]
2433pub struct FrameEvent {
2434    /// The id of the frame the event concerns.
2435    pub id: String,
2436    /// The parent frame id, if the frame has one (the main frame does not).
2437    pub parent_id: Option<String>,
2438    /// The frame's URL. Populated for navigations; empty for attach/detach.
2439    pub url: String,
2440}
2441
2442impl FrameEvent {
2443    /// Parse `Page.frameAttached` params (`params.frame.{id,parentFrameId}`).
2444    pub(crate) fn from_attached(params: &Value) -> Option<Self> {
2445        let frame = params.get("frame").unwrap_or(params);
2446        let id = frame.get("id").and_then(|v| v.as_str())?.to_string();
2447        let parent_id = frame
2448            .get("parentFrameId")
2449            .and_then(|v| v.as_str())
2450            .map(String::from);
2451        Some(Self {
2452            id,
2453            parent_id,
2454            url: String::new(),
2455        })
2456    }
2457
2458    /// Parse `Page.frameDetached` params (`params.frameId`, plus any `frame`).
2459    pub(crate) fn from_detached(params: &Value) -> Option<Self> {
2460        // frameDetached carries a bare `frameId`; `frame` may be absent.
2461        let id = params
2462            .get("frameId")
2463            .or_else(|| params.get("frame"))
2464            .and_then(|v| {
2465                v.as_str()
2466                    .map(String::from)
2467                    .or_else(|| v.get("id").and_then(|i| i.as_str()).map(String::from))
2468            })?;
2469        let parent_id = params
2470            .get("frame")
2471            .and_then(|f| f.get("parentFrameId"))
2472            .and_then(|v| v.as_str())
2473            .map(String::from);
2474        Some(Self {
2475            id,
2476            parent_id,
2477            url: String::new(),
2478        })
2479    }
2480
2481    /// Parse `Page.frameNavigated` params (`params.frame.{id,url,parentId}`).
2482    pub(crate) fn from_navigated(params: &Value) -> Option<Self> {
2483        let frame = params.get("frame")?;
2484        let id = frame.get("id").and_then(|v| v.as_str())?.to_string();
2485        let url = frame
2486            .get("url")
2487            .and_then(|v| v.as_str())
2488            .unwrap_or("")
2489            .to_string();
2490        let parent_id = frame
2491            .get("parentId")
2492            .and_then(|v| v.as_str())
2493            .map(String::from);
2494        Some(Self {
2495            id,
2496            parent_id,
2497            url,
2498        })
2499    }
2500}
2501
2502/// A JavaScript dialog (alert/confirm/prompt/beforeunload).
2503pub struct Dialog {
2504    message: String,
2505    kind: String,
2506    session: Arc<CdpSession>,
2507}
2508
2509impl Dialog {
2510    pub(crate) fn from_event(params: &Value, session: &Arc<CdpSession>) -> Self {
2511        Self {
2512            message: params
2513                .get("message")
2514                .and_then(|v| v.as_str())
2515                .unwrap_or("")
2516                .to_string(),
2517            kind: params
2518                .get("type")
2519                .and_then(|v| v.as_str())
2520                .unwrap_or("alert")
2521                .to_string(),
2522            session: Arc::clone(session),
2523        }
2524    }
2525
2526    pub fn message(&self) -> &str {
2527        &self.message
2528    }
2529
2530    pub fn kind(&self) -> &str {
2531        &self.kind
2532    }
2533
2534    pub async fn accept(&self, prompt_text: Option<&str>) -> Result<()> {
2535        let mut p = json!({ "accept": true });
2536        if let Some(t) = prompt_text {
2537            p["promptText"] = json!(t);
2538        }
2539        self.session
2540            .send("Page.handleJavaScriptDialog", p)
2541            .await
2542            .map(|_| ())
2543    }
2544
2545    pub async fn dismiss(&self) -> Result<()> {
2546        self.session
2547            .send("Page.handleJavaScriptDialog", json!({ "accept": false }))
2548            .await
2549            .map(|_| ())
2550    }
2551}