Skip to main content

glass/browser/session/
types.rs

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