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