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