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 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 pub added: Vec<String>,
68 pub removed: Vec<String>,
69}
70
71#[derive(Clone, Debug)]
73pub enum Target {
74 Ref(String),
76 Selector(String),
78 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 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 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 pub fn blocked_requests(&self) -> Vec<BlockedRequest> {
274 self.blocked.lock().unwrap().clone()
275 }
276
277 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 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 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 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 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 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 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 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 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")); 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 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 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 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 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 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 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 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 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 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 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 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 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})()"#;