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