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 = 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                    let accessibility = self.cdp.get_accessibility_tree().await?;
172                    let end = self.compact_page_state(context_id).await?;
173                    BrowserResult::Ok((start, accessibility, end))
174                })
175                .await
176                {
177                    Err(_) => {
178                        return Err(
179                            "compact observation attempt exceeded its one-second deadline".into(),
180                        );
181                    }
182                    Ok(Ok(result)) => result,
183                    Ok(Err(error))
184                        if is_stale_observation_context(error.as_ref()) && !recovered_context =>
185                    {
186                        self.discard_observation_context(&target_id, &frame_id, context_id)
187                            .await;
188                        context_id = self.observation_context_id(&target_id, &frame_id).await?;
189                        recovered_context = true;
190                        continue;
191                    }
192                    Ok(Err(error)) => return Err(error),
193                };
194            let end_revision = self.page_revision.load(Ordering::Relaxed);
195            let consistent = start_revision == end_revision
196                && attempt_result.0.mutation_revision == attempt_result.2.mutation_revision;
197            collected = Some((
198                attempt,
199                consistent,
200                start_revision,
201                end_revision,
202                attempt_result,
203            ));
204            if consistent {
205                break;
206            }
207            attempt += 1;
208        }
209        let (
210            attempts,
211            consistent,
212            start_revision,
213            end_revision,
214            (start_state, accessibility_raw, page_state),
215        ) = collected.expect("observation always performs at least one attempt");
216        let page = PageInfo {
217            url: page_state.url,
218            title: page_state.title,
219            ready_state: page_state.ready_state,
220            target_id,
221            frame_id,
222        };
223        let full_roots = parse_accessibility_tree(&accessibility_raw);
224        let mut compact_accessibility =
225            crate::browser::dom::project_compact_accessibility_with_ranking(
226                &full_roots,
227                end_revision,
228                ranking,
229            );
230        let (mut text, locally_truncated) =
231            truncate_visible_text_with_status(&page_state.text, COMPACT_TEXT_MAX_BYTES);
232        let text_truncated = locally_truncated || page_state.boundaries.text_truncated;
233        if page_state.boundaries.text_truncated && !text.ends_with(TEXT_TRUNCATION_MARKER) {
234            let content_limit = COMPACT_TEXT_MAX_BYTES.saturating_sub(TEXT_TRUNCATION_MARKER.len());
235            while text.len() > content_limit {
236                text.pop();
237            }
238            text.push_str(TEXT_TRUNCATION_MARKER);
239        }
240        let mut incomplete = Vec::new();
241        if text_truncated {
242            incomplete.push(ObservationIncompleteReason::VisibleText);
243        }
244        if compact_accessibility.nodes_truncated {
245            incomplete.push(ObservationIncompleteReason::AccessibilityNode);
246        }
247        if compact_accessibility.labels_truncated {
248            incomplete.push(ObservationIncompleteReason::AccessibilityLabel);
249        }
250        if compact_accessibility.controls_truncated {
251            incomplete.push(ObservationIncompleteReason::Control);
252        }
253        if page_state.boundaries.child_frames > 0 {
254            incomplete.push(ObservationIncompleteReason::FrameBoundary);
255        }
256        if page_state.boundaries.canvases > 0 {
257            incomplete.push(ObservationIncompleteReason::Canvas);
258        }
259        if page_state.boundaries.truncated {
260            incomplete.push(ObservationIncompleteReason::BoundaryScan);
261        }
262        if !consistent {
263            incomplete.push(ObservationIncompleteReason::MutationRace);
264        }
265        // Shadow piercing: discover which interactive controls are inside open shadow roots.
266        let (shadow_paths, pierced_hosts) = if page_state.boundaries.shadow_roots > 0 {
267            match self
268                .cdp
269                .get_flattened_document(crate::browser::dom::MAX_SHADOW_DEPTH as i64)
270                .await
271            {
272                Ok(flattened) => {
273                    let paths = crate::browser::dom::build_shadow_host_paths(&flattened);
274                    let hosts = crate::browser::dom::count_pierced_shadow_hosts(&paths);
275                    (paths, hosts)
276                }
277                Err(_) => (HashMap::new(), 0),
278            }
279        } else {
280            (HashMap::new(), 0)
281        };
282
283        // Only flag ShadowBoundary when hosts were not all pierced
284        if page_state.boundaries.shadow_roots > 0
285            && pierced_hosts < page_state.boundaries.shadow_roots
286        {
287            incomplete.push(ObservationIncompleteReason::ShadowBoundary);
288        }
289
290        // Apply shadow host paths to interactive controls
291        if !shadow_paths.is_empty() {
292            for control in compact_accessibility.interactive.iter_mut() {
293                if let Some(path) = shadow_paths.get(&control.backend_dom_node_id) {
294                    control.shadow_host_path = Some(path.clone());
295                }
296            }
297        }
298
299        // Read form field values when explicitly requested
300        if include_form_values {
301            self.read_form_field_values(&mut compact_accessibility.interactive)
302                .await?;
303        }
304
305        let interactive_len = compact_accessibility.interactive.len();
306        let accessibility = CompactAccessibilitySnapshot {
307            page: page.clone(),
308            revision: end_revision,
309            roots: compact_accessibility.roots,
310            interactive: compact_accessibility.interactive,
311            truncated: compact_accessibility.truncated,
312            omitted_count: compact_accessibility.omitted_count,
313            ranking_applied: compact_accessibility.ranking_applied,
314            completeness: Some(ObservationCompleteness::compute(
315                compact_accessibility.interactive_discovered,
316                interactive_len,
317                page_state.boundaries.shadow_roots,
318                pierced_hosts,
319                page_state.boundaries.canvases,
320                page_state.boundaries.child_frames,
321                !consistent,
322            )),
323        };
324        let context = CompactPageContext {
325            page,
326            text,
327            accessibility,
328            consistency: ObservationConsistency {
329                consistent,
330                attempts,
331                start_revision,
332                end_revision,
333                start_mutation_revision: start_state.mutation_revision,
334                end_mutation_revision: page_state.mutation_revision,
335            },
336            boundaries: page_state.boundaries,
337            incomplete,
338        };
339        if consistent && self.page_revision.load(Ordering::Relaxed) == end_revision {
340            *self.observation_cache.lock().await = Some(CachedObservation {
341                revision: end_revision,
342                context: context.clone(),
343            });
344        }
345        Ok(context)
346    }
347
348    /// Read current values of form controls and populate CompactInteractiveElement fields.
349    /// Enforces ReadFormValues policy, max 16 fields, password/CC redaction.
350    async fn read_form_field_values(
351        &self,
352        controls: &mut [CompactInteractiveElement],
353    ) -> BrowserResult<()> {
354        use crate::browser::dom::{
355            FORM_VALUE_MAX_BYTES, FORM_VALUE_MAX_FIELDS, SELECT_OPTION_MAX_BYTES, truncate_utf8,
356        };
357
358        self.policy.require(PolicyCapability::ReadFormValues)?;
359        let allow_sensitive = self.policy.allow_sensitive_form_values();
360
361        const FORM_ROLES: &[&str] = &[
362            "textbox",
363            "searchbox",
364            "combobox",
365            "spinbutton",
366            "listbox",
367            "checkbox",
368            "radio",
369            "switch",
370            "slider",
371        ];
372
373        // Prioritize controls with backend node IDs and form-relevant roles
374        let mut candidates: Vec<&mut CompactInteractiveElement> = controls
375            .iter_mut()
376            .filter(|c| {
377                FORM_ROLES.iter().any(|r| c.role.eq_ignore_ascii_case(r))
378                    && c.backend_dom_node_id > 0
379            })
380            .take(FORM_VALUE_MAX_FIELDS)
381            .collect();
382
383        if candidates.is_empty() {
384            return Ok(());
385        }
386
387        // Read values via CDP: resolve backend node IDs → object IDs → call function
388        let expression = r#"function() {
389            const el = this;
390            const result = { empty: true };
391            const tag = (el.tagName || '').toLowerCase();
392            if (tag === 'input') {
393                const type = (el.type || 'text').toLowerCase();
394                if (type === 'checkbox' || type === 'radio') {
395                    result.checked = el.checked;
396                    result.value = el.value;
397                } else {
398                    result.value = el.value;
399                }
400            } else if (tag === 'select') {
401                const opt = el.options[el.selectedIndex];
402                result.selectedOption = opt ? (opt.label || opt.text || opt.value) : '';
403                result.value = el.value;
404            } else if (tag === 'textarea') {
405                result.value = el.value;
406            } else {
407                result.value = el.value || el.textContent || '';
408            }
409            result.empty = !result.value && !result.selectedOption && !result.checked;
410            result.readOnly = !!el.readOnly;
411            result.required = !!el.required;
412            result.autocomplete = el.getAttribute('autocomplete') || '';
413            result.inputType = (el.type || '').toLowerCase();
414            return JSON.stringify(result);
415        }"#;
416
417        for control in candidates.iter_mut() {
418            let resolved = match self
419                .cdp
420                .send(
421                    "DOM.resolveNode",
422                    Some(serde_json::json!({
423                        "backendNodeId": control.backend_dom_node_id,
424                    })),
425                )
426                .await
427            {
428                Ok(resolved) => resolved,
429                Err(_) => continue,
430            };
431
432            let Some(object_id) = resolved["object"]["objectId"].as_str() else {
433                continue;
434            };
435            let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id.to_string());
436
437            let raw_result = self
438                .cdp
439                .send(
440                    "Runtime.callFunctionOn",
441                    Some(serde_json::json!({
442                        "objectId": &remote.object_id,
443                        "functionDeclaration": expression,
444                        "returnByValue": true,
445                        "awaitPromise": false,
446                    })),
447                )
448                .await;
449            let raw = match raw_result {
450                Ok(raw) => raw,
451                Err(_) => continue,
452            };
453
454            let value_str = raw["result"]["value"].as_str().unwrap_or("{}");
455            let parsed: Value = match serde_json::from_str(value_str) {
456                Ok(v) => v,
457                Err(_) => continue,
458            };
459
460            let input_type = parsed["inputType"]
461                .as_str()
462                .map(String::from)
463                .or_else(|| control.input_type.clone());
464
465            let is_password = input_type.as_deref() == Some("password");
466            let is_sensitive_autocomplete = parsed["autocomplete"]
467                .as_str()
468                .map(|ac| ac.starts_with("cc-") || ac == "current-password" || ac == "new-password")
469                .unwrap_or(false);
470
471            if let Some(val) = parsed["value"].as_str() {
472                if (is_password || is_sensitive_autocomplete) && !allow_sensitive {
473                    control.value = Some("<redacted>".to_string());
474                } else {
475                    let (truncated, _) = truncate_utf8(val, FORM_VALUE_MAX_BYTES);
476                    control.value = Some(truncated.to_string());
477                }
478            }
479
480            if let Some(checked) = parsed["checked"].as_bool() {
481                control.checked = Some(checked);
482            }
483
484            if let Some(opt) = parsed["selectedOption"].as_str() {
485                let (truncated, _) = truncate_utf8(opt, SELECT_OPTION_MAX_BYTES);
486                control.selected_option = Some(truncated.to_string());
487            }
488
489            control.empty = parsed["empty"].as_bool().unwrap_or(true);
490            control.read_only = parsed["readOnly"].as_bool().unwrap_or(false);
491            control.required = parsed["required"].as_bool().unwrap_or(false);
492
493            if let Some(it) = input_type {
494                control.input_type = Some(it);
495            }
496        }
497
498        Ok(())
499    }
500
501    async fn compact_page_state(&self, context_id: i64) -> BrowserResult<EvaluatedPageState> {
502        let raw = self
503            .cdp
504            .evaluate_in_context(COMPACT_PAGE_STATE_EXPRESSION, Some(context_id))
505            .await?;
506        Ok(serde_json::from_value(runtime_value(&raw)?)?)
507    }
508
509    async fn observation_context_id(&self, target_id: &str, frame_id: &str) -> BrowserResult<i64> {
510        let session_id = self.cdp.current_session_id();
511        {
512            let context = self.observation_context.lock().await;
513            if let Some(cached) = context.as_ref()
514                && cached.target_id == target_id
515                && cached.session_id == session_id
516                && cached.frame_id == frame_id
517            {
518                return Ok(cached.context_id);
519            }
520        }
521
522        let world = self
523            .cdp
524            .send(
525                "Page.createIsolatedWorld",
526                Some(serde_json::json!({"frameId": frame_id, "worldName": "glass-observation"})),
527            )
528            .await?;
529        let context_id = world["executionContextId"]
530            .as_i64()
531            .ok_or("Page.createIsolatedWorld returned no executionContextId")?;
532        *self.observation_context.lock().await = Some(CachedObservationContext {
533            target_id: target_id.to_string(),
534            session_id,
535            frame_id: frame_id.to_string(),
536            context_id,
537        });
538        Ok(context_id)
539    }
540
541    async fn discard_observation_context(&self, target_id: &str, frame_id: &str, context_id: i64) {
542        let mut context = self.observation_context.lock().await;
543        if context.as_ref().is_some_and(|cached| {
544            cached.target_id == target_id
545                && cached.frame_id == frame_id
546                && cached.context_id == context_id
547        }) {
548            context.take();
549        }
550    }
551}
552
553fn is_stale_observation_context(error: &(dyn std::error::Error + 'static)) -> bool {
554    error
555        .downcast_ref::<crate::browser::cdp::CdpError>()
556        .is_some_and(|error| {
557            error.message.contains("Cannot find context")
558                || error.message.contains("Execution context was destroyed")
559        })
560}