1use super::*;
8
9impl BrowserSession {
10 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 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 pub async fn observe(&self) -> BrowserResult<PageContext> {
44 self.observe_internal(false, false, true, false, CompactRanking::Relevance)
45 .await
46 }
47
48 pub async fn observe_with_dom(&self) -> BrowserResult<PageContext> {
50 self.observe_internal(true, false, true, false, CompactRanking::Relevance)
51 .await
52 }
53
54 pub async fn observe_with_screenshot(&self) -> BrowserResult<PageContext> {
56 self.observe_internal(false, true, true, false, CompactRanking::Relevance)
57 .await
58 }
59
60 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 pub async fn observe_fresh(&self) -> BrowserResult<PageContext> {
68 self.observe_internal(false, false, false, false, CompactRanking::Relevance)
69 .await
70 }
71
72 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 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 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 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 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 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, 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 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 if page_state.boundaries.shadow_roots > 0
294 && pierced_hosts < page_state.boundaries.shadow_roots
295 {
296 incomplete.push(ObservationIncompleteReason::ShadowBoundary);
297 }
298
299 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 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 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 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 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}