Skip to main content

glass/browser/session/
observe.rs

1//! Compact page observation.
2//!
3//! Produces a bounded [`CompactAccessibilitySnapshot`] with visible text,
4//! interactive element summaries, and optional full DOM / screenshot /
5//! form-value overlays.
6
7use super::*;
8
9impl BrowserSession {
10    /// Return the visible text content of the current page.
11    ///
12    /// Evaluates `document.body.innerText` and truncates the result to
13    /// [`COMPACT_TEXT_MAX_BYTES`] (16 KiB).
14    pub async fn text(&self) -> BrowserResult<String> {
15        self.cdp
16            .with_current_route(async {
17                let value = self
18                    .evaluate_value("document.body ? document.body.innerText : ''")
19                    .await?;
20                Ok(truncate_visible_text(
21                    value.as_str().unwrap_or_default(),
22                    COMPACT_TEXT_MAX_BYTES,
23                ))
24            })
25            .await
26    }
27
28    /// Fetch the full DOM only for an explicit deep-inspection operation.
29    pub async fn deep_dom(&self) -> BrowserResult<DomNode> {
30        self.cdp
31            .with_current_route(async {
32                let raw = serde_json::to_value(self.cdp.get_deep_document().await?)?;
33                parse_dom_tree(&raw).ok_or_else(|| {
34                    "CDP deep DOM response contained no parseable root node"
35                        .to_string()
36                        .into()
37                })
38            })
39            .await
40    }
41
42    /// Collect compact page context without a deep DOM or screenshot.
43    pub async fn observe(&self) -> BrowserResult<PageContext> {
44        self.observe_internal(false, false, true, false, CompactRanking::Relevance)
45            .await
46    }
47
48    /// Collect compact context and explicitly include the full DOM tree.
49    pub async fn observe_with_dom(&self) -> BrowserResult<PageContext> {
50        self.observe_internal(true, false, true, false, CompactRanking::Relevance)
51            .await
52    }
53
54    /// Collect structured context and explicitly include a current screenshot.
55    pub async fn observe_with_screenshot(&self) -> BrowserResult<PageContext> {
56        self.observe_internal(false, true, true, false, CompactRanking::Relevance)
57            .await
58    }
59
60    /// Collect context with both explicitly requested deep DOM and screenshot data.
61    pub async fn observe_with_dom_and_screenshot(&self) -> BrowserResult<PageContext> {
62        self.observe_internal(true, true, true, false, CompactRanking::Relevance)
63            .await
64    }
65
66    /// Collect fresh compact context, bypassing the compact-context cache.
67    pub async fn observe_fresh(&self) -> BrowserResult<PageContext> {
68        self.observe_internal(false, false, false, false, CompactRanking::Relevance)
69            .await
70    }
71
72    /// Collect compact context with form field values included.
73    /// Requires ReadFormValues policy capability in hardened mode.
74    pub async fn observe_with_form_values(&self) -> BrowserResult<PageContext> {
75        self.observe_internal(false, false, false, true, CompactRanking::Relevance)
76            .await
77    }
78
79    /// Collect fresh context and explicitly include the full DOM tree.
80    pub async fn observe_fresh_with_dom(&self) -> BrowserResult<PageContext> {
81        self.observe_internal(true, false, false, false, CompactRanking::Relevance)
82            .await
83    }
84
85    /// Collect fresh structured context and explicitly include a screenshot.
86    pub async fn observe_fresh_with_screenshot(&self) -> BrowserResult<PageContext> {
87        self.observe_internal(false, true, false, false, CompactRanking::Relevance)
88            .await
89    }
90
91    /// Collect fresh context with both explicitly requested deep DOM and screenshot data.
92    pub async fn observe_fresh_with_dom_and_screenshot(&self) -> BrowserResult<PageContext> {
93        self.observe_internal(true, true, false, false, CompactRanking::Relevance)
94            .await
95    }
96
97    /// Collect compact context with an explicit truncation ordering.
98    pub async fn observe_with_ranking(
99        &self,
100        ranking: ObservationRanking,
101    ) -> BrowserResult<PageContext> {
102        let ranking = match ranking {
103            ObservationRanking::Relevance => CompactRanking::Relevance,
104            ObservationRanking::DocumentOrder => CompactRanking::DocumentOrder,
105        };
106        self.observe_internal(false, false, false, false, ranking)
107            .await
108    }
109
110    async fn observe_internal(
111        &self,
112        include_dom: bool,
113        include_screenshot: bool,
114        use_cache: bool,
115        include_form_values: bool,
116        ranking: CompactRanking,
117    ) -> BrowserResult<PageContext> {
118        if let Some(interception) = &self.policy_interception
119            && let Some(error) = interception.take_denial().await
120        {
121            return Err(error.into());
122        }
123        self.cdp
124            .with_current_route(async {
125                let mut context = self
126                    .compact_observation(use_cache, include_form_values, ranking)
127                    .await?
128                    .into_page_context();
129                if include_dom {
130                    context.dom = Some(self.deep_dom().await?);
131                }
132                if include_screenshot {
133                    context.screenshot = Some(self.screenshot_base64().await?);
134                }
135                Ok(context)
136            })
137            .await
138    }
139
140    async fn compact_observation(
141        &self,
142        use_cache: bool,
143        include_form_values: bool,
144        ranking: CompactRanking,
145    ) -> BrowserResult<CompactPageContext> {
146        let revision = self.page_revision.load(Ordering::Relaxed);
147        // Never use cache when form values are requested (cache doesn't store them)
148        if use_cache && !include_form_values {
149            let cached_context = {
150                let cache = self.observation_cache.lock().await;
151                cache
152                    .as_ref()
153                    .filter(|cached| cached.revision == revision)
154                    .map(|cached| cached.context.clone())
155            };
156            if let Some(context) = cached_context {
157                return Ok(context);
158            }
159        }
160
161        let (target_id, frame_id) = self.route_identity().await?;
162        let mut context_id = self.observation_context_id(&target_id, &frame_id).await?;
163        let mut collected = None;
164        let mut attempt = 1;
165        let mut recovered_context = false;
166        while attempt <= COMPACT_OBSERVATION_MAX_ATTEMPTS {
167            let start_revision = self.page_revision.load(Ordering::Relaxed);
168            let attempt_result =
169                match tokio::time::timeout(COMPACT_OBSERVATION_ATTEMPT_TIMEOUT, async {
170                    let start = self.compact_page_state(context_id).await?;
171                    // Keep the mutation bracket intact while overlapping the
172                    // independent accessibility and page-state reads. The
173                    // start/end snapshots still gate consistency; only their
174                    // CDP round trips are allowed to share a flight.
175                    let (accessibility, end) = tokio::join!(
176                        self.cached_accessibility_tree(&target_id, &frame_id, start_revision),
177                        self.compact_page_state(context_id),
178                    );
179                    let accessibility = accessibility?;
180                    let end = end?;
181                    BrowserResult::Ok((start, accessibility, end))
182                })
183                .await
184                {
185                    Err(_) => {
186                        return Err(
187                            "compact observation attempt exceeded its one-second deadline".into(),
188                        );
189                    }
190                    Ok(Ok(result)) => result,
191                    Ok(Err(error))
192                        if is_stale_observation_context(error.as_ref()) && !recovered_context =>
193                    {
194                        self.discard_observation_context(&target_id, &frame_id, context_id)
195                            .await;
196                        context_id = self.observation_context_id(&target_id, &frame_id).await?;
197                        recovered_context = true;
198                        continue;
199                    }
200                    Ok(Err(error)) => return Err(error),
201                };
202            let end_revision = self.page_revision.load(Ordering::Relaxed);
203            let consistent = start_revision == end_revision
204                && attempt_result.0.mutation_revision == attempt_result.2.mutation_revision;
205            collected = Some((
206                attempt,
207                consistent,
208                start_revision,
209                end_revision,
210                attempt_result,
211            ));
212            if consistent {
213                break;
214            }
215            attempt += 1;
216        }
217        let (
218            attempts,
219            consistent,
220            start_revision,
221            end_revision,
222            (start_state, accessibility_raw, page_state),
223        ) = collected.expect("observation always performs at least one attempt");
224        let page = PageInfo {
225            url: page_state.url,
226            title: page_state.title,
227            ready_state: page_state.ready_state,
228            target_id: target_id.clone(),
229            frame_id: frame_id.clone(),
230        };
231        let full_roots = parse_accessibility_tree(&accessibility_raw);
232        let mut compact_accessibility =
233            crate::browser::dom::project_compact_accessibility_with_ranking(
234                &full_roots,
235                end_revision,
236                ranking,
237            );
238        let (mut text, locally_truncated) =
239            truncate_visible_text_with_status(&page_state.text, COMPACT_TEXT_MAX_BYTES);
240        let text_truncated = locally_truncated || page_state.boundaries.text_truncated;
241        if page_state.boundaries.text_truncated && !text.ends_with(TEXT_TRUNCATION_MARKER) {
242            let content_limit = COMPACT_TEXT_MAX_BYTES.saturating_sub(TEXT_TRUNCATION_MARKER.len());
243            while text.len() > content_limit {
244                text.pop();
245            }
246            text.push_str(TEXT_TRUNCATION_MARKER);
247        }
248        let mut incomplete = Vec::new();
249        if text_truncated {
250            incomplete.push(ObservationIncompleteReason::VisibleText);
251        }
252        if compact_accessibility.nodes_truncated {
253            incomplete.push(ObservationIncompleteReason::AccessibilityNode);
254        }
255        if compact_accessibility.labels_truncated {
256            incomplete.push(ObservationIncompleteReason::AccessibilityLabel);
257        }
258        if compact_accessibility.controls_truncated {
259            incomplete.push(ObservationIncompleteReason::Control);
260        }
261        if page_state.boundaries.child_frames > 0 {
262            incomplete.push(ObservationIncompleteReason::FrameBoundary);
263        }
264        if page_state.boundaries.canvases > 0 {
265            incomplete.push(ObservationIncompleteReason::Canvas);
266        }
267        if page_state.boundaries.truncated {
268            incomplete.push(ObservationIncompleteReason::BoundaryScan);
269        }
270        if !consistent {
271            incomplete.push(ObservationIncompleteReason::MutationRace);
272        }
273        // Shadow piercing: discover which interactive controls are inside open shadow roots.
274        let (shadow_paths, pierced_hosts) = if page_state.boundaries.shadow_roots > 0 {
275            match self
276                .cdp
277                .get_flattened_document(crate::browser::dom::MAX_SHADOW_DEPTH as i64)
278                .await
279            {
280                Ok(flattened) => {
281                    let flattened = serde_json::to_value(flattened)?;
282                    let paths = crate::browser::dom::build_shadow_host_paths(&flattened);
283                    let hosts = crate::browser::dom::count_pierced_shadow_hosts(&paths);
284                    (paths, hosts)
285                }
286                Err(_) => (HashMap::new(), 0),
287            }
288        } else {
289            (HashMap::new(), 0)
290        };
291
292        // Only flag ShadowBoundary when hosts were not all pierced
293        if page_state.boundaries.shadow_roots > 0
294            && pierced_hosts < page_state.boundaries.shadow_roots
295        {
296            incomplete.push(ObservationIncompleteReason::ShadowBoundary);
297        }
298
299        // Apply shadow host paths to interactive controls
300        if !shadow_paths.is_empty() {
301            for control in compact_accessibility.interactive.iter_mut() {
302                if let Some(path) = shadow_paths.get(&control.backend_dom_node_id) {
303                    control.shadow_host_path = Some(path.clone());
304                }
305            }
306        }
307
308        // Read form field values when explicitly requested
309        if include_form_values {
310            self.read_form_field_values(&mut compact_accessibility.interactive)
311                .await?;
312        }
313
314        let interactive_len = compact_accessibility.interactive.len();
315        let accessibility = CompactAccessibilitySnapshot {
316            page: page.clone(),
317            revision: end_revision,
318            roots: compact_accessibility.roots,
319            interactive: compact_accessibility.interactive,
320            truncated: compact_accessibility.truncated,
321            omitted_count: compact_accessibility.omitted_count,
322            ranking_applied: compact_accessibility.ranking_applied,
323            completeness: Some(ObservationCompleteness::compute(
324                compact_accessibility.interactive_discovered,
325                interactive_len,
326                page_state.boundaries.shadow_roots,
327                pierced_hosts,
328                page_state.boundaries.canvases,
329                page_state.boundaries.child_frames,
330                !consistent,
331            )),
332        };
333        let context = CompactPageContext {
334            page,
335            text,
336            accessibility,
337            consistency: ObservationConsistency {
338                consistent,
339                attempts,
340                start_revision,
341                end_revision,
342                start_mutation_revision: start_state.mutation_revision,
343                end_mutation_revision: page_state.mutation_revision,
344            },
345            boundaries: page_state.boundaries,
346            incomplete,
347        };
348        if consistent && self.page_revision.load(Ordering::Relaxed) == end_revision {
349            *self.observation_cache.lock().await = Some(CachedObservation {
350                revision: end_revision,
351                context: context.clone(),
352            });
353            self.cache_accessibility_tree(&target_id, &frame_id, end_revision, &accessibility_raw)
354                .await;
355        }
356        Ok(context)
357    }
358
359    async fn cached_accessibility_tree(
360        &self,
361        target_id: &str,
362        frame_id: &str,
363        revision: u64,
364    ) -> BrowserResult<Value> {
365        {
366            let cache = self.accessibility_cache.lock().await;
367            if let Some(cached) = cache.as_ref()
368                && cached.revision == revision
369                && cached.target_id == target_id
370                && cached.frame_id == frame_id
371            {
372                return Ok(cached.tree.clone());
373            }
374        }
375
376        Ok(serde_json::to_value(
377            self.cdp.get_accessibility_tree().await?,
378        )?)
379    }
380
381    async fn cache_accessibility_tree(
382        &self,
383        target_id: &str,
384        frame_id: &str,
385        revision: u64,
386        tree: &Value,
387    ) {
388        if serde_json::to_vec(&tree)
389            .map(|serialized| serialized.len() <= COMPACT_ACCESSIBILITY_CACHE_MAX_BYTES)
390            .unwrap_or(false)
391        {
392            *self.accessibility_cache.lock().await = Some(CachedAccessibilityTree {
393                target_id: target_id.to_string(),
394                frame_id: frame_id.to_string(),
395                revision,
396                tree: tree.clone(),
397            });
398        }
399    }
400
401    /// Read current values of form controls and populate CompactInteractiveElement fields.
402    /// Enforces ReadFormValues policy, max 16 fields, password/CC redaction.
403    async fn read_form_field_values(
404        &self,
405        controls: &mut [CompactInteractiveElement],
406    ) -> BrowserResult<()> {
407        use crate::browser::dom::{
408            FORM_VALUE_MAX_BYTES, FORM_VALUE_MAX_FIELDS, SELECT_OPTION_MAX_BYTES, truncate_utf8,
409        };
410
411        self.policy.require(PolicyCapability::ReadFormValues)?;
412        let allow_sensitive = self.policy.allow_sensitive_form_values();
413
414        const FORM_ROLES: &[&str] = &[
415            "textbox",
416            "searchbox",
417            "combobox",
418            "spinbutton",
419            "listbox",
420            "checkbox",
421            "radio",
422            "switch",
423            "slider",
424        ];
425
426        // Prioritize controls with backend node IDs and form-relevant roles
427        let mut candidates: Vec<&mut CompactInteractiveElement> = controls
428            .iter_mut()
429            .filter(|c| {
430                FORM_ROLES.iter().any(|r| c.role.eq_ignore_ascii_case(r))
431                    && c.backend_dom_node_id > 0
432            })
433            .take(FORM_VALUE_MAX_FIELDS)
434            .collect();
435
436        if candidates.is_empty() {
437            return Ok(());
438        }
439
440        // Read values via CDP: resolve backend node IDs → object IDs → call function
441        let expression = r#"function() {
442            const el = this;
443            const result = { empty: true };
444            const tag = (el.tagName || '').toLowerCase();
445            if (tag === 'input') {
446                const type = (el.type || 'text').toLowerCase();
447                if (type === 'checkbox' || type === 'radio') {
448                    result.checked = el.checked;
449                    result.value = el.value;
450                } else {
451                    result.value = el.value;
452                }
453            } else if (tag === 'select') {
454                const opt = el.options[el.selectedIndex];
455                result.selectedOption = opt ? (opt.label || opt.text || opt.value) : '';
456                result.value = el.value;
457            } else if (tag === 'textarea') {
458                result.value = el.value;
459            } else {
460                result.value = el.value || el.textContent || '';
461            }
462            result.empty = !result.value && !result.selectedOption && !result.checked;
463            result.readOnly = !!el.readOnly;
464            result.required = !!el.required;
465            result.autocomplete = el.getAttribute('autocomplete') || '';
466            result.inputType = (el.type || '').toLowerCase();
467            return JSON.stringify(result);
468        }"#;
469
470        for control in candidates.iter_mut() {
471            let resolved = match self
472                .cdp
473                .send(
474                    "DOM.resolveNode",
475                    Some(serde_json::json!({
476                        "backendNodeId": control.backend_dom_node_id,
477                    })),
478                )
479                .await
480            {
481                Ok(resolved) => resolved,
482                Err(_) => continue,
483            };
484
485            let Some(object_id) = resolved["object"]["objectId"].as_str() else {
486                continue;
487            };
488            let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id.to_string());
489
490            let raw_result = self
491                .cdp
492                .send(
493                    "Runtime.callFunctionOn",
494                    Some(serde_json::json!({
495                        "objectId": &remote.object_id,
496                        "functionDeclaration": expression,
497                        "returnByValue": true,
498                        "awaitPromise": false,
499                    })),
500                )
501                .await;
502            let raw = match raw_result {
503                Ok(raw) => raw,
504                Err(_) => continue,
505            };
506
507            let value_str = raw["result"]["value"].as_str().unwrap_or("{}");
508            let parsed: Value = match serde_json::from_str(value_str) {
509                Ok(v) => v,
510                Err(_) => continue,
511            };
512
513            let input_type = parsed["inputType"]
514                .as_str()
515                .map(String::from)
516                .or_else(|| control.input_type.clone());
517
518            let is_password = input_type.as_deref() == Some("password");
519            let is_sensitive_autocomplete = parsed["autocomplete"]
520                .as_str()
521                .map(|ac| ac.starts_with("cc-") || ac == "current-password" || ac == "new-password")
522                .unwrap_or(false);
523
524            if let Some(val) = parsed["value"].as_str() {
525                if (is_password || is_sensitive_autocomplete) && !allow_sensitive {
526                    control.value = Some("<redacted>".to_string());
527                } else {
528                    let (truncated, _) = truncate_utf8(val, FORM_VALUE_MAX_BYTES);
529                    control.value = Some(truncated.to_string());
530                }
531            }
532
533            if let Some(checked) = parsed["checked"].as_bool() {
534                control.checked = Some(checked);
535            }
536
537            if let Some(opt) = parsed["selectedOption"].as_str() {
538                let (truncated, _) = truncate_utf8(opt, SELECT_OPTION_MAX_BYTES);
539                control.selected_option = Some(truncated.to_string());
540            }
541
542            control.empty = parsed["empty"].as_bool().unwrap_or(true);
543            control.read_only = parsed["readOnly"].as_bool().unwrap_or(false);
544            control.required = parsed["required"].as_bool().unwrap_or(false);
545
546            if let Some(it) = input_type {
547                control.input_type = Some(it);
548            }
549        }
550
551        Ok(())
552    }
553
554    async fn compact_page_state(&self, context_id: i64) -> BrowserResult<EvaluatedPageState> {
555        let raw = self
556            .cdp
557            .evaluate_in_context(COMPACT_PAGE_STATE_EXPRESSION, Some(context_id))
558            .await?;
559        Ok(serde_json::from_value(runtime_value(&raw)?)?)
560    }
561
562    async fn observation_context_id(&self, target_id: &str, frame_id: &str) -> BrowserResult<i64> {
563        let session_id = self.cdp.current_session_id();
564        {
565            let context = self.observation_context.lock().await;
566            if let Some(cached) = context.as_ref()
567                && cached.target_id == target_id
568                && cached.session_id == session_id
569                && cached.frame_id == frame_id
570            {
571                return Ok(cached.context_id);
572            }
573        }
574
575        let world = self
576            .cdp
577            .send(
578                "Page.createIsolatedWorld",
579                Some(serde_json::json!({"frameId": frame_id, "worldName": "glass-observation"})),
580            )
581            .await?;
582        let context_id = world["executionContextId"]
583            .as_i64()
584            .ok_or("Page.createIsolatedWorld returned no executionContextId")?;
585        *self.observation_context.lock().await = Some(CachedObservationContext {
586            target_id: target_id.to_string(),
587            session_id,
588            frame_id: frame_id.to_string(),
589            context_id,
590        });
591        Ok(context_id)
592    }
593
594    async fn discard_observation_context(&self, target_id: &str, frame_id: &str, context_id: i64) {
595        let mut context = self.observation_context.lock().await;
596        if context.as_ref().is_some_and(|cached| {
597            cached.target_id == target_id
598                && cached.frame_id == frame_id
599                && cached.context_id == context_id
600        }) {
601            context.take();
602        }
603    }
604}
605
606fn is_stale_observation_context(error: &(dyn std::error::Error + 'static)) -> bool {
607    error
608        .downcast_ref::<crate::browser::cdp::CdpError>()
609        .is_some_and(|error| {
610            error.message.contains("Cannot find context")
611                || error.message.contains("Execution context was destroyed")
612        })
613}