1#![allow(dead_code)]
12#![allow(unused_imports)]
13use base64::Engine;
14use base64::engine::general_purpose::STANDARD;
15use clap::ValueEnum;
16use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
17use serde_json::Value;
18use std::collections::{HashMap, HashSet, VecDeque};
19use std::error::Error;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::time::Duration;
24use sysinfo::{Pid, ProcessesToUpdate, System};
25use tokio::sync::Mutex;
26
27use crate::browser::cdp::{CdpClient, CdpEventWithParams, RuntimeEvaluateResponse};
28use crate::browser::chrome::ChromeProcess;
29use crate::browser::dom::{
30 AxNode, CompactAxNode, CompactInteractiveElement, DomNode, backend_node_reference,
31 find_interactive_elements, format_tree,
32};
33use crate::browser::mouse::{MouseEngine, Point};
34use crate::browser::policy::{BrowserPolicy, PolicyError};
35use crate::browser::profile::ProfileManager;
36
37pub type BrowserResult<T> = Result<T, Box<dyn Error>>;
41
42pub const MAX_BATCH_STEPS: usize = 32;
44
45pub const MAX_AUDIT_ENTRIES: usize = 512;
47
48#[derive(Debug, Clone, Serialize)]
50pub struct AuditEntry {
51 pub sequence: u64,
53 pub operation: String,
55 pub detail: String,
57 pub policy_preset: String,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
63#[serde(tag = "action", rename_all = "camelCase")]
64pub enum BatchStep {
65 #[serde(rename = "navigate")]
66 Navigate {
67 url: String,
68 #[serde(default = "default_timeout_ms")]
69 timeout_ms: u64,
70 },
71 #[serde(rename = "click")]
72 Click { target: String },
73 #[serde(rename = "type")]
74 Type {
75 text: String,
76 #[serde(default)]
77 target: Option<String>,
78 },
79 #[serde(rename = "check")]
80 Check { target: String },
81 #[serde(rename = "uncheck")]
82 Uncheck { target: String },
83 #[serde(rename = "select")]
84 Select { target: String, value: String },
85 #[serde(rename = "clear")]
86 Clear { target: String },
87 #[serde(rename = "scroll")]
88 Scroll {
89 #[serde(default)]
90 dx: f64,
91 #[serde(default)]
92 dy: f64,
93 },
94 #[serde(rename = "wait")]
95 Wait {
96 condition: String,
97 #[serde(default = "default_timeout_ms")]
98 timeout_ms: u64,
99 },
100 #[serde(rename = "observe")]
101 Observe {
102 #[serde(default)]
103 include_dom: bool,
104 #[serde(default)]
105 include_screenshot: bool,
106 #[serde(default)]
107 include_form_values: bool,
108 },
109 #[serde(rename = "screenshot")]
110 Screenshot,
111 #[serde(rename = "evaluate")]
112 Evaluate { expression: String },
113 #[serde(rename = "acceptDialog")]
114 AcceptDialog,
115 #[serde(rename = "dismissDialog")]
116 DismissDialog,
117}
118
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum, Deserialize, Serialize)]
121#[serde(rename_all = "snake_case")]
122pub enum BatchMode {
123 Fixed,
125 Chain,
127 #[default]
129 Unguarded,
130}
131
132const fn default_timeout_ms() -> u64 {
133 20_000
134}
135
136#[derive(Debug, Clone, Serialize)]
138#[serde(tag = "status", rename_all = "snake_case")]
139pub enum BatchStepOutcome {
140 Success {
141 index: usize,
142 action: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 response_bytes: Option<usize>,
145 #[serde(rename = "executionId", skip_serializing_if = "Option::is_none")]
146 execution_id: Option<String>,
147 },
148 Error {
149 index: usize,
150 action: String,
151 message: String,
152 #[serde(rename = "executionId", skip_serializing_if = "Option::is_none")]
153 execution_id: Option<String>,
154 },
155}
156
157#[derive(Debug, Clone, Serialize)]
159pub struct BatchOutcome {
160 pub mode: BatchMode,
161 #[serde(rename = "initialRevision")]
162 pub initial_revision: u64,
163 #[serde(rename = "finalRevision")]
164 pub final_revision: u64,
165 pub steps: Vec<BatchStepOutcome>,
166 pub completed: usize,
167 pub failed: usize,
168 pub total: usize,
169 pub success: bool,
170}
171
172#[derive(Debug, Clone, Serialize)]
174pub struct BatchPolicyDenial {
175 pub step_index: usize,
176 pub action: String,
177 pub reason: String,
178}
179
180pub const MAX_RECONCILE_REFS: usize = 16;
184pub const MAX_RECONCILE_HINTS: usize = 8;
185pub const MAX_RECONCILIATION_BYTES: usize = 8 * 1024;
186
187#[derive(Debug, Clone, Copy, Serialize)]
188#[serde(rename_all = "snake_case")]
189pub enum ReferenceMatch {
190 BackendNode,
191 RoleAndName,
192 AccessibleName,
193 Hint,
194 ScopedHint,
195}
196
197#[derive(Debug, Clone, Serialize)]
198#[serde(tag = "kind", rename_all = "snake_case")]
199pub enum ReferenceLostReason {
200 NotFound,
201 Ambiguous { candidates: Vec<CandidateSummary> },
202 StaleBoundary,
203 OutOfScope,
204}
205
206#[derive(Debug, Clone, Copy, Serialize)]
207#[serde(rename_all = "snake_case")]
208pub enum ReconciliationStatus {
209 Complete,
210 RouteChanged,
211}
212
213#[derive(Debug, Clone, Default)]
215pub struct ReconciliationOptions {
216 pub hints: Vec<Locator>,
217 pub scope_ref: Option<String>,
218}
219
220#[derive(Debug, Clone, Serialize)]
222#[serde(tag = "kind", rename_all = "snake_case")]
223pub enum ReferenceMapping {
224 Preserved { old: String, new: String },
226 Relocated {
228 old: String,
229 new: String,
230 #[serde(rename = "matchedBy")]
231 matched_by: ReferenceMatch,
232 },
233 Lost {
235 old: String,
236 reason: ReferenceLostReason,
237 },
238}
239
240#[derive(Debug, Clone, Serialize)]
242pub struct ReconciliationOutcome {
243 pub status: ReconciliationStatus,
244 #[serde(rename = "toRevision")]
245 pub to_revision: u64,
246 pub mappings: Vec<ReferenceMapping>,
247 pub preserved: usize,
248 pub relocated: usize,
249 pub lost: usize,
250 #[serde(rename = "mutationSummary")]
251 pub mutation_summary: MutationSummary,
252 pub incomplete: Vec<ObservationIncompleteReason>,
253}
254
255#[derive(Debug, Clone, Default, Serialize)]
256pub struct MutationSummary {
257 #[serde(rename = "urlChanged")]
258 pub url_changed: bool,
259 #[serde(rename = "titleChanged")]
260 pub title_changed: bool,
261 #[serde(rename = "revisionDelta")]
262 pub revision_delta: u64,
263 #[serde(rename = "softNavigationSuspected")]
264 pub soft_navigation_suspected: bool,
265}
266
267#[derive(Debug, Clone, Serialize)]
268pub struct DeltaControl {
269 pub reference: String,
270 pub role: String,
271 pub name: String,
272}
273
274#[derive(Debug, Clone, Serialize)]
275pub struct ObservationDelta {
276 #[serde(rename = "fromRevision")]
277 pub from_revision: u64,
278 #[serde(rename = "toRevision")]
279 pub to_revision: u64,
280 pub mutation_summary: MutationSummary,
281 pub added: Vec<DeltaControl>,
282 pub removed: Vec<DeltaControl>,
283 pub changed: Vec<DeltaControl>,
284 pub prior_incomplete: Vec<ObservationIncompleteReason>,
285 pub current_incomplete: Vec<ObservationIncompleteReason>,
286}
287
288#[derive(Debug, Clone, Serialize)]
290pub struct StaleReferenceRecovery {
291 pub suggestion: &'static str,
292 #[serde(rename = "fromRevision")]
293 pub from_revision: u64,
294 #[serde(rename = "staleRef")]
295 pub stale_ref: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct CheckpointV1 {
304 #[serde(rename = "schemaVersion")]
305 pub schema_version: u8,
306 #[serde(rename = "glassVersion")]
307 pub glass_version: String,
308 #[serde(rename = "exportedAt")]
309 pub exported_at: String,
310 pub profile: String,
311 #[serde(rename = "attachMode")]
312 pub attach_mode: bool,
313 pub topology: CheckpointTopology,
314 pub observation: CheckpointObservation,
315 pub policy: String,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct CheckpointTopology {
321 #[serde(rename = "targetId", skip_serializing_if = "Option::is_none")]
322 pub target_id: Option<String>,
323 #[serde(rename = "frameId", skip_serializing_if = "Option::is_none")]
324 pub frame_id: Option<String>,
325 pub url: String,
326 pub title: String,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct CheckpointObservation {
334 pub revision: u64,
335 #[serde(rename = "lastRefs")]
337 pub last_refs: Vec<String>,
338}
339
340#[derive(Debug, Clone, Serialize)]
342#[serde(tag = "kind", rename_all = "snake_case")]
343pub enum CheckpointError {
344 SchemaVersionMismatch { expected: u8, found: u8 },
345 TargetClosed,
346 Stale,
347 InvalidJson(String),
348}
349
350impl std::fmt::Display for CheckpointError {
351 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 match self {
353 Self::SchemaVersionMismatch { expected, found } => {
354 write!(
355 formatter,
356 "checkpoint schema version mismatch: expected {expected}, found {found}"
357 )
358 }
359 Self::TargetClosed => formatter.write_str("checkpoint target is no longer open"),
360 Self::Stale => formatter.write_str("checkpoint frame or route is stale"),
361 Self::InvalidJson(message) => write!(formatter, "invalid checkpoint JSON: {message}"),
362 }
363 }
364}
365
366impl Error for CheckpointError {}
367
368pub const COMPACT_TEXT_MAX_BYTES: usize = 16 * 1024;
370pub(crate) const TEXT_TRUNCATION_MARKER: &str = "\n[truncated]";
371pub(crate) const COMPACT_OBSERVATION_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
372pub(crate) const COMPACT_OBSERVATION_MAX_ATTEMPTS: u8 = 2;
373pub(crate) const COMPACT_ACCESSIBILITY_CACHE_MAX_BYTES: usize = 1024 * 1024;
374pub(crate) const COMPACT_PAGE_STATE_EXPRESSION: &str = r#"(() => {
375 const key = '__glassObservationRevision';
376 let state = globalThis[key];
377 if (!state) {
378 state = {revision: 0};
379 const observer = new MutationObserver(() => { state.revision += 1; });
380 observer.observe(document, {subtree:true, childList:true, attributes:true, characterData:true});
381 globalThis[key] = state;
382 }
383 const summary = {scanned_elements:0, scan_limit:512, shadow_roots:0, child_frames:0, canvases:0, truncated:false};
384 const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT);
385 while (walker.nextNode()) {
386 if (summary.scanned_elements >= summary.scan_limit) { summary.truncated = true; break; }
387 const element = walker.currentNode;
388 summary.scanned_elements += 1;
389 if (element.shadowRoot) summary.shadow_roots += 1;
390 if (element.localName === 'iframe' || element.localName === 'frame') summary.child_frames += 1;
391 if (element.localName === 'canvas') summary.canvases += 1;
392 }
393 return {url:location.href, title:document.title, ready_state:document.readyState,
394 text:(() => { const source=document.body ? document.body.innerText : ''; const bytes=new Uint8Array(16384);
395 const encoded=new TextEncoder().encodeInto(source, bytes); summary.text_truncated=encoded.read < source.length;
396 return new TextDecoder().decode(bytes.subarray(0, encoded.written)); })(),
397 mutation_revision:state.revision, boundaries:summary};
398})()"#;
399pub(crate) const OWNED_BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(10);
400pub(crate) const AMBIGUOUS_CANDIDATE_LIMIT: usize = 8;
401pub(crate) const CANDIDATE_LABEL_MAX_BYTES: usize = 160;
402pub(crate) const TOPOLOGY_MAX_TARGETS: usize = 32;
403pub(crate) const TOPOLOGY_MAX_FRAMES: usize = 128;
404pub(crate) const TOPOLOGY_ID_MAX_BYTES: usize = 256;
405pub(crate) const TOPOLOGY_TEXT_MAX_BYTES: usize = 1024;
406pub(crate) const TOPOLOGY_MAX_EVENTS: usize = 64;
407pub(crate) const POPUP_WITNESS_LIFETIME_MS: u64 = 5_000;
408pub(crate) const POPUP_EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
409pub(crate) const POPUP_TOPOLOGY_QUIET_INTERVAL: Duration = Duration::from_millis(50);
410pub(crate) const POPUP_TOPOLOGY_POLL_INTERVAL: Duration = Duration::from_millis(5);
411pub(crate) const POPUP_VERIFY_CALL_TIMEOUT: Duration = Duration::from_secs(2);
412pub(crate) const POPUP_RELEASE_ACK_TIMEOUT: Duration = Duration::from_millis(500);
413pub(crate) const POPUP_ERROR_MESSAGE_MAX_BYTES: usize = 512;
414pub(crate) const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(50);
415pub(crate) const WAIT_LAST_STATE_MAX_BYTES: usize = 512;
416pub(crate) const NETWORK_IN_FLIGHT_LIMIT: usize = 1024;
417pub(crate) const MAX_WAIT_DEADLINE: Duration = Duration::from_secs(300);
418pub(crate) const MAX_WAIT_CONDITION_BYTES: usize = 4 * 1024;
419pub(crate) const MAX_DIAGNOSTIC_DURATION: Duration = Duration::from_secs(30);
420pub(crate) const MAX_DIAGNOSTIC_EVENTS: usize = 128;
421pub(crate) const MAX_DIAGNOSTIC_TEXT_BYTES: usize = 2 * 1024;
422pub(crate) const MAX_DIAGNOSTIC_URL_BYTES: usize = 4 * 1024;
423pub(crate) const MAX_VISUAL_PIXELS: f64 = 8.0 * 1024.0 * 1024.0;
426pub(crate) const MAX_VISUAL_BASE64_BYTES: usize = 64 * 1024 * 1024;
427pub(crate) const VISUAL_HEADER_BASE64_BYTES: usize = 64 * 1024;
428pub(crate) const HIT_TEST_FUNCTION: &str = r#"async function() {
429 let element = this && this.nodeType === Node.ELEMENT_NODE ? this : this && this.parentElement;
430 if (element) element = element.closest('button,a,input,select,textarea,[role],[tabindex]') || element;
431 if (!element || !element.isConnected) return {ok:false, reason:'detached'};
432 const sample = () => {
433 const rect = element.getBoundingClientRect();
434 return {left:rect.left, top:rect.top, width:rect.width, height:rect.height};
435 };
436 element.scrollIntoView({block:'nearest', inline:'nearest'});
437 const first = sample();
438 if (!element.isConnected) return {ok:false, reason:'detached'};
439 if (element.getAnimations({subtree:true}).some(animation => animation.playState === 'running'))
440 return {ok:false, reason:'unstable_geometry'};
441 const second = sample();
442 const style = getComputedStyle(element);
443 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 || second.width <= 0 || second.height <= 0)
444 return {ok:false, reason:'not_visible'};
445 if (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true')
446 return {ok:false, reason:'disabled'};
447 if ([first.left, first.top, first.width, first.height].some((value, index) => Math.abs(value - [second.left, second.top, second.width, second.height][index]) > 1))
448 return {ok:false, reason:'unstable_geometry'};
449 const x = second.left + second.width / 2;
450 const y = second.top + second.height / 2;
451 if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight)
452 return {ok:false, reason:'outside_viewport'};
453 const hit = document.elementFromPoint(x, y);
454 if (!hit || (hit !== element && !element.contains(hit)))
455 return {ok:false, reason:'hit_test_blocked'};
456 return {ok:true, x, y};
457}"#;
458pub(crate) const PREFLIGHT_FUNCTION: &str = r#"function() {
462 let element = this && this.nodeType === Node.ELEMENT_NODE ? this : this && this.parentElement;
463 if (element) element = element.closest('button,a,input,select,textarea,[role],[tabindex]') || element;
464 if (!element || !element.isConnected) return {ok:false, reason:'detached'};
465 const rect = element.getBoundingClientRect();
466 const geometry = {
467 x: Math.round(rect.left * 10) / 10,
468 y: Math.round(rect.top * 10) / 10,
469 width: Math.round(rect.width * 10) / 10,
470 height: Math.round(rect.height * 10) / 10
471 };
472 const style = getComputedStyle(element);
473 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 || rect.width <= 0 || rect.height <= 0)
474 return {ok:false, reason:'not_visible', geometry};
475 if (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true')
476 return {ok:false, reason:'disabled', geometry};
477 const x = rect.left + rect.width / 2;
478 const y = rect.top + rect.height / 2;
479 const inViewport = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight;
480 const hit = inViewport ? document.elementFromPoint(x, y) : null;
481 const hints = {
482 likelyNavigation: element.matches('a[href], [role="link"]'),
483 likelyPopup: element.matches('[target="_blank"], [rel~="noopener"], [aria-haspopup]'),
484 likelyFormSubmit: element.matches('button[type="submit"], input[type="submit"]')
485 };
486 if (!inViewport) return {ok:false, reason:'outside_viewport', geometry, hints};
487 if (!hit || (hit !== element && !element.contains(hit))) return {ok:false, reason:'hit_test_blocked', geometry, hints};
488 return {ok:true, geometry, hints};
489}"#;
490pub(crate) const WAIT_TARGET_STATE_FUNCTION: &str = r#"function() {
491 let element = this && this.nodeType === Node.ELEMENT_NODE ? this : this && this.parentElement;
492 if (!element || !element.isConnected) return {attached:false, visible:false, enabled:false};
493 const rect = element.getBoundingClientRect();
494 const style = getComputedStyle(element);
495 const visible = style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
496 const enabled = !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true';
497 return {attached:true, visible, enabled, geometry:[rect.left, rect.top, rect.width, rect.height].map(value => Math.round(value * 10) / 10).join(',')};
498}"#;
499
500pub(crate) fn is_false(value: &bool) -> bool {
501 !*value
502}
503
504#[derive(Debug, Clone)]
513pub struct SessionOptions {
514 #[doc(hidden)]
515 pub port: u16,
516 #[doc(hidden)]
517 pub chrome_path: Option<PathBuf>,
518 #[doc(hidden)]
519 pub profile: String,
520 #[doc(hidden)]
521 pub incognito: bool,
522 #[doc(hidden)]
524 pub attach: bool,
525 #[doc(hidden)]
528 pub target_id: Option<String>,
529 #[doc(hidden)]
530 pub frame_id: Option<String>,
531 #[doc(hidden)]
532 pub headed: bool,
533 #[doc(hidden)]
534 pub interaction_mode: InteractionMode,
535 #[doc(hidden)]
537 pub audit: bool,
538 #[doc(hidden)]
542 pub policy: Option<BrowserPolicy>,
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
550pub enum InteractionMode {
551 Human,
552 Fast,
553}
554
555impl Default for SessionOptions {
556 fn default() -> Self {
557 Self {
558 port: 9222,
559 chrome_path: None,
560 profile: "default".to_string(),
561 incognito: false,
562 attach: false,
563 target_id: None,
564 frame_id: None,
565 headed: false,
566 interaction_mode: InteractionMode::Human,
567 audit: false,
568 policy: None,
569 }
570 }
571}
572
573impl SessionOptions {
574 pub fn builder() -> SessionOptionsBuilder {
588 SessionOptionsBuilder::default()
589 }
590
591 pub fn validate(&self) -> BrowserResult<()> {
593 if self
594 .target_id
595 .as_deref()
596 .is_some_and(|target_id| target_id.trim().is_empty())
597 {
598 return Err("target ID cannot be empty".into());
599 }
600 if self
601 .frame_id
602 .as_deref()
603 .is_some_and(|frame_id| frame_id.trim().is_empty())
604 {
605 return Err("frame ID cannot be empty".into());
606 }
607
608 if self.attach {
609 if self.incognito {
610 return Err("--attach cannot be combined with --incognito".into());
611 }
612 if self.profile != "default" {
613 return Err(
614 "--attach cannot be combined with a named --profile; attached Chrome owns its profile"
615 .into(),
616 );
617 }
618 if self.chrome_path.is_some() {
619 return Err("--attach cannot be combined with --chrome-path".into());
620 }
621 if self.headed {
622 return Err("--attach cannot be combined with --headed".into());
623 }
624 } else {
625 ProfileManager::validate_name(&self.profile)?;
626 }
627 Ok(())
628 }
629}
630
631#[derive(Debug, Clone)]
640pub struct SessionOptionsBuilder {
641 port: u16,
642 chrome_path: Option<PathBuf>,
643 profile: String,
644 incognito: bool,
645 attach: bool,
646 target_id: Option<String>,
647 frame_id: Option<String>,
648 headed: bool,
649 interaction_mode: InteractionMode,
650 audit: bool,
651 policy: Option<BrowserPolicy>,
652}
653
654impl Default for SessionOptionsBuilder {
655 fn default() -> Self {
656 let defaults = SessionOptions::default();
657 Self {
658 port: defaults.port,
659 chrome_path: defaults.chrome_path,
660 profile: defaults.profile,
661 incognito: defaults.incognito,
662 attach: defaults.attach,
663 target_id: defaults.target_id,
664 frame_id: defaults.frame_id,
665 headed: defaults.headed,
666 interaction_mode: defaults.interaction_mode,
667 audit: defaults.audit,
668 policy: defaults.policy,
669 }
670 }
671}
672
673impl SessionOptionsBuilder {
674 pub fn port(mut self, port: u16) -> Self {
676 self.port = port;
677 self
678 }
679
680 pub fn chrome_path(mut self, chrome_path: impl Into<PathBuf>) -> Self {
682 self.chrome_path = Some(chrome_path.into());
683 self
684 }
685
686 pub fn profile(mut self, profile: impl Into<String>) -> Self {
688 self.profile = profile.into();
689 self
690 }
691
692 pub fn incognito(mut self, incognito: bool) -> Self {
694 self.incognito = incognito;
695 self
696 }
697
698 pub fn attach(mut self, attach: bool) -> Self {
700 self.attach = attach;
701 self
702 }
703
704 pub fn target_id(mut self, target_id: impl Into<String>) -> Self {
706 self.target_id = Some(target_id.into());
707 self
708 }
709
710 pub fn frame_id(mut self, frame_id: impl Into<String>) -> Self {
712 self.frame_id = Some(frame_id.into());
713 self
714 }
715
716 pub fn headed(mut self, headed: bool) -> Self {
718 self.headed = headed;
719 self
720 }
721
722 pub fn interaction_mode(mut self, mode: InteractionMode) -> Self {
724 self.interaction_mode = mode;
725 self
726 }
727
728 pub fn audit(mut self, audit: bool) -> Self {
730 self.audit = audit;
731 self
732 }
733
734 pub fn policy(mut self, policy: BrowserPolicy) -> Self {
740 self.policy = Some(policy);
741 self
742 }
743
744 pub fn policy_preset(
749 mut self,
750 preset: crate::browser::policy::PolicyPreset,
751 workspace_root: impl AsRef<Path>,
752 ) -> Result<Self, Box<dyn Error>> {
753 self.policy = Some(BrowserPolicy::from_preset(preset, workspace_root)?);
754 Ok(self)
755 }
756
757 pub fn build(self) -> BrowserResult<SessionOptions> {
761 let options = SessionOptions {
762 port: self.port,
763 chrome_path: self.chrome_path,
764 profile: self.profile,
765 incognito: self.incognito,
766 attach: self.attach,
767 target_id: self.target_id,
768 frame_id: self.frame_id,
769 headed: self.headed,
770 interaction_mode: self.interaction_mode,
771 audit: self.audit,
772 policy: self.policy,
773 };
774 options.validate()?;
775 Ok(options)
776 }
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize)]
781pub struct PageInfo {
782 pub url: String,
783 pub title: String,
784 #[serde(default)]
785 pub ready_state: String,
786 #[serde(default)]
787 pub target_id: String,
788 #[serde(default)]
789 pub frame_id: String,
790}
791
792#[derive(Debug, Clone, Serialize)]
794pub struct PageTargetInfo {
795 pub id: String,
796 pub url: String,
797 pub title: String,
798 #[serde(skip_serializing_if = "Option::is_none")]
799 pub opener_id: Option<String>,
800 pub active: bool,
801}
802
803#[derive(Debug, Clone, Serialize)]
805pub struct FrameInfo {
806 pub id: String,
807 #[serde(skip_serializing_if = "Option::is_none")]
808 pub parent_id: Option<String>,
809 pub url: String,
810 pub active: bool,
811 pub out_of_process: bool,
812}
813
814#[derive(Debug, Clone, Serialize)]
816pub struct TopologyEventSummary {
817 pub sequence: u64,
818 pub kind: String,
819 pub id: String,
820}
821
822#[derive(Debug, Clone)]
823pub(crate) struct DestroyedPageTarget {
824 pub(crate) target: PageTargetInfo,
825 pub(crate) observed_sequence: u64,
826}
827
828#[derive(Default)]
829pub(crate) struct TopologyRegistry {
830 pub(crate) targets: Vec<PageTargetInfo>,
831 pub(crate) frames: Vec<FrameInfo>,
832 pub(crate) active_target_id: Option<String>,
833 pub(crate) active_frame_id: Option<String>,
834 pub(crate) active_target_session_id: Option<String>,
835 pub(crate) active_session_id: Option<String>,
836 pub(crate) frame_sessions: HashMap<String, String>,
837 pub(crate) frame_parents: HashMap<String, String>,
838 pub(crate) events: VecDeque<TopologyEventSummary>,
839 pub(crate) sequence: u64,
840 pub(crate) event_loss_count: u64,
841 pub(crate) target_sequences: HashMap<String, u64>,
842 pub(crate) destroyed_targets: VecDeque<DestroyedPageTarget>,
843 pub(crate) pending_dialog: Option<PendingDialog>,
845}
846
847#[derive(Debug, Clone, Serialize)]
849pub struct PendingDialog {
850 #[serde(rename = "type")]
852 pub dialog_type: String,
853 pub message: String,
855 #[serde(skip_serializing_if = "Option::is_none")]
857 pub default_value: Option<String>,
858 pub url: String,
860}
861
862#[derive(Debug, Clone, Serialize)]
867pub struct InteractiveElement {
868 pub reference: String,
869 pub role: String,
870 pub name: String,
871 pub description: String,
872 pub backend_dom_node_id: i64,
873 pub input_type: Option<String>,
875}
876
877#[derive(Debug, Clone, PartialEq, Eq)]
879pub enum Locator {
880 Reference(String),
881 AccessibleName(String),
882 RoleAndName { role: String, name: String },
883 Text(String),
884 Css(String),
885 Ordinal(usize),
886}
887
888#[derive(Debug, Clone, Serialize)]
890pub struct CandidateSummary {
891 pub label: String,
892 #[serde(skip_serializing_if = "Option::is_none")]
893 pub reference: Option<String>,
894}
895
896#[derive(Debug, Clone)]
898pub struct TargetError {
899 pub kind: TargetErrorKind,
900 pub reason: Option<TargetActionabilityReason>,
901 pub candidates: Vec<CandidateSummary>,
902 pub recovery: Option<StaleReferenceRecovery>,
903}
904
905impl Serialize for TargetError {
906 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
907 where
908 S: Serializer,
909 {
910 let mut state = serializer.serialize_struct("TargetError", 5)?;
911 state.serialize_field("kind", &self.kind)?;
912 state.serialize_field("failureKind", &self.failure_kind())?;
913 if let Some(reason) = &self.reason {
914 state.serialize_field("reason", reason)?;
915 }
916 if !self.candidates.is_empty() {
917 state.serialize_field("candidates", &self.candidates)?;
918 }
919 if let Some(recovery) = &self.recovery {
920 state.serialize_field("recovery", recovery)?;
921 }
922 state.end()
923 }
924}
925
926impl TargetError {
927 pub fn failure_kind(&self) -> ActionFailureKind {
930 match self.kind {
931 TargetErrorKind::Ambiguous => ActionFailureKind::AmbiguousTarget,
932 TargetErrorKind::NotFound => ActionFailureKind::TargetNotFound,
933 TargetErrorKind::StaleReference => ActionFailureKind::StaleRevision,
934 TargetErrorKind::NotActionable => ActionFailureKind::VerificationFailed,
935 }
936 }
937}
938#[derive(Debug, Clone, Serialize)]
940pub struct PreflightOutcome {
941 pub action: PreflightAction,
943 pub unique: bool,
945 #[serde(skip_serializing_if = "Option::is_none")]
947 pub element: Option<ResolvedElement>,
948 #[serde(skip_serializing_if = "Option::is_none")]
950 pub actionable: Option<bool>,
951 #[serde(skip_serializing_if = "Option::is_none")]
953 pub actionability_reason: Option<TargetActionabilityReason>,
954 #[serde(skip_serializing_if = "Vec::is_empty")]
956 pub candidates: Vec<CandidateSummary>,
957 #[serde(skip_serializing_if = "Option::is_none")]
959 pub error_kind: Option<TargetErrorKind>,
960 pub revision: u64,
962 #[serde(skip_serializing_if = "Option::is_none")]
964 pub geometry: Option<PreflightGeometry>,
965 pub hints: PreflightHints,
967 pub target_id: Option<String>,
968 pub frame_id: Option<String>,
969}
970
971#[derive(Debug, Clone, Copy, Serialize)]
972pub struct PreflightGeometry {
973 pub x: f64,
974 pub y: f64,
975 pub width: f64,
976 pub height: f64,
977}
978
979#[derive(Debug, Clone, Copy, Default, Serialize)]
980pub struct PreflightHints {
981 pub likely_navigation: bool,
982 pub likely_popup: bool,
983 pub likely_form_submit: bool,
984}
985
986#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, clap::ValueEnum)]
987#[serde(rename_all = "snake_case")]
988pub enum PreflightAction {
989 #[default]
990 Click,
991 Hover,
992 Type,
993 Check,
994 Select,
995}
996
997#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, clap::ValueEnum)]
999#[serde(rename_all = "kebab-case")]
1000pub enum ObservationRanking {
1001 #[default]
1002 Relevance,
1003 DocumentOrder,
1004}
1005
1006#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1008#[serde(rename_all = "snake_case")]
1009pub enum TargetActionabilityReason {
1010 Detached,
1011 NotVisible,
1012 Disabled,
1013 UnstableGeometry,
1014 OutsideViewport,
1015 HitTestBlocked,
1016 GeometryChanged,
1017 NodeUnavailable,
1018 VerificationFailed,
1019}
1020
1021#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1023#[serde(rename_all = "snake_case")]
1024pub enum TargetErrorKind {
1025 Ambiguous,
1026 NotFound,
1027 StaleReference,
1028 NotActionable,
1029}
1030
1031impl std::fmt::Display for TargetError {
1032 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1033 let message = match self.kind {
1034 TargetErrorKind::Ambiguous => "element target is ambiguous",
1035 TargetErrorKind::NotFound => "element target was not found",
1036 TargetErrorKind::StaleReference => "element reference is stale",
1037 TargetErrorKind::NotActionable => "element target is not actionable",
1038 };
1039 formatter.write_str(message)
1040 }
1041}
1042
1043impl Error for TargetError {}
1044
1045#[derive(Debug, Clone, Serialize)]
1051pub struct TopologyError {
1052 pub kind: TopologyErrorKind,
1053 pub message: String,
1054 pub recovery: TopologyRecoveryHint,
1056}
1057
1058#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1060#[serde(rename_all = "snake_case")]
1061pub enum TopologyErrorKind {
1062 NoTargetSelected,
1065 StaleTarget,
1067 StaleFrame,
1069 NoSuchFrame,
1071 NoPageSession,
1073 BudgetExceeded,
1075 RoutingLost,
1077}
1078
1079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1081#[serde(rename_all = "snake_case")]
1082pub enum TopologyRecoveryHint {
1083 ListTargets,
1085 ReObserve,
1087 ListFrames,
1089 Reconnect,
1091}
1092
1093impl TopologyError {
1094 pub(crate) fn new(kind: TopologyErrorKind, message: impl Into<String>) -> Self {
1095 let recovery = match kind {
1096 TopologyErrorKind::NoTargetSelected => TopologyRecoveryHint::ListTargets,
1097 TopologyErrorKind::StaleTarget => TopologyRecoveryHint::ListTargets,
1098 TopologyErrorKind::StaleFrame => TopologyRecoveryHint::ListFrames,
1099 TopologyErrorKind::NoSuchFrame => TopologyRecoveryHint::ListFrames,
1100 TopologyErrorKind::NoPageSession => TopologyRecoveryHint::Reconnect,
1101 TopologyErrorKind::BudgetExceeded => TopologyRecoveryHint::ReObserve,
1102 TopologyErrorKind::RoutingLost => TopologyRecoveryHint::Reconnect,
1103 };
1104 Self {
1105 kind,
1106 message: message.into(),
1107 recovery,
1108 }
1109 }
1110}
1111
1112impl std::fmt::Display for TopologyError {
1113 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1114 write!(
1115 formatter,
1116 "topology {:?}: {} (recovery: {:?})",
1117 self.kind, self.message, self.recovery
1118 )
1119 }
1120}
1121
1122impl Error for TopologyError {}
1123
1124#[derive(Debug)]
1125pub(crate) enum TargetResolution {
1126 Unique(ResolvedElement),
1127 Ambiguous(Vec<CandidateSummary>),
1128 NotFound,
1129}
1130
1131#[derive(Debug, Clone, Serialize)]
1136pub struct AccessibilitySnapshot {
1137 pub page: PageInfo,
1138 pub roots: Vec<AxNode>,
1139 pub interactive: Vec<InteractiveElement>,
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Eq)]
1148pub enum WaitCondition {
1149 Lifecycle(String),
1150 UrlExact(String),
1151 UrlPrefix(String),
1152 TargetAttached(String),
1153 TargetVisible(String),
1154 TargetHidden(String),
1155 TargetEnabled(String),
1156 TargetStable(String),
1157 Text(String),
1158 JavaScript(String),
1159 NetworkQuiet(Duration),
1160}
1161
1162#[derive(Debug, Clone, Serialize)]
1164pub struct WaitOutcome {
1165 pub condition: String,
1166 pub elapsed_ms: u64,
1167 pub last_state: String,
1168 pub target_id: String,
1169 pub frame_id: String,
1170}
1171
1172#[derive(Debug, Clone, Serialize)]
1174pub struct WaitTimeout {
1175 pub condition: String,
1176 pub deadline_ms: u64,
1177 pub last_state: String,
1178 pub reason: &'static str,
1179}
1180
1181impl std::fmt::Display for WaitTimeout {
1182 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1183 write!(formatter, "wait timed out for {}", self.condition)
1184 }
1185}
1186
1187impl Error for WaitTimeout {}
1188
1189#[derive(Debug, Clone, Serialize)]
1191pub struct CompactAccessibilitySnapshot {
1192 pub page: PageInfo,
1193 pub revision: u64,
1195 pub roots: Vec<CompactAxNode>,
1196 pub interactive: Vec<CompactInteractiveElement>,
1197 #[serde(skip_serializing_if = "is_false")]
1198 pub truncated: bool,
1199 #[serde(skip_serializing_if = "is_zero")]
1201 pub omitted_count: usize,
1202 #[serde(skip_serializing_if = "is_false")]
1204 pub ranking_applied: bool,
1205 #[serde(skip_serializing_if = "Option::is_none")]
1207 pub completeness: Option<ObservationCompleteness>,
1208}
1209
1210fn is_zero(value: &usize) -> bool {
1211 *value == 0
1212}
1213
1214#[derive(Debug, Clone, Serialize)]
1216pub struct ObservationCompleteness {
1217 pub score: f64,
1219 pub confidence: &'static str,
1221 pub suggest_escalation: EscalationSuggestion,
1223 pub signals: CompletenessSignals,
1225}
1226
1227#[derive(Debug, Clone, Serialize)]
1229pub struct CompletenessSignals {
1230 pub interactive_discovered: usize,
1231 pub interactive_returned: usize,
1232 pub shadow_hosts: usize,
1233 pub shadow_hosts_pierced: usize,
1234 pub canvases: usize,
1235 pub child_frames: usize,
1236 pub mutation_race: bool,
1237}
1238
1239#[derive(Debug, Clone, Serialize)]
1241#[serde(rename_all = "snake_case")]
1242pub enum EscalationSuggestion {
1243 None,
1244 #[serde(rename = "getDOM")]
1245 GetDom,
1246 Coordinate,
1247 SelectFrame,
1248 Reobserve,
1249}
1250
1251impl ObservationCompleteness {
1252 pub fn compute(
1253 interactive_discovered: usize,
1254 interactive_returned: usize,
1255 shadow_hosts: usize,
1256 shadow_hosts_pierced: usize,
1257 canvases: usize,
1258 child_frames: usize,
1259 mutation_race: bool,
1260 ) -> Self {
1261 let interactive_factor = if interactive_discovered > 0 {
1263 (interactive_returned as f64 / interactive_discovered as f64).min(1.0)
1264 } else {
1265 1.0
1266 };
1267
1268 let shadow_factor = if shadow_hosts == 0 || shadow_hosts_pierced >= shadow_hosts {
1270 1.0
1271 } else if shadow_hosts_pierced > 0 {
1272 0.7
1273 } else {
1274 0.5
1275 };
1276
1277 let frame_factor = if child_frames == 0 { 1.0 } else { 0.8 };
1279
1280 let consistency_factor = if mutation_race { 0.0 } else { 1.0 };
1282
1283 let score = (interactive_factor * shadow_factor * frame_factor * consistency_factor)
1284 .clamp(0.0, 1.0);
1285 let score = (score * 100.0).round() / 100.0; let has_shadow_boundary = shadow_hosts > 0 && shadow_hosts_pierced < shadow_hosts;
1288 let confidence = if mutation_race {
1289 "low"
1290 } else if has_shadow_boundary || child_frames > 0 {
1291 "medium"
1292 } else {
1293 "high"
1294 };
1295
1296 let suggest_escalation = if mutation_race {
1297 EscalationSuggestion::Reobserve
1298 } else if score >= 0.85 && !has_shadow_boundary {
1299 EscalationSuggestion::None
1300 } else if has_shadow_boundary || score < 0.6 {
1301 EscalationSuggestion::GetDom
1302 } else if canvases > 0 && interactive_returned < 8 {
1303 EscalationSuggestion::Coordinate
1304 } else if child_frames > 0 {
1305 EscalationSuggestion::SelectFrame
1306 } else {
1307 EscalationSuggestion::None
1308 };
1309
1310 Self {
1311 score,
1312 confidence,
1313 suggest_escalation,
1314 signals: CompletenessSignals {
1315 interactive_discovered,
1316 interactive_returned,
1317 shadow_hosts,
1318 shadow_hosts_pierced,
1319 canvases,
1320 child_frames,
1321 mutation_race,
1322 },
1323 }
1324 }
1325}
1326
1327#[derive(Debug, Clone, Serialize)]
1329pub struct PageContext {
1330 pub page: PageInfo,
1331 pub text: String,
1332 #[serde(skip_serializing_if = "Option::is_none")]
1334 pub dom: Option<DomNode>,
1335 pub accessibility: CompactAccessibilitySnapshot,
1336 pub consistency: ObservationConsistency,
1337 pub boundaries: ObservationBoundarySummary,
1338 #[serde(skip_serializing_if = "Vec::is_empty")]
1339 pub incomplete: Vec<ObservationIncompleteReason>,
1340 #[serde(skip_serializing_if = "Option::is_none")]
1342 pub screenshot: Option<String>,
1343}
1344
1345#[derive(Debug, Clone, Serialize)]
1347pub struct ObservationConsistency {
1348 pub consistent: bool,
1349 pub attempts: u8,
1350 pub start_revision: u64,
1351 pub end_revision: u64,
1352 pub start_mutation_revision: u64,
1353 pub end_mutation_revision: u64,
1354}
1355
1356#[derive(Debug, Clone, Serialize)]
1358pub struct DiagnosticReport {
1359 pub target_id: String,
1360 pub frame_id: String,
1361 pub duration_ms: u64,
1362 pub console: Vec<ConsoleEvidence>,
1363 pub network: Vec<NetworkEvidence>,
1364 pub dropped_events: u64,
1365}
1366
1367#[derive(Debug, Clone, Serialize)]
1369pub struct ConsoleEvidence {
1370 pub level: String,
1371 pub text: String,
1372}
1373
1374#[derive(Debug, Clone, Serialize)]
1376pub struct NetworkEvidence {
1377 pub request_id: String,
1378 pub method: String,
1379 pub url: String,
1380 #[serde(skip_serializing_if = "Option::is_none")]
1381 pub status: Option<u16>,
1382 #[serde(skip_serializing_if = "Option::is_none")]
1383 pub failure: Option<String>,
1384 #[serde(skip_serializing_if = "Vec::is_empty")]
1385 pub safe_header_names: Vec<String>,
1386 pub redirect_count: u16,
1387}
1388
1389#[derive(Debug, Clone, Serialize)]
1391pub struct DownloadOutcome {
1392 pub guid: String,
1393 pub suggested_filename: String,
1394 pub state: String,
1395 pub received_bytes: u64,
1396 pub total_bytes: u64,
1397 pub target_id: String,
1398 pub frame_id: String,
1399 #[serde(skip_serializing_if = "Option::is_none")]
1401 pub sha256: Option<String>,
1402}
1403
1404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1406#[serde(rename_all = "snake_case")]
1407pub enum DownloadErrorKind {
1408 AuthorizationFailed,
1409 RestorationFailed,
1410}
1411
1412#[derive(Debug, Clone, Serialize)]
1414pub struct DownloadError {
1415 pub kind: DownloadErrorKind,
1416 pub message: String,
1417}
1418
1419impl std::fmt::Display for DownloadError {
1420 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1421 write!(formatter, "download {:?}: {}", self.kind, self.message)
1422 }
1423}
1424
1425impl Error for DownloadError {}
1426
1427#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
1429#[serde(rename_all = "snake_case")]
1430pub enum VisualFormat {
1431 Png,
1432 Jpeg,
1433 Webp,
1434}
1435
1436impl VisualFormat {
1437 pub fn as_cdp(self) -> &'static str {
1438 match self {
1439 Self::Png => "png",
1440 Self::Jpeg => "jpeg",
1441 Self::Webp => "webp",
1442 }
1443 }
1444}
1445
1446impl std::str::FromStr for VisualClip {
1447 type Err = String;
1448
1449 fn from_str(value: &str) -> Result<Self, Self::Err> {
1450 let values = value
1451 .split(',')
1452 .map(|part| {
1453 part.trim()
1454 .parse::<f64>()
1455 .map_err(|_| "clip must be x,y,width,height".to_string())
1456 })
1457 .collect::<Result<Vec<_>, _>>()?;
1458 if values.len() != 4 {
1459 return Err("clip must be x,y,width,height".to_string());
1460 }
1461 Ok(Self {
1462 x: values[0],
1463 y: values[1],
1464 width: values[2],
1465 height: values[3],
1466 })
1467 }
1468}
1469
1470#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1472pub struct VisualClip {
1473 pub x: f64,
1474 pub y: f64,
1475 pub width: f64,
1476 pub height: f64,
1477}
1478
1479#[derive(Debug, Clone)]
1481pub struct VisualCaptureOptions {
1482 pub format: VisualFormat,
1483 pub quality: Option<u8>,
1484 pub scale: f64,
1485 pub clip: Option<VisualClip>,
1486 pub full_page: bool,
1487 pub target: Option<String>,
1488}
1489
1490impl Default for VisualCaptureOptions {
1491 fn default() -> Self {
1492 Self {
1493 format: VisualFormat::Png,
1494 quality: None,
1495 scale: 1.0,
1496 clip: None,
1497 full_page: false,
1498 target: None,
1499 }
1500 }
1501}
1502
1503#[derive(Debug, Clone, Serialize)]
1505pub struct VisualCaptureMetadata {
1506 pub format: VisualFormat,
1507 pub width: usize,
1508 pub height: usize,
1509 pub encoded_bytes: usize,
1510 pub device_scale_factor: f64,
1511 pub scale: f64,
1512 pub full_page: bool,
1513 #[serde(skip_serializing_if = "Option::is_none")]
1514 pub clip: Option<VisualClip>,
1515 pub target_id: String,
1516 pub frame_id: String,
1517}
1518
1519#[derive(Debug)]
1521pub struct VisualCapture {
1522 pub data: String,
1523 pub metadata: VisualCaptureMetadata,
1524}
1525
1526#[cfg(feature = "visual-compare")]
1530#[derive(Debug, Serialize)]
1531pub struct VisualComparison {
1532 pub width: u32,
1533 pub height: u32,
1534 pub changed_pixels: u64,
1535 pub changed_ratio: f64,
1536 #[serde(skip_serializing_if = "Option::is_none")]
1537 pub difference_box: Option<VisualClip>,
1538}
1539
1540#[cfg(feature = "visual-compare")]
1546pub fn compare_png_visuals(first: &str, second: &str) -> BrowserResult<VisualComparison> {
1547 let first = decode_png_for_comparison(first)?;
1548 let second = decode_png_for_comparison(second)?;
1549 if first.0 != second.0 || first.1 != second.1 || first.2 != second.2 {
1550 return Err("visual comparison requires equal PNG dimensions and color layout".into());
1551 }
1552 let (width, height, samples, first_pixels) = first;
1553 let second_pixels = second.3;
1554 let mut changed = 0_u64;
1555 let mut left = width;
1556 let mut top = height;
1557 let mut right = 0_u32;
1558 let mut bottom = 0_u32;
1559 for (index, (a, b)) in first_pixels
1560 .chunks_exact(samples)
1561 .zip(second_pixels.chunks_exact(samples))
1562 .enumerate()
1563 {
1564 if a != b {
1565 changed += 1;
1566 let x = index as u32 % width;
1567 let y = index as u32 / width;
1568 left = left.min(x);
1569 top = top.min(y);
1570 right = right.max(x);
1571 bottom = bottom.max(y);
1572 }
1573 }
1574 Ok(VisualComparison {
1575 width,
1576 height,
1577 changed_pixels: changed,
1578 changed_ratio: changed as f64 / (u64::from(width) * u64::from(height)) as f64,
1579 difference_box: (changed > 0).then_some(VisualClip {
1580 x: f64::from(left),
1581 y: f64::from(top),
1582 width: f64::from(right - left + 1),
1583 height: f64::from(bottom - top + 1),
1584 }),
1585 })
1586}
1587
1588#[cfg(feature = "visual-compare")]
1589pub(crate) fn decode_png_for_comparison(value: &str) -> BrowserResult<(u32, u32, usize, Vec<u8>)> {
1590 if value.len() > MAX_VISUAL_BASE64_BYTES {
1591 return Err("comparison PNG exceeded 64 MiB base64 budget".into());
1592 }
1593 let encoded = STANDARD.decode(value.as_bytes())?;
1594 let mut decoder = png::Decoder::new(std::io::Cursor::new(encoded));
1595 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
1596 let mut reader = decoder.read_info()?;
1597 let output_size = reader.output_buffer_size();
1598 if output_size > 32 * 1024 * 1024 {
1599 return Err("comparison PNG decoded size exceeded 32 MiB".into());
1600 }
1601 let mut pixels = vec![0; output_size];
1602 let info = reader.next_frame(&mut pixels)?;
1603 pixels.truncate(info.buffer_size());
1604 let samples = info.color_type.samples();
1605 Ok((info.width, info.height, samples, pixels))
1606}
1607
1608#[derive(Debug, Serialize)]
1610pub struct ScreencastFrame {
1611 pub data: String,
1612 pub metadata: Value,
1613}
1614
1615#[derive(Debug, Clone, Copy, Serialize)]
1617pub struct ScreencastStats {
1618 pub received: u64,
1619 pub dropped: u64,
1620}
1621
1622pub struct ScreencastScope {
1628 pub(crate) cdp: CdpClient,
1629 pub(crate) session_id: Option<String>,
1630 pub(crate) receiver: tokio::sync::mpsc::Receiver<crate::browser::cdp::CdpScreencastFrame>,
1631 pub(crate) armed: bool,
1632}
1633
1634pub(crate) struct ScreencastStartupGuard {
1635 pub(crate) cdp: CdpClient,
1636 pub(crate) session_id: Option<String>,
1637 pub(crate) armed: bool,
1638}
1639
1640impl ScreencastStartupGuard {
1641 pub(crate) fn disarm(&mut self) {
1642 self.armed = false;
1643 }
1644}
1645
1646impl Drop for ScreencastStartupGuard {
1647 fn drop(&mut self) {
1648 if !self.armed {
1649 return;
1650 }
1651 self.cdp.close_screencast_channel();
1652 let cdp = self.cdp.clone();
1653 let session_id = self.session_id.clone();
1654 tokio::spawn(async move {
1655 let _ = stop_screencast_for(&cdp, session_id.as_deref()).await;
1656 });
1657 }
1658}
1659
1660impl ScreencastScope {
1661 pub async fn next_frame(&mut self) -> Option<ScreencastFrame> {
1665 while let Some(frame) = self.receiver.recv().await {
1666 if frame.session_id == self.session_id {
1667 return Some(ScreencastFrame {
1668 data: frame.data,
1669 metadata: frame.metadata,
1670 });
1671 }
1672 }
1673 None
1674 }
1675
1676 pub fn stats(&self) -> ScreencastStats {
1678 let (received, dropped) = self.cdp.screencast_stats();
1679 ScreencastStats { received, dropped }
1680 }
1681
1682 pub async fn stop(mut self) -> BrowserResult<ScreencastStats> {
1684 stop_screencast_for(&self.cdp, self.session_id.as_deref()).await?;
1685 let (received, dropped) = self.cdp.close_screencast_channel();
1686 self.armed = false;
1687 Ok(ScreencastStats { received, dropped })
1688 }
1689}
1690
1691impl Drop for ScreencastScope {
1692 fn drop(&mut self) {
1693 if !self.armed {
1694 return;
1695 }
1696 self.cdp.close_screencast_channel();
1697 let cdp = self.cdp.clone();
1698 let session_id = self.session_id.clone();
1699 tokio::spawn(async move {
1700 let _ = stop_screencast_for(&cdp, session_id.as_deref()).await;
1701 });
1702 }
1703}
1704
1705#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1706pub struct ObservationBoundarySummary {
1707 pub scanned_elements: usize,
1708 pub scan_limit: usize,
1709 pub shadow_roots: usize,
1710 pub child_frames: usize,
1711 pub canvases: usize,
1712 pub truncated: bool,
1713 #[serde(default)]
1714 pub text_truncated: bool,
1715}
1716
1717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1718#[serde(rename_all = "snake_case")]
1719pub enum ObservationIncompleteReason {
1720 VisibleText,
1721 AccessibilityNode,
1722 AccessibilityLabel,
1723 Control,
1724 ShadowBoundary,
1725 FrameBoundary,
1726 Canvas,
1727 BoundaryScan,
1728 MutationRace,
1729}
1730
1731#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1733#[serde(rename_all = "snake_case")]
1734pub enum ActionKind {
1735 Navigate,
1736 Click,
1737 ClickExpectPopup,
1738 DoubleClick,
1739 Hover,
1740 Drag,
1741 Type,
1742 KeyDown,
1743 KeyUp,
1744 KeyPress,
1745 Shortcut,
1746 Clear,
1747 Check,
1748 Uncheck,
1749 Select,
1750 Upload,
1751 Scroll,
1752}
1753
1754#[derive(Debug, Clone, Copy)]
1760pub(crate) struct ActionRequest<'a> {
1761 pub(crate) action: ActionKind,
1762 pub(crate) target: &'a str,
1763 pub(crate) expected_revision: Option<u64>,
1764}
1765
1766impl<'a> ActionRequest<'a> {
1767 pub(crate) const fn new(
1768 action: ActionKind,
1769 target: &'a str,
1770 expected_revision: Option<u64>,
1771 ) -> Self {
1772 Self {
1773 action,
1774 target,
1775 expected_revision,
1776 }
1777 }
1778}
1779
1780#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1782#[serde(rename_all = "snake_case")]
1783pub enum ActionStatus {
1784 Succeeded,
1785 CompletedWithVerificationFailure,
1786}
1787
1788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1790#[serde(rename_all = "snake_case")]
1791pub enum ActionFailureKind {
1792 StaleRevision,
1793 AmbiguousTarget,
1794 TargetNotFound,
1795 PolicyDenied,
1796 ConfirmationRequired,
1797 Transport,
1798 VerificationFailed,
1799}
1800
1801#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
1803#[serde(rename_all = "snake_case")]
1804pub enum ActionFailurePhase {
1805 #[default]
1806 Preflight,
1807 Policy,
1808 TargetResolution,
1809 Dispatch,
1810 BrowserEffect,
1811 Verification,
1812 Transport,
1813}
1814
1815#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1817#[serde(rename_all = "snake_case")]
1818pub enum RecoveryStrategy {
1819 None,
1820 Report,
1821 RetrySafe,
1822}
1823
1824#[derive(Debug, Clone, Serialize)]
1826pub struct ActionContractError {
1827 pub kind: ActionFailureKind,
1828 pub phase: ActionFailurePhase,
1829 #[serde(rename = "recoveryStrategy")]
1830 pub recovery_strategy: RecoveryStrategy,
1831 #[serde(rename = "executionId", skip_serializing_if = "Option::is_none")]
1832 pub execution_id: Option<String>,
1833 #[serde(rename = "expectedRevision")]
1834 pub expected_revision: u64,
1835 #[serde(rename = "currentRevision")]
1836 pub current_revision: u64,
1837 pub recovery: &'static str,
1838}
1839
1840impl ActionContractError {
1841 pub(crate) fn stale_revision(expected_revision: u64, current_revision: u64) -> Self {
1842 Self {
1843 kind: ActionFailureKind::StaleRevision,
1844 phase: ActionFailurePhase::Preflight,
1845 recovery_strategy: RecoveryStrategy::Report,
1846 execution_id: None,
1847 expected_revision,
1848 current_revision,
1849 recovery: "observe",
1850 }
1851 }
1852
1853 pub(crate) fn stale_revision_with_execution(
1854 expected_revision: u64,
1855 current_revision: u64,
1856 execution_id: String,
1857 ) -> Self {
1858 Self {
1859 execution_id: Some(execution_id),
1860 ..Self::stale_revision(expected_revision, current_revision)
1861 }
1862 }
1863}
1864
1865impl std::fmt::Display for ActionContractError {
1866 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1867 write!(
1868 formatter,
1869 "stale page revision: expected {}, current {}",
1870 self.expected_revision, self.current_revision
1871 )
1872 }
1873}
1874
1875impl Error for ActionContractError {}
1876
1877#[derive(Debug, Clone, Serialize)]
1879pub struct ActionVerificationError {
1880 pub kind: ActionFailureKind,
1881 pub action: ActionKind,
1882 pub phase: ActionFailurePhase,
1883 #[serde(rename = "recoveryStrategy")]
1884 pub recovery_strategy: RecoveryStrategy,
1885 #[serde(rename = "executionId", skip_serializing_if = "Option::is_none")]
1886 pub execution_id: Option<String>,
1887 #[serde(skip_serializing_if = "Option::is_none")]
1888 pub target: Option<ActionTarget>,
1889 pub revision: u64,
1890 pub reason: String,
1891}
1892
1893impl std::fmt::Display for ActionVerificationError {
1894 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1895 write!(formatter, "action verification failed: {}", self.reason)
1896 }
1897}
1898
1899impl Error for ActionVerificationError {}
1900
1901#[derive(Debug, Clone, Default, Serialize)]
1903pub struct ActionVerificationEvidence {
1904 #[serde(rename = "revisionDelta")]
1905 pub revision_delta: u64,
1906 #[serde(rename = "urlChanged")]
1907 pub url_changed: bool,
1908 #[serde(rename = "titleChanged")]
1909 pub title_changed: bool,
1910 #[serde(rename = "targetChanged")]
1911 pub target_changed: bool,
1912 #[serde(rename = "frameChanged")]
1913 pub frame_changed: bool,
1914 #[serde(rename = "popupOpened", skip_serializing_if = "is_false")]
1916 pub popup_opened: bool,
1917 #[serde(rename = "dialogOpen", skip_serializing_if = "is_false")]
1919 pub dialog_open: bool,
1920 #[serde(rename = "downloadStarted", skip_serializing_if = "is_false")]
1922 pub download_started: bool,
1923 #[serde(rename = "accessibilityDiff", skip_serializing_if = "Option::is_none")]
1926 pub accessibility_diff: Option<AccessibilityDiffSummary>,
1927}
1928
1929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1932#[serde(untagged)]
1933pub enum VerificationPredicate {
1934 UrlEquals {
1935 #[serde(rename = "urlEquals")]
1936 value: String,
1937 },
1938 TitleContains {
1939 #[serde(rename = "titleContains")]
1940 value: String,
1941 },
1942 Visible {
1943 visible: String,
1944 },
1945 TextContains {
1946 #[serde(rename = "textContains")]
1947 value: String,
1948 },
1949 PopupOpened {
1950 #[serde(rename = "popupOpened")]
1951 value: bool,
1952 },
1953 DialogOpen {
1954 #[serde(rename = "dialogOpen")]
1955 value: bool,
1956 },
1957 DownloadStarted {
1958 #[serde(rename = "downloadStarted")]
1959 value: bool,
1960 },
1961 RevisionEquals {
1962 #[serde(rename = "revisionEquals")]
1963 value: u64,
1964 },
1965 All {
1966 all: Vec<VerificationPredicate>,
1967 },
1968 Any {
1969 any: Vec<VerificationPredicate>,
1970 },
1971 Not {
1972 not: Box<VerificationPredicate>,
1973 },
1974}
1975
1976impl VerificationPredicate {
1977 pub(crate) fn validate(&self, depth: usize) -> BrowserResult<()> {
1978 const MAX_DEPTH: usize = 4;
1979 const MAX_COMPOSITION: usize = 8;
1980 if depth > MAX_DEPTH {
1981 return Err("verification predicate nesting exceeds four levels".into());
1982 }
1983 match self {
1984 Self::UrlEquals { value }
1985 | Self::TitleContains { value }
1986 | Self::TextContains { value } => {
1987 if value.is_empty() || value.len() > 1024 {
1988 return Err("verification text must be 1..=1024 bytes".into());
1989 }
1990 }
1991 Self::Visible { visible } => {
1992 if visible.is_empty() || visible.len() > 1024 {
1993 return Err("verification target must be 1..=1024 bytes".into());
1994 }
1995 }
1996 Self::All { all } => {
1997 if all.is_empty() || all.len() > MAX_COMPOSITION {
1998 return Err("verification composition must contain 1..=8 predicates".into());
1999 }
2000 for predicate in all {
2001 predicate.validate(depth + 1)?;
2002 }
2003 }
2004 Self::Any { any } => {
2005 if any.is_empty() || any.len() > MAX_COMPOSITION {
2006 return Err("verification composition must contain 1..=8 predicates".into());
2007 }
2008 for predicate in any {
2009 predicate.validate(depth + 1)?;
2010 }
2011 }
2012 Self::Not { not } => not.validate(depth + 1)?,
2013 Self::PopupOpened { .. }
2014 | Self::DialogOpen { .. }
2015 | Self::DownloadStarted { .. }
2016 | Self::RevisionEquals { .. } => {}
2017 }
2018 Ok(())
2019 }
2020}
2021
2022#[derive(Debug, Clone, Serialize)]
2024pub struct VerificationOutcome {
2025 pub status: &'static str,
2026 pub predicate: VerificationPredicate,
2027 #[serde(rename = "elapsedMs")]
2028 pub elapsed_ms: u64,
2029 pub state: String,
2030}
2031
2032#[derive(Debug, Clone, Copy, Default, Serialize)]
2033pub struct AccessibilityDiffSummary {
2034 pub added: usize,
2035 pub removed: usize,
2036 pub changed: usize,
2037}
2038
2039#[derive(Debug, Clone, Serialize)]
2041pub struct ActionTarget {
2042 pub label: String,
2043 #[serde(skip_serializing_if = "Option::is_none")]
2044 pub reference: Option<String>,
2045}
2046
2047#[derive(Debug, Clone, Serialize)]
2052pub struct ActionOutcome {
2053 pub status: ActionStatus,
2054 pub action: ActionKind,
2055 #[serde(rename = "executionId")]
2056 pub execution_id: String,
2057 #[serde(skip_serializing_if = "Option::is_none")]
2058 pub target: Option<ActionTarget>,
2059 pub revision: u64,
2060 #[serde(rename = "previousRevision")]
2061 pub previous_revision: u64,
2062 #[serde(rename = "currentRevision")]
2063 pub current_revision: u64,
2064 pub target_id: String,
2065 pub frame_id: String,
2066 pub verification: ActionVerificationEvidence,
2067 #[serde(skip_serializing_if = "Option::is_none")]
2068 pub evidence: Option<Value>,
2069}
2070
2071#[derive(Debug, Clone, Serialize)]
2075pub struct NavigationOutcome {
2076 pub status: ActionStatus,
2077 pub action: ActionKind,
2078 #[serde(rename = "executionId")]
2079 pub execution_id: String,
2080 pub page: PageInfo,
2081 #[serde(rename = "previousRevision")]
2082 pub previous_revision: u64,
2083 #[serde(rename = "currentRevision")]
2084 pub current_revision: u64,
2085 pub verification: ActionVerificationEvidence,
2086}
2087
2088#[derive(Debug, Clone, Serialize)]
2091pub struct CoordinateClickOutcome {
2092 #[serde(rename = "executionId")]
2093 pub execution_id: String,
2094 pub x: f64,
2095 pub y: f64,
2096 pub hit: Option<CoordinateHit>,
2097 pub revision: u64,
2098 pub target_id: String,
2099 pub frame_id: String,
2100}
2101
2102#[derive(Debug, Clone, Serialize)]
2103pub struct CoordinateHit {
2104 pub tag: String,
2105 pub role: Option<String>,
2106 pub name: Option<String>,
2107}
2108
2109#[derive(Debug, Clone, Serialize)]
2111pub struct PopupClickOutcome {
2112 pub action: ActionKind,
2113 #[serde(rename = "executionId")]
2114 pub execution_id: String,
2115 pub target: ActionTarget,
2116 pub revision: u64,
2117 pub target_id: String,
2118 pub frame_id: String,
2119 pub causally_verified_popup: bool,
2120 pub popup_id: String,
2121 pub opener_id: String,
2122 pub evidence: PopupVerificationEvidence,
2123}
2124
2125#[derive(Debug, Clone, Serialize)]
2127pub struct PopupVerificationEvidence {
2128 pub trusted_click_witness: bool,
2129 pub release_acknowledged: bool,
2130 pub release_ack_wait_ms: f64,
2131 pub topology_sequence_before_release: u64,
2132 pub popup_observed_sequence: u64,
2133 pub attached: bool,
2134 pub ready_state: String,
2135}
2136
2137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2139#[serde(rename_all = "snake_case")]
2140pub enum PopupClickErrorKind {
2141 ReleaseFailed,
2142 WitnessMissing,
2143 TopologyLagged,
2144 PopupMissing,
2145 PopupAmbiguous,
2146 PopupDestroyed,
2147 PopupOpenerMismatch,
2148 PopupUnreadable,
2149}
2150
2151#[derive(Debug, Clone, Serialize)]
2153pub struct PopupClickError {
2154 pub kind: PopupClickErrorKind,
2155 pub message: String,
2156}
2157
2158impl std::fmt::Display for PopupClickError {
2159 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2160 write!(formatter, "popup click {:?}: {}", self.kind, self.message)
2161 }
2162}
2163
2164impl Error for PopupClickError {}
2165
2166#[derive(Debug, Clone)]
2167pub(crate) struct PopupTopologySnapshot {
2168 pub(crate) original_target_id: String,
2169 pub(crate) original_frame_id: String,
2170 pub(crate) preexisting_target_ids: HashSet<String>,
2171 pub(crate) sequence: u64,
2172 pub(crate) event_loss_count: u64,
2173}
2174
2175#[derive(Debug, Clone)]
2176pub(crate) struct PopupCandidate {
2177 pub(crate) target: PageTargetInfo,
2178 pub(crate) observed_sequence: u64,
2179}
2180
2181#[derive(Debug, Clone)]
2182pub(crate) struct CompactPageContext {
2183 pub(crate) page: PageInfo,
2184 pub(crate) text: String,
2185 pub(crate) accessibility: CompactAccessibilitySnapshot,
2186 pub(crate) consistency: ObservationConsistency,
2187 pub(crate) boundaries: ObservationBoundarySummary,
2188 pub(crate) incomplete: Vec<ObservationIncompleteReason>,
2189}
2190
2191impl CompactPageContext {
2192 pub(crate) fn into_page_context(self) -> PageContext {
2193 PageContext {
2194 page: self.page,
2195 text: self.text,
2196 dom: None,
2197 accessibility: self.accessibility,
2198 consistency: self.consistency,
2199 boundaries: self.boundaries,
2200 incomplete: self.incomplete,
2201 screenshot: None,
2202 }
2203 }
2204}
2205
2206#[derive(Debug, Deserialize)]
2207pub(crate) struct EvaluatedPageState {
2208 pub(crate) url: String,
2209 pub(crate) title: String,
2210 #[serde(default)]
2211 pub(crate) ready_state: String,
2212 #[serde(default)]
2213 pub(crate) mutation_revision: u64,
2214 #[serde(default)]
2215 pub(crate) boundaries: ObservationBoundarySummary,
2216 pub(crate) text: String,
2217}
2218
2219impl AccessibilitySnapshot {
2220 pub fn format(&self) -> String {
2221 let mut output = format!(
2222 "url: {}\ntitle: {}\nreadyState: {}\n\n{}",
2223 self.page.url,
2224 self.page.title,
2225 self.page.ready_state,
2226 format_tree(&self.roots, 0)
2227 );
2228 if !self.interactive.is_empty() {
2229 output.push_str("\nInteractive elements:\n");
2230 for element in &self.interactive {
2231 output.push_str(&format!(
2232 "{} [{}] {}\n",
2233 element.reference, element.role, element.name
2234 ));
2235 }
2236 }
2237 output
2238 }
2239}
2240
2241#[derive(Debug, Clone, Serialize)]
2248pub struct FailureTracePack {
2249 pub outcome: ActionOutcome,
2251 pub error: String,
2253 #[serde(skip_serializing_if = "Option::is_none")]
2255 pub last_observation: Option<CompactObservationTrace>,
2256 pub topology: TopologyTrace,
2258 pub trace_bytes: usize,
2260}
2261
2262#[derive(Debug, Clone, Serialize)]
2264pub struct CompactObservationTrace {
2265 pub page: PageInfo,
2266 pub revision: u64,
2267 #[serde(skip_serializing_if = "Vec::is_empty")]
2268 pub interactive: Vec<CompactInteractiveElement>,
2269 #[serde(skip_serializing_if = "Option::is_none")]
2270 pub completeness: Option<ObservationCompleteness>,
2271}
2272
2273#[derive(Debug, Clone, Serialize)]
2275pub struct TopologyTrace {
2276 pub sequence: u64,
2277 pub active_target_id: Option<String>,
2278 pub active_frame_id: Option<String>,
2279 pub target_count: usize,
2280 pub frame_count: usize,
2281 pub event_loss_count: u64,
2282}
2283
2284pub struct BrowserSession {
2286 pub(crate) cdp: CdpClient,
2287 pub(crate) chrome: Option<ChromeProcess>,
2288 pub(crate) disposable_profile: Option<DisposableProfileDir>,
2289 pub(crate) launched_incognito_context_id: Option<String>,
2290 pub(crate) profile: String,
2291 pub(crate) interaction_mode: InteractionMode,
2292 pub(crate) mouse: MouseEngine,
2293 pub(crate) pointer: Mutex<Option<Point>>,
2294 pub(crate) page_revision: Arc<AtomicU64>,
2295 pub(crate) observation_cache: Mutex<Option<CachedObservation>>,
2296 pub(crate) network_wait_leases: Arc<Mutex<NetworkLeaseState>>,
2297 pub(crate) diagnostic_leases: Arc<Mutex<DiagnosticLeaseState>>,
2298 pub(crate) download_scope: Arc<Mutex<()>>,
2299 pub(crate) download_sequence: AtomicU64,
2300 pub(crate) topology: Arc<Mutex<TopologyRegistry>>,
2301 pub(crate) popup_click_scope: Mutex<()>,
2302 pub(crate) upload_root: PathBuf,
2303 pub(crate) policy: BrowserPolicy,
2304 pub(crate) policy_interception: Option<PolicyInterception>,
2305 pub(crate) audit_log: std::sync::Mutex<VecDeque<AuditEntry>>,
2306 pub(crate) audit_sequence: AtomicU64,
2307 pub(crate) audit_enabled: bool,
2308}
2309
2310pub(crate) struct CachedObservation {
2311 pub(crate) revision: u64,
2312 pub(crate) context: CompactPageContext,
2313}
2314
2315pub(crate) type PausedPolicyRequests = Arc<Mutex<HashSet<(Option<String>, String)>>>;
2316
2317pub(crate) struct PolicyInterception {
2318 pub(crate) cdp: CdpClient,
2319 pub(crate) sessions: Arc<Mutex<HashSet<String>>>,
2320 pub(crate) paused: PausedPolicyRequests,
2321 pub(crate) last_denial: Arc<Mutex<Option<PolicyError>>>,
2322 pub(crate) worker: tokio::task::JoinHandle<()>,
2323}
2324
2325impl PolicyInterception {
2326 pub(crate) async fn start(
2327 cdp: CdpClient,
2328 policy: BrowserPolicy,
2329 initial_session: String,
2330 ) -> BrowserResult<Self> {
2331 let mut events = cdp.subscribe_events_with_params();
2332 let sessions = Arc::new(Mutex::new(HashSet::from([initial_session.clone()])));
2333 let paused = Arc::new(Mutex::new(HashSet::new()));
2334 let last_denial = Arc::new(Mutex::new(None));
2335 let worker_cdp = cdp.clone();
2336 let worker_sessions = Arc::clone(&sessions);
2337 let worker_paused = Arc::clone(&paused);
2338 let worker_denial = Arc::clone(&last_denial);
2339 let worker = tokio::spawn(async move {
2340 loop {
2341 let event = match events.recv().await {
2342 Ok(event) => event,
2343 Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
2344 *worker_denial.lock().await = Some(PolicyError::Denied {
2345 operation: "navigation".to_string(),
2346 reason: format!(
2347 "policy event stream lagged by {count}; paused requests remain blocked"
2348 ),
2349 });
2350 continue;
2351 }
2352 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
2353 };
2354 if event.method == "Target.attachedToTarget" {
2355 if let Some(session_id) = event.params["sessionId"].as_str() {
2356 let session_id = session_id.to_string();
2357 if enable_fetch_for(&worker_cdp, &session_id).await.is_ok() {
2358 worker_sessions.lock().await.insert(session_id.clone());
2359 let _ = worker_cdp
2360 .send_to_session(
2361 &session_id,
2362 "Runtime.runIfWaitingForDebugger",
2363 None,
2364 )
2365 .await;
2366 }
2367 }
2368 continue;
2369 }
2370 if event.method != "Fetch.requestPaused" {
2371 continue;
2372 }
2373 let Some(request_id) = event.params["requestId"].as_str() else {
2374 continue;
2375 };
2376 let request_id = request_id.to_string();
2377 let key = (event.session_id.clone(), request_id.clone());
2378 worker_paused.lock().await.insert(key.clone());
2379 let url = event.params["request"]["url"].as_str().unwrap_or_default();
2380 let decision = policy.require_url(url).await;
2381 let (method, params) = match decision {
2382 Ok(_) => (
2383 "Fetch.continueRequest",
2384 serde_json::json!({"requestId": &request_id}),
2385 ),
2386 Err(error) => {
2387 *worker_denial.lock().await = Some(error);
2388 (
2389 "Fetch.failRequest",
2390 serde_json::json!({
2391 "requestId": &request_id,
2392 "errorReason": "BlockedByClient"
2393 }),
2394 )
2395 }
2396 };
2397 let _ = match event.session_id.as_deref() {
2398 Some(session_id) => {
2399 worker_cdp
2400 .send_to_session(session_id, method, Some(params))
2401 .await
2402 }
2403 None => worker_cdp.send(method, Some(params)).await,
2404 };
2405 worker_paused.lock().await.remove(&key);
2406 }
2407 });
2408 if let Err(error) = enable_fetch_for(&cdp, &initial_session).await {
2409 worker.abort();
2410 return Err(error);
2411 }
2412 Ok(Self {
2413 cdp,
2414 sessions,
2415 paused,
2416 last_denial,
2417 worker,
2418 })
2419 }
2420
2421 async fn take_denial(&self) -> Option<PolicyError> {
2422 self.last_denial.lock().await.take()
2423 }
2424
2425 async fn shutdown(self) {
2426 for (session_id, request_id) in self.paused.lock().await.clone() {
2427 let params = Some(serde_json::json!({
2428 "requestId": request_id,
2429 "errorReason": "Aborted"
2430 }));
2431 let _ = match session_id.as_deref() {
2432 Some(session_id) => {
2433 self.cdp
2434 .send_to_session(session_id, "Fetch.failRequest", params)
2435 .await
2436 }
2437 None => self.cdp.send("Fetch.failRequest", params).await,
2438 };
2439 }
2440 for session_id in self.sessions.lock().await.clone() {
2441 let _ = disable_fetch_for(&self.cdp, Some(&session_id)).await;
2442 }
2443 self.worker.abort();
2444 }
2445}
2446
2447#[derive(Debug)]
2453pub(crate) struct DisposableProfileDir {
2454 pub(crate) path: PathBuf,
2455}
2456
2457pub(crate) const DISPOSABLE_OWNER_FILE: &str = ".glass-owner.json";
2458pub(crate) const DISPOSABLE_CLEANUP_BATCH: usize = 1024;
2459
2460#[derive(Debug, Serialize, Deserialize)]
2461pub(crate) struct DisposableProfileOwner {
2462 pub(crate) pid: u32,
2463 pub(crate) process_start: u64,
2464}
2465
2466impl DisposableProfileDir {
2467 fn create() -> BrowserResult<Self> {
2468 static NEXT_DISPOSABLE_PROFILE: AtomicU64 = AtomicU64::new(0);
2469
2470 let root = std::env::temp_dir().join("glass");
2471 std::fs::create_dir_all(&root)?;
2472 Self::cleanup_abandoned(&root)?;
2473 let pid = std::process::id();
2474 let process_start = process_start_identity(pid)
2475 .ok_or("could not determine Glass process start identity")?;
2476 for _ in 0..32 {
2477 let sequence = NEXT_DISPOSABLE_PROFILE.fetch_add(1, Ordering::Relaxed);
2478 let nonce = format!(
2479 "{}-{}-{sequence}",
2480 std::process::id(),
2481 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
2482 );
2483 let path = root.join(format!("incognito-{nonce}"));
2484 match std::fs::create_dir(&path) {
2485 Ok(()) => {
2486 let owner = DisposableProfileOwner { pid, process_start };
2487 let owner_json = serde_json::to_vec(&owner)?;
2488 if let Err(error) = std::fs::write(path.join(DISPOSABLE_OWNER_FILE), owner_json)
2489 {
2490 let _ = std::fs::remove_dir_all(&path);
2491 return Err(error.into());
2492 }
2493 return Ok(Self { path });
2494 }
2495 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2496 Err(error) => return Err(error.into()),
2497 }
2498 }
2499 Err("could not allocate a unique incognito user-data directory".into())
2500 }
2501
2502 fn path(&self) -> &Path {
2503 &self.path
2504 }
2505
2506 fn cleanup_abandoned(root: &Path) -> BrowserResult<()> {
2507 let mut candidates = Vec::new();
2508 for entry in std::fs::read_dir(root)? {
2509 let entry = entry?;
2510 if !entry.file_type()?.is_dir()
2511 || !entry
2512 .file_name()
2513 .to_string_lossy()
2514 .starts_with("incognito-")
2515 {
2516 continue;
2517 }
2518 let bytes = match std::fs::read(entry.path().join(DISPOSABLE_OWNER_FILE)) {
2519 Ok(bytes) => bytes,
2520 Err(_) => continue,
2521 };
2522 let owner = match serde_json::from_slice::<DisposableProfileOwner>(&bytes) {
2523 Ok(owner) if owner.pid != 0 && owner.process_start != 0 => owner,
2524 _ => continue,
2525 };
2526 candidates.push((entry.path(), owner));
2527 if candidates.len() == DISPOSABLE_CLEANUP_BATCH {
2528 reap_disposable_candidates(&mut candidates)?;
2529 }
2530 }
2531 reap_disposable_candidates(&mut candidates)
2532 }
2533}
2534
2535pub(crate) fn reap_disposable_candidates(
2536 candidates: &mut Vec<(PathBuf, DisposableProfileOwner)>,
2537) -> BrowserResult<()> {
2538 if candidates.is_empty() {
2539 return Ok(());
2540 }
2541 let pids = candidates
2542 .iter()
2543 .map(|(_, owner)| Pid::from_u32(owner.pid))
2544 .collect::<Vec<_>>();
2545 let mut system = System::new();
2546 system.refresh_processes(ProcessesToUpdate::Some(&pids), true);
2547 for (path, owner) in candidates.drain(..) {
2548 let live_start = system
2549 .process(Pid::from_u32(owner.pid))
2550 .map(|process| process.start_time());
2551 if live_start != Some(owner.process_start)
2552 && let Err(error) = std::fs::remove_dir_all(path)
2553 && error.kind() != std::io::ErrorKind::NotFound
2554 {
2555 return Err(error.into());
2556 }
2557 }
2558 Ok(())
2559}
2560
2561pub(crate) fn process_start_identity(pid: u32) -> Option<u64> {
2562 let pid = Pid::from_u32(pid);
2563 let mut system = System::new();
2564 system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
2565 system.process(pid).map(|process| process.start_time())
2566}
2567
2568impl Drop for DisposableProfileDir {
2569 fn drop(&mut self) {
2570 if let Err(error) = std::fs::remove_dir_all(&self.path)
2571 && error.kind() != std::io::ErrorKind::NotFound
2572 {
2573 tracing::warn!(path = %self.path.display(), %error, "could not remove disposable incognito profile");
2574 }
2575 }
2576}
2577
2578pub(crate) async fn wait_for_stable_popup_topology(
2579 topology: &Arc<Mutex<TopologyRegistry>>,
2580 snapshot: &PopupTopologySnapshot,
2581 candidate: &PopupCandidate,
2582 deadline: tokio::time::Instant,
2583 quiet_interval: Duration,
2584) -> Result<PopupCandidate, PopupClickError> {
2585 let mut quiet_since = tokio::time::Instant::now();
2586 let mut stable_state = None;
2587 loop {
2588 let (current, state) = {
2589 let topology = topology.lock().await;
2590 let current = assess_popup_topology(snapshot, &topology, true)?;
2591 (current, (topology.sequence, topology.event_loss_count))
2592 };
2593 if current.target.id != candidate.target.id {
2594 return Err(popup_typed_error(
2595 PopupClickErrorKind::PopupAmbiguous,
2596 "popup candidate changed during topology stabilization",
2597 ));
2598 }
2599
2600 let now = tokio::time::Instant::now();
2601 if stable_state != Some(state) {
2602 stable_state = Some(state);
2603 quiet_since = now;
2604 } else if now.duration_since(quiet_since) >= quiet_interval {
2605 return Ok(current);
2606 }
2607 if now >= deadline {
2608 return Err(popup_typed_error(
2609 PopupClickErrorKind::TopologyLagged,
2610 "popup topology did not stabilize before the evidence deadline",
2611 ));
2612 }
2613
2614 let quiet_remaining = quiet_interval.saturating_sub(now.duration_since(quiet_since));
2615 let deadline_remaining = deadline.saturating_duration_since(now);
2616 tokio::time::sleep(
2617 POPUP_TOPOLOGY_POLL_INTERVAL
2618 .min(quiet_remaining)
2619 .min(deadline_remaining),
2620 )
2621 .await;
2622 }
2623}
2624
2625pub(crate) fn interactive_elements(roots: &[AxNode], revision: u64) -> Vec<InteractiveElement> {
2626 find_interactive_elements(roots)
2627 .into_iter()
2628 .filter_map(|node| {
2629 let backend_dom_node_id = node.backend_dom_node_id?;
2630 Some(InteractiveElement {
2631 reference: backend_node_reference(revision, backend_dom_node_id),
2632 role: node.role.clone(),
2633 name: node.name.clone(),
2634 description: node.description.clone(),
2635 backend_dom_node_id,
2636 input_type: None,
2637 })
2638 })
2639 .collect()
2640}
2641
2642pub(crate) fn truncate_visible_text(text: &str, max_bytes: usize) -> String {
2643 truncate_visible_text_with_status(text, max_bytes).0
2644}
2645
2646pub(crate) fn collect_diagnostic_event(
2647 event: &CdpEventWithParams,
2648 console: &mut Vec<ConsoleEvidence>,
2649 network: &mut Vec<NetworkEvidence>,
2650 request_indexes: &mut HashMap<String, usize>,
2651 dropped: &mut u64,
2652) {
2653 match event.method.as_str() {
2654 "Runtime.consoleAPICalled" => {
2655 push_bounded(
2656 console,
2657 ConsoleEvidence {
2658 level: bounded_diagnostic_text(event.params["type"].as_str().unwrap_or("log")),
2659 text: "[console arguments redacted]".to_string(),
2660 },
2661 dropped,
2662 );
2663 }
2664 "Log.entryAdded" => {
2665 let entry = &event.params["entry"];
2666 push_bounded(
2667 console,
2668 ConsoleEvidence {
2669 level: bounded_diagnostic_text(entry["level"].as_str().unwrap_or("log")),
2670 text: redact_diagnostic_text(entry["text"].as_str().unwrap_or("")),
2671 },
2672 dropped,
2673 );
2674 }
2675 "Network.requestWillBeSent" => {
2676 let Some(request_id) = event.params["requestId"].as_str() else {
2677 return;
2678 };
2679 if let Some(index) = request_indexes.get(request_id).copied() {
2680 network[index].redirect_count = network[index].redirect_count.saturating_add(1);
2681 return;
2682 }
2683 if network.len() >= MAX_DIAGNOSTIC_EVENTS {
2684 *dropped = dropped.saturating_add(1);
2685 return;
2686 }
2687 let request = &event.params["request"];
2688 let index = network.len();
2689 request_indexes.insert(request_id.to_string(), index);
2690 network.push(NetworkEvidence {
2691 request_id: bounded_diagnostic_text(request_id),
2692 method: bounded_diagnostic_text(request["method"].as_str().unwrap_or("")),
2693 url: redact_diagnostic_url(request["url"].as_str().unwrap_or("")),
2694 status: None,
2695 failure: None,
2696 safe_header_names: safe_header_names(&request["headers"]),
2697 redirect_count: u16::from(event.params.get("redirectResponse").is_some()),
2698 });
2699 }
2700 "Network.responseReceived" => {
2701 if let Some(index) = event.params["requestId"]
2702 .as_str()
2703 .and_then(|id| request_indexes.get(id))
2704 .copied()
2705 {
2706 network[index].status = event.params["response"]["status"]
2707 .as_u64()
2708 .and_then(|status| u16::try_from(status).ok());
2709 }
2710 }
2711 "Network.loadingFailed" => {
2712 if let Some(index) = event.params["requestId"]
2713 .as_str()
2714 .and_then(|id| request_indexes.get(id))
2715 .copied()
2716 {
2717 network[index].failure = Some(bounded_diagnostic_text(
2718 event.params["errorText"]
2719 .as_str()
2720 .unwrap_or("request_failed"),
2721 ));
2722 }
2723 }
2724 _ => {}
2725 }
2726}
2727
2728pub(crate) fn push_bounded<T>(values: &mut Vec<T>, value: T, dropped: &mut u64) {
2729 if values.len() < MAX_DIAGNOSTIC_EVENTS {
2730 values.push(value);
2731 } else {
2732 *dropped = dropped.saturating_add(1);
2733 }
2734}
2735
2736pub(crate) fn bounded_diagnostic_text(value: &str) -> String {
2737 truncate_utf8_bytes(value, MAX_DIAGNOSTIC_TEXT_BYTES)
2738}
2739
2740pub(crate) fn redact_diagnostic_text(value: &str) -> String {
2741 let lower = value.to_ascii_lowercase();
2742 if ["authorization", "cookie", "password", "token", "secret"]
2743 .iter()
2744 .any(|marker| lower.contains(marker))
2745 {
2746 return "[redacted sensitive console entry]".to_string();
2747 }
2748 let redacted = value
2749 .split_whitespace()
2750 .map(|part| {
2751 if part.starts_with("http://") || part.starts_with("https://") {
2752 redact_diagnostic_url(part)
2753 } else {
2754 part.to_string()
2755 }
2756 })
2757 .collect::<Vec<_>>()
2758 .join(" ");
2759 bounded_diagnostic_text(&redacted)
2760}
2761
2762pub(crate) fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String {
2763 let mut end = value.len().min(max_bytes);
2764 while end > 0 && !value.is_char_boundary(end) {
2765 end -= 1;
2766 }
2767 value[..end].to_string()
2768}
2769
2770pub(crate) fn redact_diagnostic_url(value: &str) -> String {
2771 let Ok(mut url) = url::Url::parse(value) else {
2772 return truncate_utf8_bytes(
2773 value.split('?').next().unwrap_or(""),
2774 MAX_DIAGNOSTIC_URL_BYTES,
2775 );
2776 };
2777 let _ = url.set_username("");
2778 let _ = url.set_password(None);
2779 if url.query().is_some() {
2780 let names = url
2781 .query_pairs()
2782 .map(|(name, _)| name.into_owned())
2783 .collect::<Vec<_>>();
2784 url.set_query(None);
2785 if !names.is_empty() {
2786 url.query_pairs_mut()
2787 .extend_pairs(names.iter().map(|name| (name.as_str(), "[redacted]")));
2788 }
2789 }
2790 url.set_fragment(None);
2791 truncate_utf8_bytes(url.as_str(), MAX_DIAGNOSTIC_URL_BYTES)
2792}
2793
2794pub(crate) fn safe_header_names(headers: &Value) -> Vec<String> {
2795 let mut names = headers
2796 .as_object()
2797 .into_iter()
2798 .flat_map(|headers| headers.keys())
2799 .filter(|name| {
2800 !matches!(
2801 name.to_ascii_lowercase().as_str(),
2802 "authorization" | "proxy-authorization" | "cookie" | "set-cookie"
2803 )
2804 })
2805 .take(32)
2806 .map(|name| truncate_utf8_bytes(name, 128))
2807 .collect::<Vec<_>>();
2808 names.sort();
2809 names
2810}
2811
2812pub(crate) fn finite_nonnegative_u64(value: &Value) -> u64 {
2813 value
2814 .as_f64()
2815 .filter(|value| value.is_finite() && *value >= 0.0)
2816 .map(|value| value.min(u64::MAX as f64) as u64)
2817 .unwrap_or(0)
2818}
2819
2820pub(crate) fn validate_visual_options(options: &VisualCaptureOptions) -> BrowserResult<()> {
2821 if !options.scale.is_finite() || !(0.1..=4.0).contains(&options.scale) {
2822 return Err("visual scale must be finite and between 0.1 and 4.0".into());
2823 }
2824 if options.full_page as u8 + options.clip.is_some() as u8 + options.target.is_some() as u8 > 1 {
2825 return Err("full-page, clip, and element capture are mutually exclusive".into());
2826 }
2827 if options.format == VisualFormat::Png && options.quality.is_some() {
2828 return Err("PNG capture does not accept quality".into());
2829 }
2830 if options.quality.is_some_and(|quality| quality > 100) {
2831 return Err("visual quality must be between 0 and 100".into());
2832 }
2833 if let Some(clip) = options.clip {
2834 for value in [clip.x, clip.y, clip.width, clip.height] {
2835 if !value.is_finite() {
2836 return Err("visual clip values must be finite".into());
2837 }
2838 }
2839 if clip.x < 0.0 || clip.y < 0.0 || clip.width <= 0.0 || clip.height <= 0.0 {
2840 return Err("visual clip must have non-negative origin and positive size".into());
2841 }
2842 validate_effective_visual_clip(Some(clip), options.scale)?;
2843 }
2844 Ok(())
2845}
2846
2847pub(crate) fn validate_effective_visual_clip(
2848 clip: Option<VisualClip>,
2849 scale: f64,
2850) -> BrowserResult<()> {
2851 let Some(clip) = clip else { return Ok(()) };
2852 let width = clip.width * scale;
2853 let height = clip.height * scale;
2854 if width > 16_384.0 || height > 16_384.0 || width * height > MAX_VISUAL_PIXELS {
2855 return Err("visual output exceeds the 16384-axis or 8-megapixel budget".into());
2856 }
2857 Ok(())
2858}
2859
2860pub(crate) fn visual_clips_match(first: VisualClip, second: VisualClip) -> bool {
2861 [
2862 (first.x, second.x),
2863 (first.y, second.y),
2864 (first.width, second.width),
2865 (first.height, second.height),
2866 ]
2867 .into_iter()
2868 .all(|(first, second)| (first - second).abs() <= 0.5)
2869}
2870
2871pub(crate) fn visual_rect(value: &Value) -> BrowserResult<VisualClip> {
2872 let clip = VisualClip {
2873 x: value["x"].as_f64().unwrap_or(0.0),
2874 y: value["y"].as_f64().unwrap_or(0.0),
2875 width: value["width"].as_f64().ok_or("visual width was missing")?,
2876 height: value["height"]
2877 .as_f64()
2878 .ok_or("visual height was missing")?,
2879 };
2880 validate_visual_options(&VisualCaptureOptions {
2881 clip: Some(clip),
2882 ..VisualCaptureOptions::default()
2883 })?;
2884 Ok(clip)
2885}
2886
2887pub(crate) fn visual_viewport_rect(value: &Value) -> BrowserResult<VisualClip> {
2888 visual_rect(&serde_json::json!({
2889 "x": value["pageX"].as_f64().unwrap_or(0.0),
2890 "y": value["pageY"].as_f64().unwrap_or(0.0),
2891 "width": value["clientWidth"].as_f64().ok_or("visual viewport width was missing")?,
2892 "height": value["clientHeight"].as_f64().ok_or("visual viewport height was missing")?
2893 }))
2894}
2895
2896pub(crate) fn decoded_base64_len(value: &str) -> BrowserResult<usize> {
2897 if !value.len().is_multiple_of(4) {
2898 return Err("visual base64 payload had invalid length".into());
2899 }
2900 let padding = value
2901 .as_bytes()
2902 .iter()
2903 .rev()
2904 .take_while(|byte| **byte == b'=')
2905 .count();
2906 Ok(value.len() / 4 * 3 - padding)
2907}
2908
2909pub(crate) async fn stop_screencast_for(
2910 cdp: &CdpClient,
2911 session_id: Option<&str>,
2912) -> BrowserResult<()> {
2913 match session_id {
2914 Some(session_id) => {
2915 cdp.send_to_session(session_id, "Page.stopScreencast", None)
2916 .await?;
2917 }
2918 None => {
2919 cdp.send("Page.stopScreencast", None).await?;
2920 }
2921 }
2922 Ok(())
2923}
2924
2925pub(crate) async fn disable_fetch_for(
2926 cdp: &CdpClient,
2927 session_id: Option<&str>,
2928) -> BrowserResult<()> {
2929 match session_id {
2930 Some(session_id) => {
2931 cdp.send_to_session(session_id, "Fetch.disable", None)
2932 .await?
2933 }
2934 None => cdp.send("Fetch.disable", None).await?,
2935 };
2936 Ok(())
2937}
2938
2939pub(crate) async fn enable_fetch_for(cdp: &CdpClient, session_id: &str) -> BrowserResult<()> {
2940 cdp.send_to_session(
2941 session_id,
2942 "Fetch.enable",
2943 Some(serde_json::json!({
2944 "patterns": [{
2945 "urlPattern": "*",
2946 "resourceType": "Document",
2947 "requestStage": "Request"
2948 }]
2949 })),
2950 )
2951 .await?;
2952 Ok(())
2953}
2954
2955pub(crate) fn visual_quad_rect(value: &Value) -> BrowserResult<VisualClip> {
2956 let values = value
2957 .as_array()
2958 .ok_or("visual element border quad was missing")?;
2959 if values.len() != 8 {
2960 return Err("visual element border quad must contain eight coordinates".into());
2961 }
2962 let coordinates = values
2963 .iter()
2964 .map(|value| {
2965 value
2966 .as_f64()
2967 .ok_or("visual border coordinate was not numeric")
2968 })
2969 .collect::<Result<Vec<_>, _>>()?;
2970 let xs = [
2971 coordinates[0],
2972 coordinates[2],
2973 coordinates[4],
2974 coordinates[6],
2975 ];
2976 let ys = [
2977 coordinates[1],
2978 coordinates[3],
2979 coordinates[5],
2980 coordinates[7],
2981 ];
2982 let left = xs.into_iter().fold(f64::INFINITY, f64::min);
2983 let right = xs.into_iter().fold(f64::NEG_INFINITY, f64::max);
2984 let top = ys.into_iter().fold(f64::INFINITY, f64::min);
2985 let bottom = ys.into_iter().fold(f64::NEG_INFINITY, f64::max);
2986 let width = right - left;
2987 let height = bottom - top;
2988 if width <= 0.0 || height <= 0.0 {
2989 return Err("visual element has empty geometry".into());
2990 }
2991 Ok(VisualClip {
2992 x: left,
2993 y: top,
2994 width,
2995 height,
2996 })
2997}
2998
2999pub(crate) fn truncate_visible_text_with_status(text: &str, max_bytes: usize) -> (String, bool) {
3000 if text.len() <= max_bytes {
3001 return (text.to_string(), false);
3002 }
3003
3004 let content_limit = max_bytes.saturating_sub(TEXT_TRUNCATION_MARKER.len());
3005 let mut end = content_limit;
3006 while end > 0 && !text.is_char_boundary(end) {
3007 end -= 1;
3008 }
3009
3010 let mut truncated = text[..end].to_string();
3011 if max_bytes >= TEXT_TRUNCATION_MARKER.len() {
3012 truncated.push_str(TEXT_TRUNCATION_MARKER);
3013 }
3014 (truncated, true)
3015}
3016
3017pub(crate) fn interaction_path(
3018 mode: InteractionMode,
3019 mouse: &MouseEngine,
3020 start: Point,
3021 end: Point,
3022) -> Vec<Point> {
3023 match mode {
3024 InteractionMode::Human => mouse.generate_path(start, end),
3025 InteractionMode::Fast => vec![start, end],
3026 }
3027}
3028
3029pub(crate) fn validate_key(key: &str) -> BrowserResult<()> {
3030 if key.is_empty() || key.len() > 64 || key.chars().any(char::is_control) {
3031 return Err("key must be 1..=64 printable UTF-8 bytes".into());
3032 }
3033 Ok(())
3034}
3035
3036pub(crate) fn key_code(key: &str) -> String {
3037 match key {
3038 " " => "Space".to_string(),
3039 "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" | "Enter" | "Tab" | "Escape"
3040 | "Backspace" | "Delete" | "Home" | "End" | "PageUp" | "PageDown" => key.to_string(),
3041 _ if key.chars().count() == 1 => {
3042 let character = key.chars().next().unwrap();
3043 if character.is_ascii_alphabetic() {
3044 format!("Key{}", character.to_ascii_uppercase())
3045 } else if character.is_ascii_digit() {
3046 format!("Digit{character}")
3047 } else {
3048 key.to_string()
3049 }
3050 }
3051 _ => key.to_string(),
3052 }
3053}
3054
3055pub(crate) fn parse_shortcut(value: &str) -> BrowserResult<(i64, String)> {
3056 if value.is_empty() || value.len() > 256 {
3057 return Err("shortcut must be 1..=256 bytes".into());
3058 }
3059 let mut modifiers = 0;
3060 let mut key = None;
3061 for part in value.split('+') {
3062 match part.to_ascii_lowercase().as_str() {
3063 "alt" => modifiers |= 1,
3064 "control" | "ctrl" => modifiers |= 2,
3065 "meta" | "cmd" | "command" => modifiers |= 4,
3066 "shift" => modifiers |= 8,
3067 _ if key.is_none() => key = Some(part.to_string()),
3068 _ => return Err("shortcut must contain exactly one non-modifier key".into()),
3069 }
3070 }
3071 let key = key.ok_or("shortcut requires a non-modifier key")?;
3072 validate_key(&key)?;
3073 Ok((modifiers, key))
3074}
3075
3076pub(crate) fn context_event_invalidates_observation(method: &str) -> bool {
3077 matches!(
3078 method,
3079 "Page.frameNavigated"
3080 | "Page.loadEventFired"
3081 | "Page.frameStartedLoading"
3082 | "Page.frameStoppedLoading"
3083 | "DOM.documentUpdated"
3084 | "DOM.childNodeInserted"
3085 | "DOM.childNodeRemoved"
3086 | "DOM.attributeModified"
3087 | "DOM.attributeRemoved"
3088 | "DOM.characterDataModified"
3089 | "DOM.setChildNodes"
3090 )
3091}
3092
3093pub(crate) fn observation_context_invalidates(method: &str) -> bool {
3094 matches!(
3095 method,
3096 "Page.frameNavigated"
3097 | "DOM.documentUpdated"
3098 | "Runtime.executionContextDestroyed"
3099 | "Runtime.executionContextsCleared"
3100 )
3101}
3102
3103#[derive(Debug, Clone, Serialize)]
3104pub struct ResolvedElement {
3105 pub node_id: Option<i64>,
3106 pub backend_dom_node_id: Option<i64>,
3107 pub label: String,
3108 pub reference: Option<String>,
3109 pub role: Option<String>,
3111 pub input_type: Option<String>,
3113}
3114
3115pub(crate) struct PressedButtonGuard {
3116 pub(crate) cdp: CdpClient,
3117 pub(crate) point: Point,
3118 pub(crate) click_count: u32,
3119 pub(crate) armed: bool,
3120}
3121
3122pub(crate) struct RemoteObjectGuard {
3123 pub(crate) cdp: CdpClient,
3124 pub(crate) session_id: Option<String>,
3125 pub(crate) object_id: String,
3126}
3127
3128impl RemoteObjectGuard {
3129 pub(crate) fn new(cdp: CdpClient, object_id: String) -> Self {
3130 let session_id = cdp.current_session_id();
3131 Self {
3132 cdp,
3133 session_id,
3134 object_id,
3135 }
3136 }
3137}
3138
3139pub(crate) struct PopupWitnessGuard {
3140 pub(crate) cdp: CdpClient,
3141 pub(crate) session_id: String,
3142 pub(crate) state_object_id: String,
3143 pub(crate) element_object_id: String,
3144 pub(crate) armed: bool,
3145}
3146
3147pub(crate) struct PopupAttachmentGuard {
3148 pub(crate) cdp: CdpClient,
3149 pub(crate) session_id: String,
3150 pub(crate) armed: bool,
3151}
3152
3153pub(crate) struct NetworkDomainGuard {
3154 pub(crate) cdp: CdpClient,
3155 pub(crate) leases: Arc<Mutex<NetworkLeaseState>>,
3156 pub(crate) session_id: Option<String>,
3157 pub(crate) armed: bool,
3158}
3159
3160pub(crate) struct DiagnosticDomainGuard {
3161 pub(crate) cdp: CdpClient,
3162 pub(crate) network: NetworkDomainGuard,
3163 pub(crate) leases: Arc<Mutex<DiagnosticLeaseState>>,
3164 pub(crate) session_id: Option<String>,
3165 pub(crate) armed: bool,
3166}
3167
3168pub(crate) struct DownloadBehaviorGuard {
3169 pub(crate) cdp: CdpClient,
3170 pub(crate) page_session_id: Option<String>,
3171 pub(crate) armed: bool,
3172}
3173
3174#[derive(Default)]
3175pub(crate) struct DiagnosticLeaseState {
3176 pub(crate) counts: HashMap<Option<String>, usize>,
3177}
3178
3179#[derive(Default)]
3180pub(crate) struct NetworkLeaseState {
3181 pub(crate) counts: HashMap<Option<String>, usize>,
3182}
3183
3184impl NetworkDomainGuard {
3185 pub(crate) async fn acquire(
3186 cdp: CdpClient,
3187 leases: Arc<Mutex<NetworkLeaseState>>,
3188 ) -> BrowserResult<Self> {
3189 let session_id = cdp.current_session_id();
3190 let mut state = leases.lock().await;
3191 let count = state.counts.entry(session_id.clone()).or_default();
3192 *count += 1;
3193 let mut guard = Self {
3194 cdp,
3195 leases: Arc::clone(&leases),
3196 session_id: session_id.clone(),
3197 armed: true,
3198 };
3199 if *count == 1
3200 && let Err(error) = guard
3201 .cdp
3202 .set_domain_enabled_for(session_id.clone(), "Network", true)
3203 .await
3204 {
3205 state.counts.remove(&session_id);
3206 guard.armed = false;
3207 drop(state);
3208 let _ = guard
3209 .cdp
3210 .set_domain_enabled_for(session_id, "Network", false)
3211 .await;
3212 return Err(error.into());
3213 }
3214 drop(state);
3215 Ok(guard)
3216 }
3217
3218 pub(crate) async fn disable(&mut self) -> BrowserResult<()> {
3219 let state = self.leases.lock().await;
3220 release_network_lease_locked(&self.cdp, &self.session_id, state).await?;
3221 self.armed = false;
3222 Ok(())
3223 }
3224}
3225
3226impl DiagnosticDomainGuard {
3227 pub(crate) async fn acquire(
3228 cdp: CdpClient,
3229 network_leases: Arc<Mutex<NetworkLeaseState>>,
3230 leases: Arc<Mutex<DiagnosticLeaseState>>,
3231 ) -> BrowserResult<Self> {
3232 let network = NetworkDomainGuard::acquire(cdp.clone(), network_leases).await?;
3233 let session_id = cdp.current_session_id();
3234 let mut state = leases.lock().await;
3235 let count = state.counts.entry(session_id.clone()).or_default();
3236 *count += 1;
3237 if *count == 1 {
3238 if let Err(error) = cdp
3239 .set_domain_enabled_for(session_id.clone(), "Runtime", true)
3240 .await
3241 {
3242 state.counts.remove(&session_id);
3243 return Err(error.into());
3244 }
3245 if let Err(error) = cdp
3246 .set_domain_enabled_for(session_id.clone(), "Log", true)
3247 .await
3248 {
3249 state.counts.remove(&session_id);
3250 let _ = cdp
3251 .set_domain_enabled_for(session_id.clone(), "Runtime", false)
3252 .await;
3253 return Err(error.into());
3254 }
3255 }
3256 drop(state);
3257 Ok(Self {
3258 cdp,
3259 network,
3260 leases,
3261 session_id,
3262 armed: true,
3263 })
3264 }
3265
3266 pub(crate) async fn disable(&mut self) -> BrowserResult<()> {
3267 let mut state = self.leases.lock().await;
3268 let count = state.counts.entry(self.session_id.clone()).or_default();
3269 *count = count.saturating_sub(1);
3270 if *count == 0 {
3271 state.counts.remove(&self.session_id);
3272 self.cdp
3273 .set_domain_enabled_for(self.session_id.clone(), "Log", false)
3274 .await?;
3275 self.cdp
3276 .set_domain_enabled_for(self.session_id.clone(), "Runtime", false)
3277 .await?;
3278 }
3279 drop(state);
3280 self.network.disable().await?;
3281 self.armed = false;
3282 Ok(())
3283 }
3284}
3285
3286impl Drop for DiagnosticDomainGuard {
3287 fn drop(&mut self) {
3288 if !self.armed {
3289 return;
3290 }
3291 let cdp = self.cdp.clone();
3292 let leases = Arc::clone(&self.leases);
3293 let session_id = self.session_id.clone();
3294 tokio::spawn(async move {
3295 let mut state = leases.lock().await;
3296 let count = state.counts.entry(session_id.clone()).or_default();
3297 *count = count.saturating_sub(1);
3298 if *count == 0 {
3299 state.counts.remove(&session_id);
3300 let _ = cdp
3301 .set_domain_enabled_for(session_id.clone(), "Log", false)
3302 .await;
3303 let _ = cdp
3304 .set_domain_enabled_for(session_id, "Runtime", false)
3305 .await;
3306 }
3307 });
3308 }
3309}
3310
3311impl DownloadBehaviorGuard {
3312 pub(crate) async fn acquire_for_incognito(
3313 cdp: CdpClient,
3314 destination: PathBuf,
3315 target_id: String,
3316 page_session_id: String,
3317 launched_context_id: String,
3318 ) -> BrowserResult<Self> {
3319 let selected_context_id = target_browser_context_id(&cdp, &target_id, true).await?;
3320 if selected_context_id.as_deref() != Some(launched_context_id.as_str()) {
3321 return Err(download_error(
3322 DownloadErrorKind::AuthorizationFailed,
3323 "selected target does not belong to the launched incognito context",
3324 )
3325 .into());
3326 }
3327 Self::acquire(cdp, destination, Some(page_session_id)).await
3328 }
3329
3330 pub(crate) async fn acquire(
3331 cdp: CdpClient,
3332 destination: PathBuf,
3333 page_session_id: Option<String>,
3334 ) -> BrowserResult<Self> {
3335 let (sender, receiver) = tokio::sync::oneshot::channel();
3336 tokio::spawn(async move {
3337 let result = Self::acquire_owned(cdp, &destination, page_session_id).await;
3338 let _ = sender.send(result);
3339 });
3340 receiver
3341 .await
3342 .map_err(|_| {
3343 download_error(
3344 DownloadErrorKind::AuthorizationFailed,
3345 "download authorization worker ended without a result",
3346 )
3347 })?
3348 .map_err(Into::into)
3349 }
3350
3351 pub(crate) async fn acquire_owned(
3352 cdp: CdpClient,
3353 destination: &Path,
3354 page_session_id: Option<String>,
3355 ) -> Result<Self, DownloadError> {
3356 cdp.set_download_behavior("allow", Some(destination), true)
3357 .await
3358 .map_err(|error| {
3359 download_error(
3360 DownloadErrorKind::AuthorizationFailed,
3361 format!("browser download authorization failed: {error}"),
3362 )
3363 })?;
3364 if let Some(session_id) = page_session_id.as_deref()
3365 && let Err(error) = cdp
3366 .send_to_session(
3367 session_id,
3368 "Page.setDownloadBehavior",
3369 Some(serde_json::json!({
3370 "behavior": "allow",
3371 "downloadPath": destination.to_string_lossy()
3372 })),
3373 )
3374 .await
3375 {
3376 let restoration = cdp.set_download_behavior("deny", None, false).await;
3377 return Err(download_error(
3378 DownloadErrorKind::AuthorizationFailed,
3379 format!(
3380 "incognito page download authorization failed: {error}; browser deny restoration: {}",
3381 restoration
3382 .map(|_| "completed".to_string())
3383 .unwrap_or_else(|restore| restore.to_string())
3384 ),
3385 ));
3386 }
3387 Ok(Self {
3388 cdp,
3389 page_session_id,
3390 armed: true,
3391 })
3392 }
3393
3394 pub(crate) async fn disable(&mut self) -> BrowserResult<()> {
3395 let page_result = match self.page_session_id.as_deref() {
3396 Some(session_id) => self
3397 .cdp
3398 .send_to_session(
3399 session_id,
3400 "Page.setDownloadBehavior",
3401 Some(serde_json::json!({"behavior": "deny"})),
3402 )
3403 .await
3404 .map(|_| ()),
3405 None => Ok(()),
3406 };
3407 let browser_result = self
3408 .cdp
3409 .set_download_behavior("deny", None, false)
3410 .await
3411 .map(|_| ());
3412 match (page_result, browser_result) {
3413 (Ok(()), Ok(())) => {
3414 self.armed = false;
3415 Ok(())
3416 }
3417 (page, browser) => Err(download_error(
3418 DownloadErrorKind::RestorationFailed,
3419 format!(
3420 "download deny restoration failed (page: {}; browser: {})",
3421 protocol_result_summary(page),
3422 protocol_result_summary(browser)
3423 ),
3424 )
3425 .into()),
3426 }
3427 }
3428}
3429
3430impl Drop for DownloadBehaviorGuard {
3431 fn drop(&mut self) {
3432 if !self.armed {
3433 return;
3434 }
3435 let cdp = self.cdp.clone();
3436 let page_session_id = self.page_session_id.clone();
3437 tokio::spawn(async move {
3438 if let Some(session_id) = page_session_id.as_deref() {
3439 let _ = cdp
3440 .send_to_session(
3441 session_id,
3442 "Page.setDownloadBehavior",
3443 Some(serde_json::json!({"behavior": "deny"})),
3444 )
3445 .await;
3446 }
3447 let _ = cdp.set_download_behavior("deny", None, false).await;
3448 });
3449 }
3450}
3451
3452impl Drop for NetworkDomainGuard {
3453 fn drop(&mut self) {
3454 if !self.armed {
3455 return;
3456 }
3457 let cdp = self.cdp.clone();
3458 let leases = Arc::clone(&self.leases);
3459 let session_id = self.session_id.clone();
3460 tokio::spawn(async move {
3461 let _ = release_network_lease(&cdp, &leases, &session_id).await;
3462 });
3463 }
3464}
3465
3466pub(crate) async fn release_network_lease(
3467 cdp: &CdpClient,
3468 leases: &Mutex<NetworkLeaseState>,
3469 session_id: &Option<String>,
3470) -> BrowserResult<()> {
3471 release_network_lease_locked(cdp, session_id, leases.lock().await).await
3472}
3473
3474pub(crate) async fn release_network_lease_locked(
3475 cdp: &CdpClient,
3476 session_id: &Option<String>,
3477 mut state: tokio::sync::MutexGuard<'_, NetworkLeaseState>,
3478) -> BrowserResult<()> {
3479 let count = state.counts.entry(session_id.clone()).or_default();
3480 *count = count.saturating_sub(1);
3481 if *count == 0 {
3482 state.counts.remove(session_id);
3483 cdp.set_domain_enabled_for(session_id.clone(), "Network", false)
3484 .await?;
3485 }
3486 Ok(())
3487}
3488
3489impl Drop for RemoteObjectGuard {
3490 fn drop(&mut self) {
3491 let cdp = self.cdp.clone();
3492 let session_id = self.session_id.clone();
3493 let object_id = self.object_id.clone();
3494 tokio::spawn(async move {
3495 if let Some(session_id) = session_id {
3496 let _ = cdp
3497 .release_object_for_session(&session_id, &object_id)
3498 .await;
3499 } else {
3500 let _ = cdp.release_object(&object_id).await;
3501 }
3502 });
3503 }
3504}
3505
3506impl PopupWitnessGuard {
3507 pub(crate) async fn fired(&self) -> BrowserResult<bool> {
3508 let value = popup_verification_call(
3509 self.cdp.send_to_session(
3510 &self.session_id,
3511 "Runtime.callFunctionOn",
3512 Some(serde_json::json!({
3513 "objectId": self.state_object_id,
3514 "functionDeclaration": "function(){return this.read();}",
3515 "returnByValue": true,
3516 "awaitPromise": false
3517 })),
3518 ),
3519 "popup witness read",
3520 )
3521 .await?;
3522 value["result"]["value"].as_bool().ok_or_else(|| {
3523 popup_error(
3524 PopupClickErrorKind::WitnessMissing,
3525 "popup witness returned no trusted-click state",
3526 )
3527 })
3528 }
3529
3530 pub(crate) async fn cleanup(&mut self) -> BrowserResult<()> {
3531 cleanup_popup_witness(
3532 &self.cdp,
3533 &self.session_id,
3534 &self.state_object_id,
3535 &self.element_object_id,
3536 )
3537 .await?;
3538 self.armed = false;
3539 Ok(())
3540 }
3541}
3542
3543impl Drop for PopupWitnessGuard {
3544 fn drop(&mut self) {
3545 if !self.armed {
3546 return;
3547 }
3548 let cdp = self.cdp.clone();
3549 let session_id = self.session_id.clone();
3550 let state_object_id = self.state_object_id.clone();
3551 let element_object_id = self.element_object_id.clone();
3552 tokio::spawn(async move {
3553 let _ = cleanup_popup_witness(&cdp, &session_id, &state_object_id, &element_object_id)
3554 .await;
3555 });
3556 }
3557}
3558
3559impl PopupAttachmentGuard {
3560 pub(crate) async fn detach(&mut self) -> BrowserResult<()> {
3561 popup_verification_call(
3562 self.cdp.send_browser(
3563 "Target.detachFromTarget",
3564 Some(serde_json::json!({"sessionId": self.session_id})),
3565 ),
3566 "popup detach",
3567 )
3568 .await?;
3569 self.armed = false;
3570 Ok(())
3571 }
3572}
3573
3574impl Drop for PopupAttachmentGuard {
3575 fn drop(&mut self) {
3576 if !self.armed {
3577 return;
3578 }
3579 let cdp = self.cdp.clone();
3580 let session_id = self.session_id.clone();
3581 tokio::spawn(async move {
3582 let _ = tokio::time::timeout(
3583 POPUP_VERIFY_CALL_TIMEOUT,
3584 cdp.send_browser(
3585 "Target.detachFromTarget",
3586 Some(serde_json::json!({"sessionId": session_id})),
3587 ),
3588 )
3589 .await;
3590 });
3591 }
3592}
3593
3594impl Drop for PressedButtonGuard {
3595 fn drop(&mut self) {
3596 if !self.armed {
3597 return;
3598 }
3599 let cdp = self.cdp.clone();
3600 let point = self.point;
3601 let click_count = self.click_count;
3602 tokio::spawn(async move {
3603 let _ = cdp
3604 .dispatch_mouse_event(
3605 "mouseReleased",
3606 point.x,
3607 point.y,
3608 Some("left"),
3609 Some(click_count),
3610 )
3611 .await;
3612 });
3613 }
3614}
3615
3616impl WaitCondition {
3617 pub fn parse(value: &str) -> BrowserResult<Self> {
3618 if value.len() > MAX_WAIT_CONDITION_BYTES {
3619 return Err("wait condition exceeds 4096 bytes".into());
3620 }
3621 let (kind, argument) = value
3622 .split_once('=')
3623 .ok_or("wait condition must use <kind>=<value>")?;
3624 if argument.is_empty() {
3625 return Err("wait condition value cannot be empty".into());
3626 }
3627 Ok(match kind {
3628 "lifecycle" if matches!(argument, "load" | "domcontentloaded" | "complete") => {
3629 Self::Lifecycle(
3630 if argument == "load" {
3631 "complete"
3632 } else {
3633 argument
3634 }
3635 .to_string(),
3636 )
3637 }
3638 "url" => Self::UrlExact(argument.to_string()),
3639 "url-prefix" => Self::UrlPrefix(argument.to_string()),
3640 "target-attached" => Self::TargetAttached(argument.to_string()),
3641 "target-visible" => Self::TargetVisible(argument.to_string()),
3642 "target-hidden" => Self::TargetHidden(argument.to_string()),
3643 "target-enabled" => Self::TargetEnabled(argument.to_string()),
3644 "target-stable" => Self::TargetStable(argument.to_string()),
3645 "text" => Self::Text(argument.to_string()),
3646 "js" => Self::JavaScript(argument.to_string()),
3647 "network-quiet" => {
3648 let duration = Duration::from_millis(argument.parse::<u64>()?);
3649 if duration.is_zero() || duration > MAX_WAIT_DEADLINE {
3650 return Err("network quiet duration must be between 1 ms and 300000 ms".into());
3651 }
3652 Self::NetworkQuiet(duration)
3653 }
3654 "lifecycle" => return Err("unsupported lifecycle wait value".into()),
3655 _ => return Err("unknown wait condition kind".into()),
3656 })
3657 }
3658
3659 pub(crate) fn description(&self) -> String {
3660 match self {
3661 Self::Lifecycle(_) => "lifecycle".to_string(),
3662 Self::UrlExact(_) => "url_exact".to_string(),
3663 Self::UrlPrefix(_) => "url_prefix".to_string(),
3664 Self::TargetAttached(_) => "target_attached".to_string(),
3665 Self::TargetVisible(_) => "target_visible".to_string(),
3666 Self::TargetHidden(_) => "target_hidden".to_string(),
3667 Self::TargetEnabled(_) => "target_enabled".to_string(),
3668 Self::TargetStable(_) => "target_stable".to_string(),
3669 Self::Text(_) => "text".to_string(),
3670 Self::JavaScript(_) => "javascript_predicate".to_string(),
3671 Self::NetworkQuiet(_) => "network_quiet".to_string(),
3672 }
3673 }
3674
3675 pub(crate) fn validate(&self) -> BrowserResult<()> {
3676 let value = match self {
3677 Self::Lifecycle(value)
3678 | Self::UrlExact(value)
3679 | Self::UrlPrefix(value)
3680 | Self::TargetAttached(value)
3681 | Self::TargetVisible(value)
3682 | Self::TargetHidden(value)
3683 | Self::TargetEnabled(value)
3684 | Self::TargetStable(value)
3685 | Self::Text(value)
3686 | Self::JavaScript(value) => Some(value),
3687 Self::NetworkQuiet(duration) => {
3688 if duration.is_zero() || *duration > MAX_WAIT_DEADLINE {
3689 return Err("network quiet duration must be between 1 ms and 300000 ms".into());
3690 }
3691 None
3692 }
3693 };
3694 if value.is_some_and(|value| value.is_empty() || value.len() > MAX_WAIT_CONDITION_BYTES) {
3695 return Err("wait condition value must contain 1-4096 bytes".into());
3696 }
3697 Ok(())
3698 }
3699}
3700
3701pub(crate) fn bounded_wait_state(value: &str) -> String {
3702 truncate_visible_text(value, WAIT_LAST_STATE_MAX_BYTES)
3703}
3704
3705pub(crate) fn validate_wait_deadline(deadline: Duration) -> BrowserResult<()> {
3706 if deadline.is_zero() || deadline > MAX_WAIT_DEADLINE {
3707 return Err("wait deadline must be between 1 ms and 300000 ms".into());
3708 }
3709 Ok(())
3710}
3711
3712pub(crate) fn wait_timeout(condition: &str, deadline: Duration, last_state: &str) -> WaitTimeout {
3713 WaitTimeout {
3714 condition: condition.to_string(),
3715 deadline_ms: deadline.as_millis() as u64,
3716 last_state: bounded_wait_state(last_state),
3717 reason: "deadline_exceeded",
3718 }
3719}
3720
3721impl Locator {
3722 pub fn parse(value: &str) -> BrowserResult<Self> {
3723 let value = value.trim().trim_matches('"');
3724 if value.is_empty() {
3725 return Err("element target cannot be empty".into());
3726 }
3727 if parse_revisioned_reference(value)?.is_some() {
3728 return Ok(Self::Reference(value.to_string()));
3729 }
3730 if let Some(reference) = value.strip_prefix("ref=") {
3731 if reference.is_empty() {
3732 return Err("reference locator cannot be empty".into());
3733 }
3734 return Ok(Self::Reference(reference.to_string()));
3735 }
3736 if let Some(name) = value.strip_prefix("name=") {
3737 return nonempty_locator(name, "accessible name").map(Self::AccessibleName);
3738 }
3739 if let Some(text) = value.strip_prefix("text=") {
3740 return nonempty_locator(text, "text").map(Self::Text);
3741 }
3742 if let Some(selector) = value.strip_prefix("css=") {
3743 return nonempty_locator(selector, "CSS selector").map(Self::Css);
3744 }
3745 if let Some(index) = value.strip_prefix("ordinal=") {
3746 let index = index
3747 .parse::<usize>()
3748 .ok()
3749 .filter(|index| *index > 0)
3750 .ok_or("ordinal locator must be a positive one-based integer")?;
3751 return Ok(Self::Ordinal(index));
3752 }
3753 if let Some(rest) = value.strip_prefix("role=") {
3754 let (role, name) = rest
3755 .split_once(";name=")
3756 .ok_or("role locator must use role=<role>;name=<accessible name>")?;
3757 return Ok(Self::RoleAndName {
3758 role: nonempty_locator(role, "role")?,
3759 name: nonempty_locator(name, "accessible name")?,
3760 });
3761 }
3762 Ok(Self::AccessibleName(value.to_string()))
3763 }
3764}
3765
3766pub(crate) fn nonempty_locator(value: &str, kind: &str) -> BrowserResult<String> {
3767 if value.is_empty() {
3768 return Err(format!("{kind} locator cannot be empty").into());
3769 }
3770 Ok(value.to_string())
3771}
3772
3773pub(crate) fn dom_nodes_resolution(
3774 count: usize,
3775 nodes: Vec<i64>,
3776 label: String,
3777 candidate_kind: &str,
3778) -> BrowserResult<TargetResolution> {
3779 match count {
3780 0 => Ok(TargetResolution::NotFound),
3781 1 if nodes.len() == 1 => Ok(TargetResolution::Unique(ResolvedElement {
3782 node_id: Some(nodes[0]),
3783 backend_dom_node_id: None,
3784 label,
3785 reference: None,
3786 role: None,
3787 input_type: None,
3788 })),
3789 1 => Err("unique element query returned no DOM node".into()),
3790 _ => Ok(TargetResolution::Ambiguous(
3791 nodes
3792 .into_iter()
3793 .take(AMBIGUOUS_CANDIDATE_LIMIT)
3794 .enumerate()
3795 .map(|(index, _)| CandidateSummary {
3796 label: format!("{candidate_kind} {}", index + 1),
3797 reference: None,
3798 })
3799 .collect(),
3800 )),
3801 }
3802}
3803
3804pub(crate) fn css_query_expression(selector: &str) -> BrowserResult<String> {
3805 let selector = serde_json::to_string(selector)?;
3806 Ok(format!(
3807 "(() => {{ const nodes = document.querySelectorAll({selector}); const out = []; for (let i = 0; i < Math.min(nodes.length, {AMBIGUOUS_CANDIDATE_LIMIT}); i++) out.push(nodes[i]); out.glassCount = nodes.length; return out; }})()"
3808 ))
3809}
3810
3811pub(crate) fn text_query_expression(text: &str) -> BrowserResult<String> {
3812 let text = serde_json::to_string(text)?;
3813 Ok(format!(
3814 r#"(() => {{
3815 const wanted = ({text}).replace(/\s+/g, ' ').trim();
3816 const matches = [];
3817 let count = 0;
3818 const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_ELEMENT);
3819 for (let element = walker.currentNode; element; element = walker.nextNode()) {{
3820 const style = getComputedStyle(element);
3821 const rect = element.getBoundingClientRect();
3822 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 || rect.width <= 0 || rect.height <= 0) continue;
3823 if (element.checkVisibility && !element.checkVisibility({{checkOpacity:true, checkVisibilityCSS:true}})) continue;
3824 let clipped = false;
3825 let visibleLeft = rect.left, visibleTop = rect.top, visibleRight = rect.right, visibleBottom = rect.bottom;
3826 for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {{
3827 const ancestorStyle = getComputedStyle(ancestor);
3828 if (ancestorStyle.display === 'none' || ancestorStyle.visibility === 'hidden' || Number(ancestorStyle.opacity) === 0) {{ clipped = true; break; }}
3829 if (/(hidden|clip)/.test(ancestorStyle.overflow + ancestorStyle.overflowX + ancestorStyle.overflowY)) {{
3830 const bounds = ancestor.getBoundingClientRect();
3831 visibleLeft = Math.max(visibleLeft, bounds.left); visibleTop = Math.max(visibleTop, bounds.top);
3832 visibleRight = Math.min(visibleRight, bounds.right); visibleBottom = Math.min(visibleBottom, bounds.bottom);
3833 if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {{ clipped = true; break; }}
3834 }}
3835 }}
3836 if (clipped) continue;
3837 const actual = (element.innerText || '').replace(/\s+/g, ' ').trim();
3838 if (actual !== wanted) continue;
3839 const candidate = element.closest('button,a,input,select,textarea,[role],[tabindex]') || element;
3840 if (matches.includes(candidate)) continue;
3841 for (let index = matches.length - 1; index >= 0; index--) {{
3842 if (matches[index].contains(candidate)) {{ matches.splice(index, 1); count--; }}
3843 }}
3844 count++;
3845 if (matches.length < {AMBIGUOUS_CANDIDATE_LIMIT}) matches.push(candidate);
3846 }}
3847 matches.glassCount = count;
3848 return matches;
3849 }})()"#
3850 ))
3851}
3852
3853pub(crate) fn visible_text_contains_expression(text: &str) -> BrowserResult<String> {
3854 let text = serde_json::to_string(text)?;
3855 Ok(format!(
3856 r#"(() => {{
3857 const wanted = {text};
3858 const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
3859 for (let node = walker.nextNode(); node; node = walker.nextNode()) {{
3860 if (!(node.nodeValue || '').includes(wanted)) continue;
3861 const element = node.parentElement;
3862 if (!element) continue;
3863 if (element.checkVisibility && !element.checkVisibility({{checkOpacity:true, checkVisibilityCSS:true}})) continue;
3864 const rect = element.getBoundingClientRect();
3865 if (rect.width <= 0 || rect.height <= 0) continue;
3866 let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom, hidden = false;
3867 for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {{
3868 const style = getComputedStyle(ancestor);
3869 if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {{ hidden = true; break; }}
3870 if (/(hidden|clip)/.test(style.overflow + style.overflowX + style.overflowY)) {{
3871 const bounds = ancestor.getBoundingClientRect();
3872 left = Math.max(left, bounds.left); top = Math.max(top, bounds.top);
3873 right = Math.min(right, bounds.right); bottom = Math.min(bottom, bounds.bottom);
3874 if (right <= left || bottom <= top) {{ hidden = true; break; }}
3875 }}
3876 }}
3877 if (!hidden) return true;
3878 }}
3879 return false;
3880 }})()"#
3881 ))
3882}
3883
3884pub(crate) fn bounded_candidate_label(value: &str) -> String {
3885 if value.len() <= CANDIDATE_LABEL_MAX_BYTES {
3886 return value.to_string();
3887 }
3888 let mut end = CANDIDATE_LABEL_MAX_BYTES - "…".len();
3889 while !value.is_char_boundary(end) {
3890 end -= 1;
3891 }
3892 format!("{}…", &value[..end])
3893}
3894
3895pub(crate) fn validate_topology_id(value: &str) -> BrowserResult<()> {
3896 if value.is_empty() {
3897 return Err("topology ID cannot be empty".into());
3898 }
3899 if value.len() > TOPOLOGY_ID_MAX_BYTES {
3900 return Err(format!("topology ID exceeds {TOPOLOGY_ID_MAX_BYTES} UTF-8 bytes").into());
3901 }
3902 Ok(())
3903}
3904
3905pub(crate) fn bounded_topology_text(value: &str) -> String {
3906 if value.len() <= TOPOLOGY_TEXT_MAX_BYTES {
3907 return value.to_string();
3908 }
3909 let mut end = TOPOLOGY_TEXT_MAX_BYTES - "…".len();
3910 while !value.is_char_boundary(end) {
3911 end -= 1;
3912 }
3913 format!("{}…", &value[..end])
3914}
3915
3916pub(crate) fn collect_frames(
3917 frame_tree: &Value,
3918 parent_id: Option<&str>,
3919 active_frame_id: Option<&str>,
3920 frames: &mut Vec<FrameInfo>,
3921) -> BrowserResult<()> {
3922 if frames.len() == TOPOLOGY_MAX_FRAMES {
3923 return Err("frame limit exceeded".into());
3924 }
3925 let frame = frame_tree
3926 .get("frame")
3927 .ok_or("Page.getFrameTree returned a node without frame data")?;
3928 let id = frame["id"]
3929 .as_str()
3930 .ok_or("Page.getFrameTree returned a frame without an ID")?;
3931 validate_topology_id(id)?;
3932 frames.push(FrameInfo {
3933 id: id.to_string(),
3934 parent_id: parent_id.map(str::to_string),
3935 url: bounded_topology_text(frame["url"].as_str().unwrap_or_default()),
3936 active: active_frame_id == Some(id),
3937 out_of_process: false,
3938 });
3939 if let Some(children) = frame_tree["childFrames"].as_array() {
3940 for child in children {
3941 collect_frames(child, Some(id), active_frame_id, frames)?;
3942 }
3943 }
3944 Ok(())
3945}
3946
3947pub(crate) fn push_topology_event(topology: &mut TopologyRegistry, kind: &str, id: &str) -> u64 {
3948 topology.sequence = topology
3949 .sequence
3950 .checked_add(1)
3951 .expect("topology sequence exhausted");
3952 let sequence = topology.sequence;
3953 topology.events.push_back(TopologyEventSummary {
3954 sequence,
3955 kind: kind.to_string(),
3956 id: bounded_topology_id(id),
3957 });
3958 while topology.events.len() > TOPOLOGY_MAX_EVENTS {
3959 topology.events.pop_front();
3960 }
3961 sequence
3962}
3963
3964pub(crate) fn bounded_topology_id(value: &str) -> String {
3965 if value.len() <= TOPOLOGY_ID_MAX_BYTES {
3966 return value.to_string();
3967 }
3968 let mut end = TOPOLOGY_ID_MAX_BYTES - "…".len();
3969 while !value.is_char_boundary(end) {
3970 end -= 1;
3971 }
3972 format!("{}…", &value[..end])
3973}
3974
3975pub(crate) fn retained_optional_topology_id(value: Option<&str>) -> BrowserResult<Option<String>> {
3976 value
3977 .map(|id| {
3978 validate_topology_id(id)?;
3979 Ok(id.to_string())
3980 })
3981 .transpose()
3982}
3983
3984pub(crate) fn popup_typed_error(
3985 kind: PopupClickErrorKind,
3986 message: impl Into<String>,
3987) -> PopupClickError {
3988 let message = message.into();
3989 PopupClickError {
3990 kind,
3991 message: truncate_utf8_bytes(&message, POPUP_ERROR_MESSAGE_MAX_BYTES),
3992 }
3993}
3994
3995pub(crate) fn popup_witness_install_function() -> String {
3996 format!(
3997 r#"function() {{
3998 const element = this;
3999 const nativeAdd = EventTarget.prototype.addEventListener;
4000 const nativeRemove = EventTarget.prototype.removeEventListener;
4001 const nativeApply = Reflect.apply;
4002 const nativeSetTimeout = setTimeout;
4003 const nativeClearTimeout = clearTimeout;
4004 const state = {{fired:false, cleaned:false}};
4005 const listener = function(event) {{
4006 if (event.isTrusted === true && event.currentTarget === element) state.fired = true;
4007 }};
4008 let timer;
4009 const cleanup = function() {{
4010 if (state.cleaned) return true;
4011 state.cleaned = true;
4012 nativeApply(nativeRemove, element, ['click', listener, true]);
4013 nativeClearTimeout(timer);
4014 return true;
4015 }};
4016 Object.defineProperties(state, {{
4017 read: {{value:function(){{return state.fired;}}, enumerable:false}},
4018 cleanup: {{value:cleanup, enumerable:false}}
4019 }});
4020 nativeApply(nativeAdd, element, ['click', listener, {{capture:true, once:true}}]);
4021 timer = nativeSetTimeout(cleanup, {POPUP_WITNESS_LIFETIME_MS});
4022 return state;
4023 }}"#
4024 )
4025}
4026
4027pub(crate) async fn popup_witness_call<F>(future: F, step: &str) -> Result<Value, PopupClickError>
4028where
4029 F: std::future::Future<Output = Result<Value, crate::browser::cdp::CdpError>>,
4030{
4031 match tokio::time::timeout(POPUP_VERIFY_CALL_TIMEOUT, future).await {
4032 Ok(Ok(value)) => Ok(value),
4033 Ok(Err(error)) => Err(popup_typed_error(
4034 PopupClickErrorKind::WitnessMissing,
4035 format!("{step} failed: {error}"),
4036 )),
4037 Err(_) => Err(popup_typed_error(
4038 PopupClickErrorKind::WitnessMissing,
4039 format!("{step} exceeded its bounded deadline"),
4040 )),
4041 }
4042}
4043
4044pub(crate) async fn arm_popup_witness_owned(
4045 cdp: CdpClient,
4046 session_id: String,
4047 frame_id: String,
4048 backend_node_id: i64,
4049) -> Result<PopupWitnessGuard, PopupClickError> {
4050 let world = popup_witness_call(
4051 cdp.send_to_session(
4052 &session_id,
4053 "Page.createIsolatedWorld",
4054 Some(serde_json::json!({
4055 "frameId": frame_id,
4056 "worldName": "glass-popup-witness"
4057 })),
4058 ),
4059 "popup witness isolated world",
4060 )
4061 .await?;
4062 let context_id = world["executionContextId"].as_i64().ok_or_else(|| {
4063 popup_typed_error(
4064 PopupClickErrorKind::WitnessMissing,
4065 "popup witness isolated world returned no context ID",
4066 )
4067 })?;
4068 let resolved = popup_witness_call(
4069 cdp.send_to_session(
4070 &session_id,
4071 "DOM.resolveNode",
4072 Some(serde_json::json!({
4073 "backendNodeId": backend_node_id,
4074 "executionContextId": context_id
4075 })),
4076 ),
4077 "popup witness exact-node resolution",
4078 )
4079 .await?;
4080 let element_object_id = resolved["object"]["objectId"]
4081 .as_str()
4082 .ok_or_else(|| {
4083 popup_typed_error(
4084 PopupClickErrorKind::WitnessMissing,
4085 "popup witness exact-node resolution returned no object",
4086 )
4087 })?
4088 .to_string();
4089 let install = popup_witness_call(
4090 cdp.send_to_session(
4091 &session_id,
4092 "Runtime.callFunctionOn",
4093 Some(serde_json::json!({
4094 "objectId": element_object_id,
4095 "functionDeclaration": popup_witness_install_function(),
4096 "returnByValue": false,
4097 "awaitPromise": false
4098 })),
4099 ),
4100 "popup witness installation",
4101 )
4102 .await;
4103 let state_object_id = match install {
4104 Ok(value) => match value["result"]["objectId"].as_str() {
4105 Some(id) => id.to_string(),
4106 None => {
4107 let _ = tokio::time::timeout(
4108 POPUP_VERIFY_CALL_TIMEOUT,
4109 cdp.release_object_for_session(&session_id, &element_object_id),
4110 )
4111 .await;
4112 return Err(popup_typed_error(
4113 PopupClickErrorKind::WitnessMissing,
4114 "popup witness installation returned no private state",
4115 ));
4116 }
4117 },
4118 Err(error) => {
4119 let _ = tokio::time::timeout(
4120 POPUP_VERIFY_CALL_TIMEOUT,
4121 cdp.release_object_for_session(&session_id, &element_object_id),
4122 )
4123 .await;
4124 return Err(error);
4125 }
4126 };
4127 Ok(PopupWitnessGuard {
4128 cdp,
4129 session_id,
4130 state_object_id,
4131 element_object_id,
4132 armed: true,
4133 })
4134}
4135
4136pub(crate) async fn cleanup_popup_witness(
4137 cdp: &CdpClient,
4138 session_id: &str,
4139 state_object_id: &str,
4140 element_object_id: &str,
4141) -> BrowserResult<()> {
4142 let cleanup = tokio::time::timeout(
4143 POPUP_VERIFY_CALL_TIMEOUT,
4144 cdp.send_to_session(
4145 session_id,
4146 "Runtime.callFunctionOn",
4147 Some(serde_json::json!({
4148 "objectId": state_object_id,
4149 "functionDeclaration": "function(){return this.cleanup();}",
4150 "returnByValue": true,
4151 "awaitPromise": false
4152 })),
4153 ),
4154 )
4155 .await;
4156 let release_state = tokio::time::timeout(
4157 POPUP_VERIFY_CALL_TIMEOUT,
4158 cdp.release_object_for_session(session_id, state_object_id),
4159 )
4160 .await;
4161 let release_element = tokio::time::timeout(
4162 POPUP_VERIFY_CALL_TIMEOUT,
4163 cdp.release_object_for_session(session_id, element_object_id),
4164 )
4165 .await;
4166 if !matches!(cleanup, Ok(Ok(_)))
4167 || !matches!(release_state, Ok(Ok(_)))
4168 || !matches!(release_element, Ok(Ok(_)))
4169 {
4170 return Err(popup_error(
4171 PopupClickErrorKind::PopupUnreadable,
4172 "popup witness remote-object cleanup failed",
4173 ));
4174 }
4175 Ok(())
4176}
4177
4178pub(crate) fn popup_error(kind: PopupClickErrorKind, message: impl Into<String>) -> Box<dyn Error> {
4179 Box::new(popup_typed_error(kind, message))
4180}
4181
4182pub(crate) fn download_error(kind: DownloadErrorKind, message: impl Into<String>) -> DownloadError {
4183 let message = message.into();
4184 DownloadError {
4185 kind,
4186 message: truncate_utf8_bytes(&message, POPUP_ERROR_MESSAGE_MAX_BYTES),
4187 }
4188}
4189
4190pub(crate) async fn target_browser_context_id(
4191 cdp: &CdpClient,
4192 target_id: &str,
4193 required: bool,
4194) -> Result<Option<String>, DownloadError> {
4195 let response = cdp
4196 .send_browser(
4197 "Target.getTargetInfo",
4198 Some(serde_json::json!({"targetId": target_id})),
4199 )
4200 .await
4201 .map_err(|error| {
4202 download_error(
4203 DownloadErrorKind::AuthorizationFailed,
4204 format!("download target context lookup failed: {error}"),
4205 )
4206 })?;
4207 let target_info = response["targetInfo"].as_object().ok_or_else(|| {
4208 download_error(
4209 DownloadErrorKind::AuthorizationFailed,
4210 "download target context lookup returned no targetInfo",
4211 )
4212 })?;
4213 if target_info.get("targetId").and_then(Value::as_str) != Some(target_id) {
4214 return Err(download_error(
4215 DownloadErrorKind::AuthorizationFailed,
4216 "download target context lookup returned a mismatched target ID",
4217 ));
4218 }
4219 let context_id = target_info
4220 .get("browserContextId")
4221 .and_then(Value::as_str)
4222 .map(str::to_string);
4223 if required && context_id.is_none() {
4224 return Err(download_error(
4225 DownloadErrorKind::AuthorizationFailed,
4226 "download target context lookup returned no browser context ID",
4227 ));
4228 }
4229 if let Some(context_id) = context_id.as_deref() {
4230 validate_topology_id(context_id).map_err(|error| {
4231 download_error(
4232 DownloadErrorKind::AuthorizationFailed,
4233 format!("download target returned an invalid browser context ID: {error}"),
4234 )
4235 })?;
4236 }
4237 Ok(context_id)
4238}
4239
4240pub(crate) fn use_page_download_compatibility(owned: bool, command_line_incognito: bool) -> bool {
4241 owned && command_line_incognito
4242}
4243
4244pub(crate) fn protocol_result_summary(result: Result<(), crate::browser::cdp::CdpError>) -> String {
4245 result
4246 .map(|_| "completed".to_string())
4247 .unwrap_or_else(|error| error.to_string())
4248}
4249
4250pub(crate) fn assess_popup_topology(
4251 snapshot: &PopupTopologySnapshot,
4252 topology: &TopologyRegistry,
4253 witnessed: bool,
4254) -> Result<PopupCandidate, PopupClickError> {
4255 if !witnessed {
4256 return Err(popup_typed_error(
4257 PopupClickErrorKind::WitnessMissing,
4258 "trusted-click witness was not observed",
4259 ));
4260 }
4261 if topology.event_loss_count != snapshot.event_loss_count {
4262 return Err(popup_typed_error(
4263 PopupClickErrorKind::TopologyLagged,
4264 "topology event stream lagged after mouseReleased",
4265 ));
4266 }
4267
4268 let later_targets = topology.targets.iter().filter_map(|target| {
4269 let sequence = *topology.target_sequences.get(&target.id)?;
4270 (!snapshot.preexisting_target_ids.contains(&target.id) && sequence > snapshot.sequence)
4271 .then_some((target, sequence))
4272 });
4273 let mut matching = Vec::new();
4274 let mut nonmatching_count = 0usize;
4275 for (target, sequence) in later_targets {
4276 if target.opener_id.as_deref() == Some(snapshot.original_target_id.as_str()) {
4277 matching.push(PopupCandidate {
4278 target: target.clone(),
4279 observed_sequence: sequence,
4280 });
4281 } else {
4282 nonmatching_count += 1;
4283 }
4284 }
4285 if matching.len() > 1 {
4286 return Err(popup_typed_error(
4287 PopupClickErrorKind::PopupAmbiguous,
4288 format!(
4289 "trusted click produced {} opener-matching page targets",
4290 matching.len()
4291 ),
4292 ));
4293 }
4294 if let Some(candidate) = matching.pop() {
4295 return Ok(candidate);
4296 }
4297 if topology.destroyed_targets.iter().any(|destroyed| {
4298 destroyed.observed_sequence > snapshot.sequence
4299 && !snapshot
4300 .preexisting_target_ids
4301 .contains(&destroyed.target.id)
4302 && destroyed.target.opener_id.as_deref() == Some(snapshot.original_target_id.as_str())
4303 }) {
4304 return Err(popup_typed_error(
4305 PopupClickErrorKind::PopupDestroyed,
4306 "the opener-matching popup was destroyed before verification",
4307 ));
4308 }
4309 if nonmatching_count > 0 {
4310 return Err(popup_typed_error(
4311 PopupClickErrorKind::PopupOpenerMismatch,
4312 "later page targets did not name the original target as opener",
4313 ));
4314 }
4315 Err(popup_typed_error(
4316 PopupClickErrorKind::PopupMissing,
4317 "no later opener-matching popup was observed",
4318 ))
4319}
4320
4321pub(crate) async fn popup_verification_call<F>(future: F, step: &str) -> BrowserResult<Value>
4322where
4323 F: std::future::Future<Output = Result<Value, crate::browser::cdp::CdpError>>,
4324{
4325 match tokio::time::timeout(POPUP_VERIFY_CALL_TIMEOUT, future).await {
4326 Ok(Ok(value)) => Ok(value),
4327 Ok(Err(error)) => Err(popup_error(
4328 PopupClickErrorKind::PopupUnreadable,
4329 format!("{step} failed: {error}"),
4330 )),
4331 Err(_) => Err(popup_error(
4332 PopupClickErrorKind::PopupUnreadable,
4333 format!("{step} exceeded its bounded deadline"),
4334 )),
4335 }
4336}
4337
4338pub(crate) fn apply_topology_event(
4341 topology: &mut TopologyRegistry,
4342 event: &CdpEventWithParams,
4343) -> bool {
4344 match event.method.as_str() {
4345 "Target.targetCreated" | "Target.targetInfoChanged" => {
4346 let info = &event.params["targetInfo"];
4347 if info["type"].as_str() != Some("page") {
4348 return false;
4349 }
4350 let Some(id) = info["targetId"].as_str() else {
4351 return false;
4352 };
4353 if validate_topology_id(id).is_err() {
4354 push_topology_event(topology, "rejected-target", id);
4355 return false;
4356 }
4357 let Ok(opener_id) = retained_optional_topology_id(info["openerId"].as_str()) else {
4358 push_topology_event(topology, "rejected-target-opener", id);
4359 return false;
4360 };
4361 let target = PageTargetInfo {
4362 id: id.to_string(),
4363 url: bounded_topology_text(info["url"].as_str().unwrap_or_default()),
4364 title: bounded_topology_text(info["title"].as_str().unwrap_or_default()),
4365 opener_id,
4366 active: topology.active_target_id.as_deref() == Some(id),
4367 };
4368 if let Some(existing) = topology.targets.iter_mut().find(|target| target.id == id) {
4369 *existing = target;
4370 } else if topology.targets.len() < TOPOLOGY_MAX_TARGETS {
4371 topology.targets.push(target);
4372 } else {
4373 push_topology_event(topology, "rejected-target-budget", id);
4374 return false;
4375 }
4376 let sequence = push_topology_event(topology, "target-updated", id);
4377 topology.target_sequences.insert(id.to_string(), sequence);
4378 }
4379 "Target.targetDestroyed" | "Target.targetCrashed" => {
4380 let Some(id) = event.params["targetId"].as_str() else {
4381 return false;
4382 };
4383 let removed = topology
4384 .targets
4385 .iter()
4386 .find(|target| target.id == id)
4387 .cloned();
4388 topology.targets.retain(|target| target.id != id);
4389 topology.target_sequences.remove(id);
4390 let sequence = push_topology_event(topology, event.method.as_str(), id);
4391 if let Some(target) = removed {
4392 topology.destroyed_targets.push_back(DestroyedPageTarget {
4393 target,
4394 observed_sequence: sequence,
4395 });
4396 while topology.destroyed_targets.len() > TOPOLOGY_MAX_EVENTS {
4397 topology.destroyed_targets.pop_front();
4398 }
4399 }
4400 if topology.active_target_id.as_deref() == Some(id) {
4401 topology.active_target_id = None;
4402 topology.active_target_session_id = None;
4403 topology.active_session_id = None;
4404 topology.active_frame_id = None;
4405 topology.frames.clear();
4406 topology.frame_sessions.clear();
4407 topology.frame_parents.clear();
4408 return true;
4409 }
4410 }
4411 "Target.detachedFromTarget" => {
4412 let Some(session_id) = event.params["sessionId"].as_str() else {
4413 return false;
4414 };
4415 push_topology_event(topology, "Target.detachedFromTarget", session_id);
4416 if topology.active_target_session_id.as_deref() == Some(session_id) {
4417 topology.active_target_id = None;
4418 topology.active_target_session_id = None;
4419 topology.active_session_id = None;
4420 topology.active_frame_id = None;
4421 topology.frames.clear();
4422 return true;
4423 }
4424 let active_session_detached = topology.active_session_id.as_deref() == Some(session_id);
4425 topology
4426 .frame_sessions
4427 .retain(|_, attached_session| attached_session != session_id);
4428 if active_session_detached {
4429 topology.active_frame_id = None;
4430 topology.active_session_id = topology.active_target_session_id.clone();
4431 }
4432 }
4433 "Target.attachedToTarget" => {
4434 let info = &event.params["targetInfo"];
4435 if info["type"].as_str() != Some("iframe") {
4436 return false;
4437 }
4438 let (Some(frame_id), Some(session_id)) = (
4439 info["targetId"].as_str(),
4440 event.params["sessionId"].as_str(),
4441 ) else {
4442 return false;
4443 };
4444 if validate_topology_id(frame_id).is_err() || validate_topology_id(session_id).is_err()
4445 {
4446 return false;
4447 }
4448 if topology.frame_sessions.len() < TOPOLOGY_MAX_FRAMES {
4449 topology
4450 .frame_sessions
4451 .insert(frame_id.to_string(), session_id.to_string());
4452 push_topology_event(topology, "Target.attachedToTarget", frame_id);
4453 }
4454 }
4455 "Page.frameAttached" | "Page.frameNavigated" | "Page.frameDetached" => {
4456 let event_session = event.session_id.as_deref();
4457 let belongs_to_topology = event_session == topology.active_target_session_id.as_deref()
4458 || event_session.is_some_and(|session_id| {
4459 topology
4460 .frame_sessions
4461 .values()
4462 .any(|attached| attached == session_id)
4463 });
4464 if !belongs_to_topology {
4465 return false;
4466 }
4467 let id = event.params["frameId"]
4468 .as_str()
4469 .or_else(|| event.params["frame"]["id"].as_str());
4470 if let Some(id) = id
4471 && validate_topology_id(id).is_ok()
4472 {
4473 push_topology_event(topology, event.method.as_str(), id);
4474 if event.method == "Page.frameAttached"
4475 && let Some(parent_id) = event.params["parentFrameId"].as_str()
4476 && validate_topology_id(parent_id).is_ok()
4477 {
4478 topology
4479 .frame_parents
4480 .insert(id.to_string(), parent_id.to_string());
4481 }
4482 if event.method == "Page.frameDetached" {
4483 topology.frame_parents.remove(id);
4484 }
4485 }
4486 let selected_was_affected = id.is_some_and(|changed_id| {
4487 topology.active_frame_id.as_deref() == Some(changed_id)
4488 || frame_is_descendant_of(
4489 &topology.frames,
4490 topology.active_frame_id.as_deref(),
4491 changed_id,
4492 )
4493 });
4494 let selected_is_main = topology
4495 .active_frame_id
4496 .as_deref()
4497 .and_then(|selected| topology.frames.iter().find(|frame| frame.id == selected))
4498 .is_some_and(|frame| frame.parent_id.is_none());
4499 topology.frames.clear();
4500 if selected_was_affected
4501 && matches!(
4502 event.method.as_str(),
4503 "Page.frameNavigated" | "Page.frameDetached"
4504 )
4505 && !(event.method == "Page.frameNavigated" && selected_is_main)
4506 {
4507 topology.active_frame_id = None;
4508 }
4509 }
4510 "Page.javascriptDialogOpening" => {
4511 let dialog_type = event.params["type"].as_str().unwrap_or("alert").to_string();
4512 let message =
4513 bounded_topology_text(event.params["message"].as_str().unwrap_or_default());
4514 let default_value = event.params["defaultPrompt"].as_str().map(String::from);
4515 let url = bounded_topology_text(event.params["url"].as_str().unwrap_or_default());
4516 topology.pending_dialog = Some(PendingDialog {
4517 dialog_type,
4518 message,
4519 default_value,
4520 url,
4521 });
4522 push_topology_event(topology, "Page.javascriptDialogOpening", "dialog");
4523 }
4524 "Page.javascriptDialogClosed" => {
4525 topology.pending_dialog = None;
4526 push_topology_event(topology, "Page.javascriptDialogClosed", "dialog");
4527 }
4528 _ => {}
4529 }
4530 false
4531}
4532
4533pub(crate) fn frame_is_descendant_of(
4534 frames: &[FrameInfo],
4535 selected: Option<&str>,
4536 ancestor: &str,
4537) -> bool {
4538 let Some(mut current) = selected else {
4539 return false;
4540 };
4541 while let Some(frame) = frames.iter().find(|frame| frame.id == current) {
4542 let Some(parent) = frame.parent_id.as_deref() else {
4543 return false;
4544 };
4545 if parent == ancestor {
4546 return true;
4547 }
4548 current = parent;
4549 }
4550 false
4551}
4552
4553pub(crate) async fn resync_topology(
4554 cdp: &CdpClient,
4555 registry: &Arc<Mutex<TopologyRegistry>>,
4556) -> BrowserResult<()> {
4557 let raw = cdp.send_browser("Target.getTargets", None).await?;
4558 let (active_target, active_frame, target_session) = {
4559 let topology = registry.lock().await;
4560 (
4561 topology.active_target_id.clone(),
4562 topology.active_frame_id.clone(),
4563 topology.active_target_session_id.clone(),
4564 )
4565 };
4566 let mut targets = Vec::new();
4567 for info in raw["targetInfos"].as_array().into_iter().flatten() {
4568 if info["type"].as_str() != Some("page") {
4569 continue;
4570 }
4571 let id = info["targetId"]
4572 .as_str()
4573 .ok_or("Target.getTargets returned a page without an ID")?;
4574 validate_topology_id(id)?;
4575 if targets.len() == TOPOLOGY_MAX_TARGETS {
4576 return Err(TopologyError::new(
4577 TopologyErrorKind::BudgetExceeded,
4578 "page target limit exceeded during topology resync; consider closing unused targets",
4579 )
4580 .into());
4581 }
4582 targets.push(PageTargetInfo {
4583 id: id.to_string(),
4584 url: bounded_topology_text(info["url"].as_str().unwrap_or_default()),
4585 title: bounded_topology_text(info["title"].as_str().unwrap_or_default()),
4586 opener_id: retained_optional_topology_id(info["openerId"].as_str())?,
4587 active: active_target.as_deref() == Some(id),
4588 });
4589 }
4590 let mut frames = Vec::new();
4591 if let Some(target_session) = target_session {
4592 let raw = cdp
4593 .send_to_session(&target_session, "Page.getFrameTree", None)
4594 .await?;
4595 collect_frames(
4596 &raw["frameTree"],
4597 None,
4598 active_frame.as_deref(),
4599 &mut frames,
4600 )?;
4601 }
4602 let mut topology = registry.lock().await;
4603 topology.targets = targets;
4604 topology.frames = frames;
4605 let sequence = push_topology_event(&mut topology, "resynchronized", "topology");
4606 topology.target_sequences = topology
4607 .targets
4608 .iter()
4609 .map(|target| (target.id.clone(), sequence))
4610 .collect();
4611 Ok(())
4612}
4613
4614pub(crate) fn actionability_reason(reason: &str) -> TargetActionabilityReason {
4615 match reason {
4616 "detached" => TargetActionabilityReason::Detached,
4617 "not_visible" => TargetActionabilityReason::NotVisible,
4618 "disabled" => TargetActionabilityReason::Disabled,
4619 "unstable_geometry" => TargetActionabilityReason::UnstableGeometry,
4620 "outside_viewport" => TargetActionabilityReason::OutsideViewport,
4621 "hit_test_blocked" => TargetActionabilityReason::HitTestBlocked,
4622 _ => TargetActionabilityReason::VerificationFailed,
4623 }
4624}
4625
4626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4627pub(crate) struct RevisionedElementReference {
4628 pub(crate) revision: u64,
4629 pub(crate) backend_dom_node_id: i64,
4630}
4631
4632pub(crate) fn parse_revisioned_reference(
4638 value: &str,
4639) -> BrowserResult<Option<RevisionedElementReference>> {
4640 let Some(rest) = value.strip_prefix('r') else {
4641 return Ok(None);
4642 };
4643 let Some((revision, backend_dom_node_id)) = rest.split_once(":b") else {
4644 return Ok(None);
4645 };
4646 if revision.is_empty() || backend_dom_node_id.is_empty() {
4647 return Err(format!("invalid revisioned element reference: {value}").into());
4648 }
4649 let revision = revision
4650 .parse::<u64>()
4651 .map_err(|_| format!("invalid element reference revision: {value}"))?;
4652 let backend_dom_node_id = backend_dom_node_id
4653 .parse::<i64>()
4654 .ok()
4655 .filter(|id| *id > 0)
4656 .ok_or_else(|| format!("invalid backend node ID in element reference: {value}"))?;
4657 Ok(Some(RevisionedElementReference {
4658 revision,
4659 backend_dom_node_id,
4660 }))
4661}
4662
4663pub(crate) fn runtime_value(raw: &RuntimeEvaluateResponse) -> BrowserResult<Value> {
4664 if let Some(exception) = &raw.exception_details {
4665 return Err(format!("JavaScript evaluation failed: {exception}").into());
4666 }
4667 Ok(raw.result.value.clone().unwrap_or(Value::Null))
4668}
4669
4670pub(crate) async fn wait_for_ws_url(port: u16, target_id: Option<&str>) -> BrowserResult<String> {
4671 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
4672 loop {
4673 match crate::browser::chrome::get_owned_ws_url(port, target_id).await {
4674 Ok(url) => return Ok(url),
4675 Err(error)
4676 if error.to_string().starts_with("No page target")
4677 && tokio::time::Instant::now() < deadline =>
4678 {
4679 tracing::debug!(%error, "waiting for Chrome page target");
4680 tokio::time::sleep(Duration::from_millis(100)).await;
4681 }
4682 Err(error) => return Err(error),
4683 }
4684 }
4685}
4686
4687pub fn normalize_url(url: &str) -> String {
4690 let url = url.trim();
4691 if url.starts_with("http://")
4692 || url.starts_with("https://")
4693 || url.starts_with("about:")
4694 || url.starts_with("file:")
4695 || url.starts_with("data:")
4696 {
4697 url.to_string()
4698 } else {
4699 format!("https://{url}")
4700 }
4701}
4702
4703pub(crate) async fn disable_network_for(
4704 cdp: &CdpClient,
4705 session_id: Option<&str>,
4706) -> BrowserResult<()> {
4707 match session_id {
4708 Some(session_id) => {
4709 cdp.send_to_session(session_id, "Network.disable", None)
4710 .await?
4711 }
4712 None => cdp.send("Network.disable", None).await?,
4713 };
4714 Ok(())
4715}