Skip to main content

rightkit_browser/
page.rs

1use crate::error::{cdp, BrowserError, Result};
2use crate::keys::{self, KeyDef};
3use crate::policy::{ActionKind, AdmissionRequest, BlockedRequest, Guard};
4use chromiumoxide::cdp::browser_protocol::dom::SetFileInputFilesParams;
5use chromiumoxide::cdp::browser_protocol::fetch::{
6    ContinueRequestParams, EnableParams as FetchEnableParams, EventRequestPaused,
7    FailRequestParams, RequestPattern,
8};
9use chromiumoxide::cdp::browser_protocol::input::{
10    DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
11    MouseButton,
12};
13use chromiumoxide::cdp::browser_protocol::network::ErrorReason;
14use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
15use chromiumoxide::cdp::js_protocol::runtime::{EventConsoleApiCalled, EventExceptionThrown};
16use chromiumoxide::page::{Page, ScreenshotParams};
17use futures::StreamExt;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex as StdMutex};
23use std::time::{Duration, Instant};
24use tokio::sync::Mutex;
25use tokio::task::JoinHandle;
26
27pub const MAX_CONSOLE_LINES: usize = 500;
28const MAX_UPLOAD_BYTES: u64 = 64 * 1024 * 1024;
29
30#[derive(Clone, Debug, Serialize)]
31pub struct ConsoleLine {
32    pub level: String,
33    pub text: String,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
37pub struct Rect {
38    pub x: f64,
39    pub y: f64,
40    pub width: f64,
41    pub height: f64,
42}
43
44#[derive(Clone, Debug, Serialize)]
45pub struct ObservedNode {
46    /// `r<epoch>.<n>`; valid only while the page stays unchanged.
47    pub node_ref: String,
48    pub role: String,
49    pub name: String,
50    pub tag: String,
51    pub selector: String,
52    pub visible: bool,
53    pub disabled: bool,
54    pub bounds: Rect,
55}
56
57#[derive(Clone, Debug, Serialize)]
58pub struct Observation {
59    pub page_id: String,
60    pub epoch: u64,
61    pub url: String,
62    pub title: String,
63    pub viewport: Rect,
64    pub nodes: Vec<ObservedNode>,
65    pub console_errors: usize,
66    /// Refs/roles/names added, removed, or changed since the previous observation.
67    pub added: Vec<String>,
68    pub removed: Vec<String>,
69}
70
71/// What an action addresses.
72#[derive(Clone, Debug)]
73pub enum Target {
74    /// Ref from [`BrowserPage::observe`]; checked for staleness.
75    Ref(String),
76    /// A CSS selector that must match exactly one element.
77    Selector(String),
78    /// Viewport coordinates.
79    Point(f64, f64),
80}
81
82impl Target {
83    pub fn css(s: impl Into<String>) -> Self {
84        Target::Selector(s.into())
85    }
86    pub fn node_ref(s: impl Into<String>) -> Self {
87        Target::Ref(s.into())
88    }
89}
90
91#[derive(Clone, Debug, Deserialize)]
92struct RawNode {
93    role: String,
94    name: String,
95    tag: String,
96    selector: String,
97    visible: bool,
98    disabled: bool,
99    #[serde(default)]
100    sensitive: bool,
101    bounds: Rect,
102}
103
104#[derive(Clone, Debug)]
105struct RefEntry {
106    selector: String,
107    tag: String,
108    role: String,
109    name: String,
110}
111
112#[derive(Default)]
113struct ObsState {
114    epoch: u64,
115    fingerprint: String,
116    refs: HashMap<String, RefEntry>,
117    previous: HashMap<String, String>,
118}
119
120pub struct BrowserPage {
121    id: String,
122    inner: Page,
123    console: Arc<Mutex<Vec<ConsoleLine>>>,
124    obs: StdMutex<ObsState>,
125    mouse: StdMutex<(f64, f64)>,
126    buttons: StdMutex<i64>,
127    /// Serializes mutations so concurrent callers commit in order.
128    lane: Mutex<()>,
129    upload_root: Option<PathBuf>,
130    pumps: StdMutex<Vec<JoinHandle<()>>>,
131    guard: Arc<Guard>,
132    blocked: Arc<StdMutex<Vec<BlockedRequest>>>,
133}
134
135impl BrowserPage {
136    pub(crate) async fn attach(
137        id: String,
138        inner: Page,
139        upload_root: Option<PathBuf>,
140        guard: Arc<Guard>,
141    ) -> Result<Self> {
142        let console = Arc::new(Mutex::new(Vec::new()));
143        let mut pumps = Vec::new();
144        let mut ev = inner
145            .event_listener::<EventConsoleApiCalled>()
146            .await
147            .map_err(cdp)?;
148        let sink = console.clone();
149        pumps.push(tokio::spawn(async move {
150            while let Some(e) = ev.next().await {
151                let text = e
152                    .args
153                    .iter()
154                    .map(|a| match (&a.value, &a.description) {
155                        (Some(Value::String(s)), _) => s.clone(),
156                        (Some(v), _) => v.to_string(),
157                        (None, Some(d)) => d.clone(),
158                        _ => String::new(),
159                    })
160                    .collect::<Vec<_>>()
161                    .join(" ");
162                push_capped(
163                    &sink,
164                    ConsoleLine {
165                        level: format!("{:?}", e.r#type).to_lowercase(),
166                        text,
167                    },
168                )
169                .await;
170            }
171        }));
172        let mut ex = inner
173            .event_listener::<EventExceptionThrown>()
174            .await
175            .map_err(cdp)?;
176        let sink = console.clone();
177        pumps.push(tokio::spawn(async move {
178            while let Some(e) = ex.next().await {
179                let d = &e.exception_details;
180                let text = d
181                    .exception
182                    .as_ref()
183                    .and_then(|x| x.description.clone())
184                    .unwrap_or_else(|| d.text.clone());
185                push_capped(
186                    &sink,
187                    ConsoleLine {
188                        level: "exception".into(),
189                        text,
190                    },
191                )
192                .await;
193            }
194        }));
195        let blocked = Arc::new(StdMutex::new(Vec::new()));
196        if !guard.network.unrestricted {
197            // Pause every request (document, redirect hops, subresources) and
198            // decide before it leaves the browser. Enabled before any navigation.
199            let mut paused = inner
200                .event_listener::<EventRequestPaused>()
201                .await
202                .map_err(cdp)?;
203            let page = inner.clone();
204            let g = guard.clone();
205            let sink = blocked.clone();
206            pumps.push(tokio::spawn(async move {
207                while let Some(e) = paused.next().await {
208                    let page = page.clone();
209                    let g = g.clone();
210                    let sink = sink.clone();
211                    tokio::spawn(async move {
212                        let kind = format!("{:?}", e.resource_type);
213                        match g.network.check(&e.request.url, &kind).await {
214                            Ok(()) => {
215                                let _ = page
216                                    .execute(ContinueRequestParams::new(e.request_id.clone()))
217                                    .await;
218                            }
219                            Err(reason) => {
220                                let _ = page
221                                    .execute(FailRequestParams::new(
222                                        e.request_id.clone(),
223                                        ErrorReason::BlockedByClient,
224                                    ))
225                                    .await;
226                                g.emit(crate::policy::BrowserEvent::Denied {
227                                    session_id: g.session_id.clone(),
228                                    what: kind,
229                                    reason: reason.clone(),
230                                });
231                                let mut b = sink.lock().unwrap();
232                                if b.len() < 500 {
233                                    b.push(BlockedRequest {
234                                        url: e.request.url.clone(),
235                                        reason,
236                                    });
237                                }
238                            }
239                        }
240                    });
241                }
242            }));
243            let en = FetchEnableParams {
244                patterns: Some(vec![RequestPattern {
245                    url_pattern: Some("*".into()),
246                    resource_type: None,
247                    request_stage: None,
248                }]),
249                ..Default::default()
250            };
251            inner.execute(en).await.map_err(cdp)?;
252        }
253        Ok(Self {
254            id,
255            inner,
256            console,
257            obs: StdMutex::new(ObsState::default()),
258            mouse: StdMutex::new((0.0, 0.0)),
259            buttons: StdMutex::new(0),
260            lane: Mutex::new(()),
261            upload_root,
262            pumps: StdMutex::new(pumps),
263            guard,
264            blocked,
265        })
266    }
267
268    pub fn id(&self) -> &str {
269        &self.id
270    }
271
272    /// Requests the network policy refused on this page (redirect hops and subresources included).
273    pub fn blocked_requests(&self) -> Vec<BlockedRequest> {
274        self.blocked.lock().unwrap().clone()
275    }
276
277    /// Typed pre-effect admission. Reads no page state beyond the current URL.
278    async fn admit(
279        &self,
280        action: ActionKind,
281        url: Option<String>,
282        target: Option<String>,
283        detail: Option<String>,
284    ) -> Result<()> {
285        let url = match url {
286            Some(u) => Some(u),
287            None if self.guard.admission.is_some() => Some(self.url().await),
288            None => None,
289        };
290        self.guard
291            .admit(AdmissionRequest {
292                session_id: self.guard.session_id.clone(),
293                page_id: self.id.clone(),
294                action,
295                url,
296                target,
297                detail,
298            })
299            .await
300    }
301
302    /// Escape hatch to the underlying chromiumoxide page.
303    pub fn raw(&self) -> &Page {
304        &self.inner
305    }
306
307    pub(crate) fn abort_pumps(&self) {
308        if let Ok(mut p) = self.pumps.lock() {
309            for h in p.drain(..) {
310                h.abort();
311            }
312        }
313    }
314
315    pub(crate) async fn close_inner(&self) -> Result<()> {
316        self.abort_pumps();
317        self.inner.clone().close().await.map_err(cdp)
318    }
319
320    pub async fn bring_to_front(&self) -> Result<()> {
321        self.inner.bring_to_front().await.map_err(cdp)?;
322        Ok(())
323    }
324
325    // ---- navigation ------------------------------------------------------
326
327    fn invalidate(&self) {
328        let mut o = self.obs.lock().unwrap();
329        o.epoch += 1;
330        o.refs.clear();
331        o.fingerprint.clear();
332    }
333
334    pub async fn goto(&self, url: &str) -> Result<()> {
335        self.admit(ActionKind::Navigate, Some(url.to_string()), None, None)
336            .await?;
337        self.guard.check_url(url, "Navigation").await?;
338        let _l = self.lane.lock().await;
339        self.inner
340            .goto(url)
341            .await
342            .map_err(|e| BrowserError::Cdp(format!("navigate '{url}': {e}")))?;
343        self.inner.wait_for_navigation().await.map_err(cdp)?;
344        self.invalidate();
345        Ok(())
346    }
347
348    pub async fn reload(&self) -> Result<()> {
349        self.admit(ActionKind::Reload, None, None, None).await?;
350        let _l = self.lane.lock().await;
351        self.inner.reload().await.map_err(cdp)?;
352        self.inner.wait_for_navigation().await.map_err(cdp)?;
353        self.invalidate();
354        Ok(())
355    }
356
357    pub async fn history(&self, delta: i32) -> Result<()> {
358        self.admit(
359            ActionKind::History,
360            None,
361            None,
362            Some(format!("delta={delta}")),
363        )
364        .await?;
365        let _l = self.lane.lock().await;
366        self.inner
367            .evaluate(format!("history.go({delta})"))
368            .await
369            .map_err(cdp)?;
370        tokio::time::sleep(Duration::from_millis(150)).await;
371        self.inner.wait_for_navigation().await.map_err(cdp)?;
372        self.invalidate();
373        Ok(())
374    }
375
376    pub async fn url(&self) -> String {
377        self.inner.url().await.ok().flatten().unwrap_or_default()
378    }
379
380    pub async fn title(&self) -> String {
381        self.inner
382            .get_title()
383            .await
384            .ok()
385            .flatten()
386            .unwrap_or_default()
387    }
388
389    /// Rendered text (`document.body.innerText`).
390    pub async fn text(&self) -> Result<String> {
391        self.eval_internal("document.body ? document.body.innerText : ''")
392            .await
393            .map(|v| v.as_str().unwrap_or_default().to_string())
394    }
395
396    /// Evaluate a JS expression and return its JSON value. Script can navigate and
397    /// act, so it is admitted like any other effect.
398    pub async fn eval(&self, expr: &str) -> Result<Value> {
399        self.admit(
400            ActionKind::Eval,
401            None,
402            None,
403            Some(expr.chars().take(2000).collect()),
404        )
405        .await?;
406        self.eval_internal(expr).await
407    }
408
409    /// Runtime-owned evaluation (observation, geometry); never user script.
410    async fn eval_internal(&self, expr: &str) -> Result<Value> {
411        let r = self.inner.evaluate(expr).await.map_err(cdp)?;
412        Ok(r.value().cloned().unwrap_or(Value::Null))
413    }
414
415    pub async fn set_viewport(&self, width: u32, height: u32, scale: f64) -> Result<()> {
416        use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
417        self.inner
418            .execute(SetDeviceMetricsOverrideParams::new(
419                i64::from(width),
420                i64::from(height),
421                scale,
422                false,
423            ))
424            .await
425            .map_err(cdp)?;
426        Ok(())
427    }
428
429    // ---- observation -----------------------------------------------------
430
431    pub async fn observe(&self) -> Result<Observation> {
432        let _l = self.lane.lock().await;
433        let raw: Vec<RawNode> = self
434            .inner
435            .evaluate(OBSERVE_JS)
436            .await
437            .map_err(cdp)?
438            .into_value()
439            .map_err(|e| BrowserError::Cdp(format!("decode observation: {e}")))?;
440        let viewport: Rect = self
441            .inner
442            .evaluate("({x:0,y:0,width:window.innerWidth,height:window.innerHeight})")
443            .await
444            .map_err(cdp)?
445            .into_value()
446            .map_err(cdp)?;
447        let fingerprint = raw
448            .iter()
449            .map(|n| {
450                format!(
451                    "{}|{}|{}|{}|{}",
452                    n.selector, n.role, n.name, n.visible, n.disabled
453                )
454            })
455            .collect::<Vec<_>>()
456            .join("\n");
457        let (epoch, nodes, added, removed) = {
458            let mut o = self.obs.lock().unwrap();
459            if o.fingerprint != fingerprint {
460                o.epoch += 1;
461                o.fingerprint = fingerprint;
462            }
463            let epoch = o.epoch;
464            o.refs.clear();
465            let mut cur = HashMap::new();
466            let mut nodes = Vec::new();
467            for (i, n) in raw.into_iter().enumerate() {
468                let name = if n.sensitive {
469                    "••••••••".to_string()
470                } else {
471                    n.name.clone()
472                };
473                let node_ref = format!("r{epoch}.{}", i + 1);
474                if !n.selector.is_empty() {
475                    o.refs.insert(
476                        node_ref.clone(),
477                        RefEntry {
478                            selector: n.selector.clone(),
479                            tag: n.tag.clone(),
480                            role: n.role.clone(),
481                            name: name.clone(),
482                        },
483                    );
484                    cur.insert(n.selector.clone(), format!("{}|{}", n.role, name));
485                }
486                nodes.push(ObservedNode {
487                    node_ref,
488                    role: n.role,
489                    name,
490                    tag: n.tag,
491                    selector: n.selector,
492                    visible: n.visible,
493                    disabled: n.disabled,
494                    bounds: n.bounds,
495                });
496            }
497            let added = cur
498                .keys()
499                .filter(|k| !o.previous.contains_key(*k))
500                .cloned()
501                .collect();
502            let removed = o
503                .previous
504                .keys()
505                .filter(|k| !cur.contains_key(*k))
506                .cloned()
507                .collect();
508            o.previous = cur;
509            (epoch, nodes, added, removed)
510        };
511        let console_errors = self
512            .console
513            .lock()
514            .await
515            .iter()
516            .filter(|l| matches!(l.level.as_str(), "error" | "exception"))
517            .count();
518        Ok(Observation {
519            page_id: self.id.clone(),
520            epoch,
521            url: self.url().await,
522            title: self.title().await,
523            viewport,
524            nodes,
525            console_errors,
526            added,
527            removed,
528        })
529    }
530
531    /// Resolve a target to a unique CSS selector, rejecting stale refs.
532    async fn resolve(&self, target: &Target) -> Result<Option<String>> {
533        match target {
534            Target::Point(..) => Ok(None),
535            Target::Selector(s) => {
536                self.unique(s).await?;
537                Ok(Some(s.clone()))
538            }
539            Target::Ref(r) => {
540                let (entry, current_epoch) = {
541                    let o = self.obs.lock().unwrap();
542                    (o.refs.get(r).cloned(), o.epoch)
543                };
544                let entry = entry.ok_or_else(|| BrowserError::StaleRef(r.clone()))?;
545                let ref_epoch: u64 = r
546                    .trim_start_matches('r')
547                    .split('.')
548                    .next()
549                    .and_then(|e| e.parse().ok())
550                    .unwrap_or(0);
551                if ref_epoch != current_epoch {
552                    return Err(BrowserError::StaleRef(r.clone()));
553                }
554                // Identity check: the element behind the selector must still look
555                // like what was observed, else the page changed under the ref.
556                let sig = self
557                    .eval(&format!(
558                        "(()=>{{const l=document.querySelectorAll({s});if(l.length!==1)return null;const e=l[0];\
559                         return {{tag:e.tagName.toLowerCase(),name:(e.getAttribute('aria-label')||e.innerText||e.value||e.getAttribute('placeholder')||'').trim().slice(0,160)}}}})()",
560                        s = js(&entry.selector)
561                    ))
562                    .await?;
563                let ok = sig.get("tag").and_then(Value::as_str) == Some(entry.tag.as_str())
564                    && sig.get("name").and_then(Value::as_str) == Some(entry.name.as_str())
565                    || (sig.get("tag").and_then(Value::as_str) == Some(entry.tag.as_str())
566                        && matches!(entry.tag.as_str(), "input" | "textarea" | "select")); // value changes as the user types
567                if !ok {
568                    return Err(BrowserError::StaleRef(r.clone()));
569                }
570                let _ = &entry.role;
571                Ok(Some(entry.selector))
572            }
573        }
574    }
575
576    async fn unique(&self, selector: &str) -> Result<()> {
577        let n = self
578            .eval_internal(&format!(
579                "document.querySelectorAll({}).length",
580                js(selector)
581            ))
582            .await?
583            .as_u64()
584            .unwrap_or(0);
585        match n {
586            1 => Ok(()),
587            0 => Err(BrowserError::NoElement(selector.into())),
588            n => Err(BrowserError::Ambiguous(selector.into(), n)),
589        }
590    }
591
592    /// Scroll into view, return the centre point, and verify nothing covers it.
593    async fn point_of(&self, target: &Target) -> Result<(f64, f64)> {
594        if let Target::Point(x, y) = target {
595            return Ok((*x, *y));
596        }
597        let sel = self.resolve(target).await?.expect("selector");
598        let v = self
599            .eval(&format!(
600                "(()=>{{const e=document.querySelector({s});e.scrollIntoView({{block:'center',inline:'center'}});\
601                 const r=e.getBoundingClientRect();if(r.width<=0||r.height<=0)return null;\
602                 const x=r.x+r.width/2,y=r.y+r.height/2;const t=document.elementFromPoint(x,y);\
603                 const hit=!!t&&(t===e||e.contains(t)||t.contains(e));return {{x,y,hit}}}})()",
604                s = js(&sel)
605            ))
606            .await?;
607        if v.is_null() {
608            return Err(BrowserError::NotInteractable(sel));
609        }
610        if v.get("hit").and_then(Value::as_bool) != Some(true) {
611            return Err(BrowserError::NotInteractable(format!(
612                "{sel} (covered by another element)"
613            )));
614        }
615        Ok((
616            v["x"].as_f64().unwrap_or(0.0),
617            v["y"].as_f64().unwrap_or(0.0),
618        ))
619    }
620
621    // ---- real pointer input ----------------------------------------------
622
623    async fn mouse_event(
624        &self,
625        kind: DispatchMouseEventType,
626        x: f64,
627        y: f64,
628        button: MouseButton,
629        click_count: i64,
630    ) -> Result<()> {
631        let mut p = DispatchMouseEventParams::new(kind.clone(), x, y);
632        p.button = Some(button);
633        p.click_count = Some(click_count);
634        p.buttons = Some(match kind {
635            DispatchMouseEventType::MousePressed => 1,
636            DispatchMouseEventType::MouseReleased => 0,
637            _ => *self.buttons.lock().unwrap(),
638        });
639        self.inner.execute(p).await.map_err(cdp)?;
640        *self.mouse.lock().unwrap() = (x, y);
641        Ok(())
642    }
643
644    async fn move_to(&self, x: f64, y: f64) -> Result<()> {
645        self.mouse_event(
646            DispatchMouseEventType::MouseMoved,
647            x,
648            y,
649            MouseButton::None,
650            0,
651        )
652        .await
653    }
654
655    pub async fn hover(&self, target: &Target) -> Result<()> {
656        self.admit(ActionKind::Hover, None, Some(describe(target)), None)
657            .await?;
658        let _l = self.lane.lock().await;
659        let (x, y) = self.point_of(target).await?;
660        self.move_to(x, y).await
661    }
662
663    pub async fn click(&self, target: &Target) -> Result<()> {
664        self.admit(ActionKind::Click, None, Some(describe(target)), None)
665            .await?;
666        let _l = self.lane.lock().await;
667        self.click_n(target, 1).await
668    }
669
670    pub async fn double_click(&self, target: &Target) -> Result<()> {
671        self.admit(ActionKind::DoubleClick, None, Some(describe(target)), None)
672            .await?;
673        let _l = self.lane.lock().await;
674        self.click_n(target, 2).await
675    }
676
677    /// Caller holds the lane.
678    async fn click_n(&self, target: &Target, count: i64) -> Result<()> {
679        let (x, y) = self.point_of(target).await?;
680        self.move_to(x, y).await?;
681        for n in 1..=count {
682            self.mouse_event(
683                DispatchMouseEventType::MousePressed,
684                x,
685                y,
686                MouseButton::Left,
687                n,
688            )
689            .await?;
690            self.mouse_event(
691                DispatchMouseEventType::MouseReleased,
692                x,
693                y,
694                MouseButton::Left,
695                n,
696            )
697            .await?;
698        }
699        Ok(())
700    }
701
702    /// Press at `from`, move in `steps` increments, release at `to`.
703    pub async fn drag(&self, from: &Target, to: &Target, steps: u32) -> Result<()> {
704        self.admit(
705            ActionKind::Drag,
706            None,
707            Some(format!("{} -> {}", describe(from), describe(to))),
708            None,
709        )
710        .await?;
711        let _l = self.lane.lock().await;
712        let (x0, y0) = self.point_of(from).await?;
713        let (x1, y1) = self.point_of(to).await?;
714        self.move_to(x0, y0).await?;
715        self.mouse_event(
716            DispatchMouseEventType::MousePressed,
717            x0,
718            y0,
719            MouseButton::Left,
720            1,
721        )
722        .await?;
723        *self.buttons.lock().unwrap() = 1;
724        let steps = steps.max(1);
725        for i in 1..=steps {
726            let t = f64::from(i) / f64::from(steps);
727            self.mouse_event(
728                DispatchMouseEventType::MouseMoved,
729                x0 + (x1 - x0) * t,
730                y0 + (y1 - y0) * t,
731                MouseButton::Left,
732                0,
733            )
734            .await?;
735        }
736        *self.buttons.lock().unwrap() = 0;
737        self.mouse_event(
738            DispatchMouseEventType::MouseReleased,
739            x1,
740            y1,
741            MouseButton::Left,
742            1,
743        )
744        .await
745    }
746
747    /// Mouse-wheel scroll at the target (or viewport centre when `None`).
748    pub async fn wheel(&self, at: Option<&Target>, dx: f64, dy: f64) -> Result<()> {
749        self.admit(
750            ActionKind::Wheel,
751            None,
752            at.map(describe),
753            Some(format!("dx={dx} dy={dy}")),
754        )
755        .await?;
756        let _l = self.lane.lock().await;
757        let (x, y) = match at {
758            Some(t) => self.point_of(t).await?,
759            None => {
760                let v = self
761                    .eval_internal("({x:innerWidth/2,y:innerHeight/2})")
762                    .await?;
763                (
764                    v["x"].as_f64().unwrap_or(100.0),
765                    v["y"].as_f64().unwrap_or(100.0),
766                )
767            }
768        };
769        let mut p = DispatchMouseEventParams::new(DispatchMouseEventType::MouseWheel, x, y);
770        p.delta_x = Some(dx);
771        p.delta_y = Some(dy);
772        self.inner.execute(p).await.map_err(cdp)?;
773        Ok(())
774    }
775
776    // ---- real keyboard input ---------------------------------------------
777
778    async fn key_event(
779        &self,
780        kind: DispatchKeyEventType,
781        d: &KeyDef,
782        mods: i64,
783        with_text: bool,
784    ) -> Result<()> {
785        let mut p = DispatchKeyEventParams::new(kind);
786        p.modifiers = Some(mods);
787        p.key = Some(d.key.clone());
788        if !d.code.is_empty() {
789            p.code = Some(d.code.clone());
790        }
791        p.windows_virtual_key_code = Some(d.vk);
792        p.native_virtual_key_code = Some(d.vk);
793        if with_text {
794            p.text = d.text.clone();
795            p.unmodified_text = d.text.clone();
796        }
797        self.inner.execute(p).await.map_err(cdp)?;
798        Ok(())
799    }
800
801    async fn press_locked(&self, spec: &str) -> Result<()> {
802        let (mods, mod_keys, def) = keys::parse_chord(spec)?;
803        for (_, m) in &mod_keys {
804            self.key_event(DispatchKeyEventType::RawKeyDown, m, mods, false)
805                .await?;
806        }
807        // Shortcut chords (Ctrl/Meta/Alt) carry no text.
808        let text = mods & (keys::CTRL | keys::META | keys::ALT) == 0;
809        let kind = if text && def.text.is_some() {
810            DispatchKeyEventType::KeyDown
811        } else {
812            DispatchKeyEventType::RawKeyDown
813        };
814        self.key_event(kind, &def, mods, text).await?;
815        self.key_event(DispatchKeyEventType::KeyUp, &def, mods, false)
816            .await?;
817        for (_, m) in mod_keys.iter().rev() {
818            self.key_event(DispatchKeyEventType::KeyUp, m, 0, false)
819                .await?;
820        }
821        Ok(())
822    }
823
824    /// Press a key or chord (`Enter`, `Tab`, `Control+a`) on the focused element.
825    pub async fn press(&self, spec: &str) -> Result<()> {
826        self.admit(ActionKind::Press, None, Some(spec.to_string()), None)
827            .await?;
828        let _l = self.lane.lock().await;
829        self.press_locked(spec).await
830    }
831
832    /// Type text one key event at a time into the focused element.
833    pub async fn type_text(&self, text: &str) -> Result<()> {
834        self.admit(
835            ActionKind::TypeText,
836            None,
837            None,
838            Some(format!("chars={}", text.chars().count())),
839        )
840        .await?;
841        let _l = self.lane.lock().await;
842        self.type_locked(text).await
843    }
844
845    async fn type_locked(&self, text: &str) -> Result<()> {
846        for c in text.chars() {
847            let d = keys::char_key(c);
848            self.key_event(DispatchKeyEventType::KeyDown, &d, 0, true)
849                .await?;
850            self.key_event(DispatchKeyEventType::KeyUp, &d, 0, false)
851                .await?;
852        }
853        Ok(())
854    }
855
856    /// Click the target to focus it, then type.
857    pub async fn fill(&self, target: &Target, text: &str, clear: bool) -> Result<()> {
858        self.admit(
859            ActionKind::Fill,
860            None,
861            Some(describe(target)),
862            Some(format!("chars={} clear={clear}", text.chars().count())),
863        )
864        .await?;
865        let _l = self.lane.lock().await;
866        self.click_n(target, 1).await?;
867        if clear {
868            let sel_all = if cfg!(target_os = "macos") {
869                "Meta+a"
870            } else {
871                "Control+a"
872            };
873            self.press_locked(sel_all).await?;
874            self.press_locked("Backspace").await?;
875        }
876        self.type_locked(text).await
877    }
878
879    // ---- other actions -----------------------------------------------------
880
881    /// Choose a `<select>` option by value/label. Native popups cannot be driven
882    /// over CDP, so this sets the value and fires `input`/`change` (documented gap).
883    pub async fn select_option(&self, target: &Target, value: &str) -> Result<()> {
884        self.admit(
885            ActionKind::Select,
886            None,
887            Some(describe(target)),
888            Some(format!("value={value}")),
889        )
890        .await?;
891        let _l = self.lane.lock().await;
892        let sel = self
893            .resolve(target)
894            .await?
895            .ok_or_else(|| BrowserError::Invalid("select needs an element".into()))?;
896        let r = self
897            .eval(&format!(
898                "(()=>{{const e=document.querySelector({s});const w={v};\
899                 const o=[...e.options].find(o=>o.value===w||o.label===w||o.text.trim()===w);if(!o)return 'nooption';\
900                 e.value=o.value;e.dispatchEvent(new Event('input',{{bubbles:true}}));e.dispatchEvent(new Event('change',{{bubbles:true}}));return 'ok'}})()",
901                s = js(&sel),
902                v = js(value)
903            ))
904            .await?;
905        if r.as_str() == Some("ok") {
906            Ok(())
907        } else {
908            Err(BrowserError::Invalid(format!(
909                "'{sel}' has no option '{value}'"
910            )))
911        }
912    }
913
914    pub async fn upload(&self, target: &Target, file: &Path) -> Result<()> {
915        self.admit(
916            ActionKind::Upload,
917            None,
918            Some(describe(target)),
919            Some(file.display().to_string()),
920        )
921        .await?;
922        let _l = self.lane.lock().await;
923        let file = file.canonicalize()?;
924        if !file.is_file() {
925            return Err(BrowserError::Invalid(
926                "upload must be a regular file".into(),
927            ));
928        }
929        if let Some(root) = &self.upload_root {
930            if !file.starts_with(root.canonicalize()?) {
931                return Err(BrowserError::Invalid(
932                    "upload outside the configured upload root".into(),
933                ));
934            }
935        }
936        if std::fs::metadata(&file)?.len() > MAX_UPLOAD_BYTES {
937            return Err(BrowserError::Invalid("upload exceeds 64 MiB".into()));
938        }
939        let sel = self
940            .resolve(target)
941            .await?
942            .ok_or_else(|| BrowserError::Invalid("upload needs an element".into()))?;
943        let el = self
944            .inner
945            .find_element(sel.as_str())
946            .await
947            .map_err(|_| BrowserError::NoElement(sel.clone()))?;
948        let node = el.description().await.map_err(cdp)?;
949        self.inner
950            .execute(
951                SetFileInputFilesParams::builder()
952                    .file(file.to_string_lossy().to_string())
953                    .backend_node_id(node.backend_node_id)
954                    .build()
955                    .map_err(BrowserError::Invalid)?,
956            )
957            .await
958            .map_err(cdp)?;
959        Ok(())
960    }
961
962    pub async fn wait_for(&self, selector: &str, timeout: Duration) -> Result<()> {
963        self.wait_eval(
964            &format!("!!document.querySelector({})", js(selector)),
965            timeout,
966        )
967        .await
968    }
969
970    /// Poll a JS expression until truthy.
971    pub async fn wait_eval(&self, expr: &str, timeout: Duration) -> Result<()> {
972        self.admit(
973            ActionKind::Eval,
974            None,
975            None,
976            Some(expr.chars().take(2000).collect()),
977        )
978        .await?;
979        let deadline = Instant::now() + timeout;
980        loop {
981            let v = self.eval_internal(expr).await?;
982            if v.as_bool()
983                .unwrap_or(!v.is_null() && v != Value::Bool(false))
984            {
985                return Ok(());
986            }
987            if Instant::now() >= deadline {
988                return Err(BrowserError::Timeout(expr.to_string()));
989            }
990            tokio::time::sleep(Duration::from_millis(50)).await;
991        }
992    }
993
994    pub async fn screenshot(&self) -> Result<Vec<u8>> {
995        self.inner
996            .screenshot(
997                ScreenshotParams::builder()
998                    .format(CaptureScreenshotFormat::Png)
999                    .build(),
1000            )
1001            .await
1002            .map_err(cdp)
1003    }
1004
1005    pub async fn screenshot_to(&self, path: &Path) -> Result<()> {
1006        std::fs::write(path, self.screenshot().await?)?;
1007        Ok(())
1008    }
1009
1010    pub async fn console(&self) -> Vec<ConsoleLine> {
1011        self.console.lock().await.clone()
1012    }
1013
1014    pub async fn clear_console(&self) {
1015        self.console.lock().await.clear();
1016    }
1017}
1018
1019async fn push_capped(sink: &Arc<Mutex<Vec<ConsoleLine>>>, line: ConsoleLine) {
1020    let mut g = sink.lock().await;
1021    if g.len() >= MAX_CONSOLE_LINES {
1022        g.remove(0);
1023    }
1024    g.push(line);
1025}
1026
1027fn describe(t: &Target) -> String {
1028    match t {
1029        Target::Ref(r) => format!("ref:{r}"),
1030        Target::Selector(s) => format!("css:{s}"),
1031        Target::Point(x, y) => format!("point:{x},{y}"),
1032    }
1033}
1034
1035fn js(s: &str) -> String {
1036    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
1037}
1038
1039const OBSERVE_JS: &str = r#"(() => {
1040  const esc = globalThis.CSS?.escape || (v => String(v).replace(/[^a-zA-Z0-9_-]/g, '\\$&'));
1041  const unique = c => { if (!c) return null; try { return document.querySelectorAll(c).length === 1 ? c : null; } catch (_) { return null; } };
1042  const selectorFor = el => {
1043    if (el.id) { const s = unique('#' + esc(el.id)); if (s) return s; }
1044    for (const a of ['data-testid', 'name', 'aria-label']) {
1045      const v = el.getAttribute(a);
1046      if (v) { const s = unique(el.tagName.toLowerCase() + '[' + a + '="' + esc(v) + '"]'); if (s) return s; }
1047    }
1048    const parts = [];
1049    for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
1050      let p = n.tagName.toLowerCase();
1051      const sib = n.parentElement ? [...n.parentElement.children].filter(x => x.tagName === n.tagName) : [];
1052      if (sib.length > 1) p += ':nth-of-type(' + (sib.indexOf(n) + 1) + ')';
1053      parts.unshift(p);
1054      const c = parts.join(' > ');
1055      if (unique(c)) return c;
1056    }
1057    return '';
1058  };
1059  const sel = 'a[href],button,input,select,textarea,[role],[tabindex],[contenteditable="true"],[onclick]';
1060  return [...document.querySelectorAll(sel)].slice(0, 300).map(el => {
1061    const r = el.getBoundingClientRect();
1062    const ac = (el.getAttribute('autocomplete') || '').trim().toLowerCase();
1063    const sensitive = (el instanceof HTMLInputElement && el.type.toLowerCase() === 'password') || ac === 'current-password' || ac === 'new-password';
1064    const visible = (() => {
1065      for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
1066        const s = getComputedStyle(n);
1067        if (n.hidden || n.getAttribute('aria-hidden') === 'true' || s.visibility === 'hidden' || s.display === 'none' || s.opacity === '0') return false;
1068      }
1069      return r.width > 0 && r.height > 0;
1070    })();
1071    return {
1072      role: el.getAttribute('role') || ({A:'link',BUTTON:'button',INPUT:'textbox',SELECT:'combobox',TEXTAREA:'textbox'}[el.tagName] || el.tagName.toLowerCase()),
1073      name: (el.getAttribute('aria-label') || el.innerText || el.value || el.getAttribute('placeholder') || '').trim().slice(0, 160),
1074      tag: el.tagName.toLowerCase(), selector: selectorFor(el), visible,
1075      disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true', sensitive,
1076      bounds: {x: r.x, y: r.y, width: r.width, height: r.height}
1077    };
1078  });
1079})()"#;