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 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 pub added: Vec<String>,
71 pub removed: Vec<String>,
72}
73
74#[derive(Clone, Debug)]
76pub enum Target {
77 Ref(String),
79 Selector(String),
81 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 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 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 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 pub fn blocked_requests(&self) -> Vec<BlockedRequest> {
292 self.blocked.lock().unwrap().clone()
293 }
294
295 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 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 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 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 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 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 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 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 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 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")); 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 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 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 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 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 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 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 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 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 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 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 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 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})()"#;