1use serde::{Deserialize, Serialize};
9#[cfg(test)]
10use serde_json::Value;
11use std::collections::BTreeSet;
12
13use super::types::PageContext;
14use crate::browser::dom::CompactAxNode;
15
16pub const SEMANTIC_OBSERVATION_SCHEMA_VERSION: u32 = 1;
17const MAX_REGIONS: usize = 64;
18const MAX_EVIDENCE_ITEMS: usize = 8;
19const MAX_EVIDENCE_BYTES: usize = 128;
20const MAX_ID_BYTES: usize = 128;
21const MAX_LABEL_BYTES: usize = 256;
22const MAX_TITLE_BYTES: usize = 1_024;
23const MAX_URL_BYTES: usize = 2_048;
24const MAX_TARGETS: usize = 32;
25const MAX_CHANGE_ITEMS: usize = 128;
26const MAX_ROLE_BYTES: usize = 64;
27const MAX_VISIBLE_TEXT_BYTES: usize = 16 * 1024;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SemanticObservationLevel {
33 Summary,
34 Interactive,
35 Structured,
36 Detailed,
37 Raw,
38}
39
40impl SemanticObservationLevel {
41 fn includes_targets(self) -> bool {
42 matches!(
43 self,
44 Self::Interactive | Self::Structured | Self::Detailed | Self::Raw
45 )
46 }
47
48 fn includes_text(self) -> bool {
49 matches!(self, Self::Structured | Self::Detailed | Self::Raw)
50 }
51
52 fn includes_accessibility(self) -> bool {
53 matches!(self, Self::Detailed | Self::Raw)
54 }
55
56 fn includes_raw_accessibility(self) -> bool {
57 matches!(self, Self::Raw)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum SemanticConfidence {
65 Exact,
66 High,
67 Medium,
68 Low,
69 Unknown,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub enum SemanticPageKind {
76 Generic,
77 Home,
78 Search,
79 SearchResults,
80 Article,
81 Documentation,
82 Listing,
83 Detail,
84 Form,
85 Authentication,
86 Checkout,
87 Confirmation,
88 Dashboard,
89 Settings,
90 Error,
91 AccessDenied,
92 Unknown,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub enum SemanticRegionKind {
99 Navigation,
100 Main,
101 Search,
102 Form,
103 Dialog,
104 Alert,
105 Status,
106 Toolbar,
107 FilterPanel,
108 Results,
109 Collection,
110 Table,
111 Pagination,
112 Article,
113 Sidebar,
114 CheckoutSummary,
115 Authentication,
116 Footer,
117 Unknown,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct SemanticRouteIdentity {
124 pub target_id: String,
125 pub frame_id: String,
126 pub url: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct SemanticPage {
133 pub kind: SemanticPageKind,
134 pub title: String,
135 pub url: String,
136 pub target_id: String,
137 pub frame_id: String,
138 pub confidence: SemanticConfidence,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub evidence: Vec<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct SemanticExpansionHandle {
147 pub region_id: String,
148 pub revision: u64,
149 pub route: SemanticRouteIdentity,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct SemanticTarget {
156 pub reference: String,
157 pub role: String,
158 #[serde(default, skip_serializing_if = "String::is_empty")]
159 pub name: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub input_type: Option<String>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct SemanticAccessibilityNode {
168 pub role: String,
169 #[serde(default, skip_serializing_if = "String::is_empty")]
170 pub name: String,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub children: Vec<SemanticAccessibilityNode>,
173 #[serde(default, skip_serializing_if = "is_false")]
174 pub interactive: bool,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "lowercase")]
180pub enum SemanticChangeKind {
181 Added,
182 Removed,
183 Updated,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct SemanticRegionChange {
190 pub id: String,
191 pub kind: SemanticChangeKind,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub previous_id: Option<String>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct SemanticTargetChange {
200 pub region_id: String,
201 pub target_id: String,
202 pub kind: SemanticChangeKind,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub previous_target_id: Option<String>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct SemanticContinuity {
211 pub previous_reference: String,
212 pub current_reference: String,
213 pub confidence: SemanticConfidence,
214 pub evidence: String,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct SemanticChangeSet {
221 pub from_revision: u64,
222 pub to_revision: u64,
223 pub route: SemanticRouteIdentity,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub regions: Vec<SemanticRegionChange>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub targets: Vec<SemanticTargetChange>,
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub continuity: Vec<SemanticContinuity>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct SemanticRegion {
236 pub id: String,
237 pub kind: SemanticRegionKind,
238 pub label: String,
239 pub interactive_count: usize,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub item_count: Option<usize>,
242 pub confidence: SemanticConfidence,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub evidence: Vec<String>,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub targets: Vec<SemanticTarget>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub expansion: Option<SemanticExpansionHandle>,
249}
250
251#[derive(Debug, Clone, Default, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct SemanticObservationLimits {
255 pub truncated: bool,
256 pub omitted_regions: usize,
257 #[serde(default)]
258 pub omitted_targets: usize,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub omitted_bytes: Option<usize>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct SemanticObservation {
267 pub schema_version: u32,
268 pub revision: u64,
269 pub level: SemanticObservationLevel,
270 pub route: SemanticRouteIdentity,
271 pub page: SemanticPage,
272 pub regions: Vec<SemanticRegion>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub text: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub accessibility: Option<Vec<SemanticAccessibilityNode>>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub raw_accessibility: Option<Vec<SemanticAccessibilityNode>>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub changes: Option<SemanticChangeSet>,
281 pub limits: SemanticObservationLimits,
282}
283
284impl SemanticObservation {
285 pub fn from_page_context(
289 context: &PageContext,
290 level: SemanticObservationLevel,
291 ) -> Result<Self, SemanticObservationError> {
292 let route = SemanticRouteIdentity {
293 target_id: context.page.target_id.clone(),
294 frame_id: context.page.frame_id.clone(),
295 url: context.page.url.clone(),
296 };
297 let mut regions = Vec::new();
298 let mut anchors = Vec::new();
299 for root in &context.accessibility.roots {
300 collect_regions(
301 root,
302 context.accessibility.revision,
303 &route,
304 &mut regions,
305 &mut anchors,
306 );
307 }
308 if regions.is_empty() {
309 regions.push(SemanticRegion {
310 id: "region_main".into(),
311 kind: SemanticRegionKind::Unknown,
312 label: "Unclassified page content".into(),
313 interactive_count: context.accessibility.interactive.len(),
314 item_count: None,
315 confidence: SemanticConfidence::Unknown,
316 evidence: vec!["no recognized landmark role".into()],
317 targets: Vec::new(),
318 expansion: Some(SemanticExpansionHandle {
319 region_id: "region_main".into(),
320 revision: context.accessibility.revision,
321 route: route.clone(),
322 }),
323 });
324 anchors.push(("region_main".into(), None));
325 }
326 populate_level(
327 &mut regions,
328 &context.accessibility.interactive,
329 &anchors,
330 level,
331 );
332 let (kind, confidence, evidence) = classify_page(context, ®ions);
333 let observation = Self {
334 schema_version: SEMANTIC_OBSERVATION_SCHEMA_VERSION,
335 revision: context.accessibility.revision,
336 level,
337 route: route.clone(),
338 page: SemanticPage {
339 kind,
340 title: context.page.title.clone(),
341 url: context.page.url.clone(),
342 target_id: context.page.target_id.clone(),
343 frame_id: context.page.frame_id.clone(),
344 confidence,
345 evidence,
346 },
347 regions,
348 text: level
349 .includes_text()
350 .then(|| bounded_semantic_text(&context.text, MAX_VISIBLE_TEXT_BYTES)),
351 accessibility: level.includes_accessibility().then(|| {
352 context
353 .accessibility
354 .roots
355 .iter()
356 .map(to_semantic_node)
357 .collect()
358 }),
359 raw_accessibility: level.includes_raw_accessibility().then(|| {
360 context
361 .accessibility
362 .roots
363 .iter()
364 .map(to_semantic_node)
365 .collect()
366 }),
367 changes: None,
368 limits: SemanticObservationLimits {
369 truncated: context.accessibility.truncated
370 || context.accessibility.omitted_count > 0
371 || !context.incomplete.is_empty(),
372 omitted_regions: 0,
373 omitted_targets: context.accessibility.omitted_count,
374 omitted_bytes: None,
375 },
376 };
377 observation.validate()?;
378 Ok(observation)
379 }
380
381 pub fn scoped_region_from_page_context(
386 context: &PageContext,
387 level: SemanticObservationLevel,
388 region_id: &str,
389 ) -> Result<Self, SemanticObservationError> {
390 let mut observation = Self::from_page_context(context, level)?;
391 let region_index = observation
392 .regions
393 .iter()
394 .position(|region| region.id == region_id)
395 .ok_or_else(|| {
396 SemanticObservationError::new(
397 "regionId",
398 format!(
399 "region {region_id:?} is not present at revision {}",
400 observation.revision
401 ),
402 )
403 })?;
404 let omitted_regions = observation.regions.len().saturating_sub(1);
405 observation.regions = vec![observation.regions.remove(region_index)];
406 observation.limits.omitted_regions = omitted_regions;
407 observation.limits.truncated |= omitted_regions > 0;
408 observation.validate()?;
409 Ok(observation)
410 }
411
412 pub fn diff_from(
414 &self,
415 previous: &SemanticObservation,
416 ) -> Result<SemanticChangeSet, SemanticObservationError> {
417 self.validate()?;
418 previous.validate()?;
419 if self.route != previous.route {
420 return Err(SemanticObservationError::new(
421 "route",
422 "semantic diff requires the same target, frame, and URL",
423 ));
424 }
425 if self.level != previous.level {
426 return Err(SemanticObservationError::new(
427 "level",
428 "semantic diff requires matching observation levels",
429 ));
430 }
431 if self.revision < previous.revision {
432 return Err(SemanticObservationError::new(
433 "revision",
434 "semantic diff cannot move backwards in revision",
435 ));
436 }
437
438 let mut regions = Vec::new();
439 let mut matched_current = BTreeSet::new();
440 for previous_region in &previous.regions {
441 let exact = self
442 .regions
443 .iter()
444 .position(|region| region.id == previous_region.id);
445 let fallback = exact.or_else(|| {
446 let candidates: Vec<usize> = self
447 .regions
448 .iter()
449 .enumerate()
450 .filter(|(index, region)| {
451 !matched_current.contains(index)
452 && region.kind == previous_region.kind
453 && region.label == previous_region.label
454 })
455 .map(|(index, _)| index)
456 .collect();
457 (candidates.len() == 1).then_some(candidates[0])
458 });
459 if let Some(current_index) = fallback {
460 matched_current.insert(current_index);
461 let current_region = &self.regions[current_index];
462 if current_region != previous_region {
463 regions.push(SemanticRegionChange {
464 id: current_region.id.clone(),
465 kind: SemanticChangeKind::Updated,
466 previous_id: (current_region.id != previous_region.id)
467 .then(|| previous_region.id.clone()),
468 });
469 }
470 } else {
471 regions.push(SemanticRegionChange {
472 id: previous_region.id.clone(),
473 kind: SemanticChangeKind::Removed,
474 previous_id: None,
475 });
476 }
477 }
478 for (index, current_region) in self.regions.iter().enumerate() {
479 if !matched_current.contains(&index)
480 && !previous
481 .regions
482 .iter()
483 .any(|region| region.id == current_region.id)
484 {
485 regions.push(SemanticRegionChange {
486 id: current_region.id.clone(),
487 kind: SemanticChangeKind::Added,
488 previous_id: None,
489 });
490 }
491 }
492
493 let (targets, continuity) = diff_targets(previous, self);
494 let changes = SemanticChangeSet {
495 from_revision: previous.revision,
496 to_revision: self.revision,
497 route: self.route.clone(),
498 regions,
499 targets,
500 continuity,
501 };
502 validate_change_set(&changes)?;
503 Ok(changes)
504 }
505
506 pub fn with_changes_from(
508 mut self,
509 previous: &SemanticObservation,
510 ) -> Result<Self, SemanticObservationError> {
511 self.changes = Some(self.diff_from(previous)?);
512 self.validate()?;
513 Ok(self)
514 }
515
516 pub fn validate(&self) -> Result<(), SemanticObservationError> {
518 if self.schema_version != SEMANTIC_OBSERVATION_SCHEMA_VERSION {
519 return Err(SemanticObservationError::new(
520 "schemaVersion",
521 format!(
522 "unsupported schema version {}; expected {}",
523 self.schema_version, SEMANTIC_OBSERVATION_SCHEMA_VERSION
524 ),
525 ));
526 }
527 validate_route("route", &self.route)?;
528 validate_route_fields(
529 "page",
530 &self.page.target_id,
531 &self.page.frame_id,
532 &self.page.url,
533 )?;
534 validate_text("page.title", &self.page.title, MAX_TITLE_BYTES, true)?;
535 validate_evidence("page.evidence", &self.page.evidence)?;
536 if self.regions.len() > MAX_REGIONS {
537 return Err(SemanticObservationError::new(
538 "regions",
539 format!("contains more than {MAX_REGIONS} regions"),
540 ));
541 }
542 validate_level_payload(self)?;
543 if let Some(changes) = &self.changes {
544 validate_change_set(changes)?;
545 if changes.to_revision != self.revision || changes.route != self.route {
546 return Err(SemanticObservationError::new(
547 "changes",
548 "change set does not belong to this observation",
549 ));
550 }
551 }
552 if self.page.target_id != self.route.target_id
553 || self.page.frame_id != self.route.frame_id
554 || self.page.url != self.route.url
555 {
556 return Err(SemanticObservationError::new(
557 "page",
558 "page route identity does not match observation route",
559 ));
560 }
561 let mut region_ids = BTreeSet::new();
562 for (index, region) in self.regions.iter().enumerate() {
563 let path = format!("regions[{index}]");
564 validate_text(&format!("{path}.id"), ®ion.id, MAX_ID_BYTES, false)?;
565 if !region_ids.insert(region.id.as_str()) {
566 return Err(SemanticObservationError::new(
567 format!("{path}.id"),
568 "duplicate semantic region ID",
569 ));
570 }
571 validate_text(
572 &format!("{path}.label"),
573 ®ion.label,
574 MAX_LABEL_BYTES,
575 false,
576 )?;
577 validate_evidence(&format!("{path}.evidence"), ®ion.evidence)?;
578 if region.targets.len() > MAX_TARGETS {
579 return Err(SemanticObservationError::new(
580 format!("{path}.targets"),
581 format!("contains more than {MAX_TARGETS} targets"),
582 ));
583 }
584 for (target_index, target) in region.targets.iter().enumerate() {
585 let target_path = format!("{path}.targets[{target_index}]");
586 validate_text(
587 &format!("{target_path}.reference"),
588 &target.reference,
589 MAX_ID_BYTES,
590 false,
591 )?;
592 validate_text(
593 &format!("{target_path}.role"),
594 &target.role,
595 MAX_ROLE_BYTES,
596 false,
597 )?;
598 validate_text(
599 &format!("{target_path}.name"),
600 &target.name,
601 MAX_LABEL_BYTES,
602 true,
603 )?;
604 if let Some(input_type) = &target.input_type {
605 validate_text(
606 &format!("{target_path}.inputType"),
607 input_type,
608 MAX_ROLE_BYTES,
609 false,
610 )?;
611 }
612 }
613 if let Some(expansion) = ®ion.expansion {
614 if expansion.region_id != region.id || expansion.revision != self.revision {
615 return Err(SemanticObservationError::new(
616 format!("{path}.expansion"),
617 "expansion handle does not belong to this region revision",
618 ));
619 }
620 if expansion.route != self.route {
621 return Err(SemanticObservationError::new(
622 format!("{path}.expansion.route"),
623 "expansion handle route does not match observation route",
624 ));
625 }
626 }
627 }
628 Ok(())
629 }
630
631 pub fn from_json(input: &str) -> Result<Self, SemanticObservationError> {
633 let observation: Self = serde_json::from_str(input).map_err(|error| {
634 SemanticObservationError::new("$", format!("invalid observation shape: {error}"))
635 })?;
636 observation.validate()?;
637 Ok(observation)
638 }
639
640 pub fn to_canonical_json(&self) -> Result<String, SemanticObservationError> {
642 self.validate()?;
643 serde_json::to_string(self)
644 .map_err(|error| SemanticObservationError::new("$", error.to_string()))
645 }
646}
647
648fn validate_level_payload(
649 observation: &SemanticObservation,
650) -> Result<(), SemanticObservationError> {
651 let has_targets = observation
652 .regions
653 .iter()
654 .any(|region| !region.targets.is_empty());
655 if has_targets && !observation.level.includes_targets() {
656 return Err(SemanticObservationError::new(
657 "regions.targets",
658 "target payload is not available at the summary observation level",
659 ));
660 }
661 if observation.text.is_some() != observation.level.includes_text() {
662 return Err(SemanticObservationError::new(
663 "text",
664 "visible text payload does not match observation level",
665 ));
666 }
667 if observation.accessibility.is_some() != observation.level.includes_accessibility() {
668 return Err(SemanticObservationError::new(
669 "accessibility",
670 "accessibility payload does not match observation level",
671 ));
672 }
673 if observation.raw_accessibility.is_some() != observation.level.includes_raw_accessibility() {
674 return Err(SemanticObservationError::new(
675 "rawAccessibility",
676 "raw accessibility payload does not match observation level",
677 ));
678 }
679 Ok(())
680}
681
682fn validate_change_set(changes: &SemanticChangeSet) -> Result<(), SemanticObservationError> {
683 validate_route("changes.route", &changes.route)?;
684 if changes.from_revision > changes.to_revision {
685 return Err(SemanticObservationError::new(
686 "changes.fromRevision",
687 "cannot be newer than toRevision",
688 ));
689 }
690 if changes.regions.len() > MAX_CHANGE_ITEMS {
691 return Err(SemanticObservationError::new(
692 "changes.regions",
693 format!("contains more than {MAX_CHANGE_ITEMS} changes"),
694 ));
695 }
696 if changes.targets.len() > MAX_CHANGE_ITEMS {
697 return Err(SemanticObservationError::new(
698 "changes.targets",
699 format!("contains more than {MAX_CHANGE_ITEMS} changes"),
700 ));
701 }
702 if changes.continuity.len() > MAX_CHANGE_ITEMS {
703 return Err(SemanticObservationError::new(
704 "changes.continuity",
705 format!("contains more than {MAX_CHANGE_ITEMS} mappings"),
706 ));
707 }
708 for (index, change) in changes.regions.iter().enumerate() {
709 validate_text(
710 &format!("changes.regions[{index}].id"),
711 &change.id,
712 MAX_ID_BYTES,
713 false,
714 )?;
715 if let Some(previous_id) = &change.previous_id {
716 validate_text(
717 &format!("changes.regions[{index}].previousId"),
718 previous_id,
719 MAX_ID_BYTES,
720 false,
721 )?;
722 }
723 }
724 for (index, change) in changes.targets.iter().enumerate() {
725 validate_text(
726 &format!("changes.targets[{index}].regionId"),
727 &change.region_id,
728 MAX_ID_BYTES,
729 false,
730 )?;
731 validate_text(
732 &format!("changes.targets[{index}].targetId"),
733 &change.target_id,
734 MAX_ID_BYTES,
735 false,
736 )?;
737 if let Some(previous_target_id) = &change.previous_target_id {
738 validate_text(
739 &format!("changes.targets[{index}].previousTargetId"),
740 previous_target_id,
741 MAX_ID_BYTES,
742 false,
743 )?;
744 }
745 }
746 for (index, continuity) in changes.continuity.iter().enumerate() {
747 validate_text(
748 &format!("changes.continuity[{index}].previousReference"),
749 &continuity.previous_reference,
750 MAX_ID_BYTES,
751 false,
752 )?;
753 validate_text(
754 &format!("changes.continuity[{index}].currentReference"),
755 &continuity.current_reference,
756 MAX_ID_BYTES,
757 false,
758 )?;
759 validate_text(
760 &format!("changes.continuity[{index}].evidence"),
761 &continuity.evidence,
762 MAX_EVIDENCE_BYTES,
763 false,
764 )?;
765 }
766 Ok(())
767}
768
769fn diff_targets(
770 previous: &SemanticObservation,
771 current: &SemanticObservation,
772) -> (Vec<SemanticTargetChange>, Vec<SemanticContinuity>) {
773 let mut changes = Vec::new();
774 let mut continuity = Vec::new();
775 let mut matched_current = BTreeSet::new();
776 for previous_region in &previous.regions {
777 let Some(current_region) = current
778 .regions
779 .iter()
780 .find(|region| region.id == previous_region.id)
781 else {
782 for target in &previous_region.targets {
783 changes.push(SemanticTargetChange {
784 region_id: previous_region.id.clone(),
785 target_id: target.reference.clone(),
786 kind: SemanticChangeKind::Removed,
787 previous_target_id: None,
788 });
789 }
790 continue;
791 };
792 for previous_target in &previous_region.targets {
793 let exact = current_region
794 .targets
795 .iter()
796 .position(|target| target.reference == previous_target.reference);
797 let fallback = exact.or_else(|| {
798 let candidates: Vec<usize> = current_region
799 .targets
800 .iter()
801 .enumerate()
802 .filter(|(index, target)| {
803 !matched_current.contains(&(*index, current_region.id.clone()))
804 && target.role == previous_target.role
805 && target.name == previous_target.name
806 && target.input_type == previous_target.input_type
807 })
808 .map(|(index, _)| index)
809 .collect();
810 (candidates.len() == 1).then_some(candidates[0])
811 });
812 if let Some(current_index) = fallback {
813 matched_current.insert((current_index, current_region.id.clone()));
814 let current_target = ¤t_region.targets[current_index];
815 if current_target != previous_target {
816 changes.push(SemanticTargetChange {
817 region_id: current_region.id.clone(),
818 target_id: current_target.reference.clone(),
819 kind: SemanticChangeKind::Updated,
820 previous_target_id: (current_target.reference != previous_target.reference)
821 .then(|| previous_target.reference.clone()),
822 });
823 }
824 if current_target.reference != previous_target.reference {
825 continuity.push(SemanticContinuity {
826 previous_reference: previous_target.reference.clone(),
827 current_reference: current_target.reference.clone(),
828 confidence: SemanticConfidence::Medium,
829 evidence: "unique role/name/inputType match".into(),
830 });
831 }
832 } else {
833 changes.push(SemanticTargetChange {
834 region_id: previous_region.id.clone(),
835 target_id: previous_target.reference.clone(),
836 kind: SemanticChangeKind::Removed,
837 previous_target_id: None,
838 });
839 }
840 }
841 for (index, current_target) in current_region.targets.iter().enumerate() {
842 if !matched_current.contains(&(index, current_region.id.clone()))
843 && !previous_region
844 .targets
845 .iter()
846 .any(|target| target.reference == current_target.reference)
847 {
848 changes.push(SemanticTargetChange {
849 region_id: current_region.id.clone(),
850 target_id: current_target.reference.clone(),
851 kind: SemanticChangeKind::Added,
852 previous_target_id: None,
853 });
854 }
855 }
856 }
857 for current_region in ¤t.regions {
858 if !previous
859 .regions
860 .iter()
861 .any(|region| region.id == current_region.id)
862 {
863 for target in ¤t_region.targets {
864 changes.push(SemanticTargetChange {
865 region_id: current_region.id.clone(),
866 target_id: target.reference.clone(),
867 kind: SemanticChangeKind::Added,
868 previous_target_id: None,
869 });
870 }
871 }
872 }
873 changes.truncate(MAX_CHANGE_ITEMS);
874 continuity.truncate(MAX_CHANGE_ITEMS);
875 (changes, continuity)
876}
877
878fn validate_route(
879 path: &str,
880 route: &SemanticRouteIdentity,
881) -> Result<(), SemanticObservationError> {
882 validate_route_fields(path, &route.target_id, &route.frame_id, &route.url)
883}
884
885fn validate_route_fields(
886 path: &str,
887 target_id: &str,
888 frame_id: &str,
889 url: &str,
890) -> Result<(), SemanticObservationError> {
891 validate_text(&format!("{path}.targetId"), target_id, MAX_ID_BYTES, false)?;
892 validate_text(&format!("{path}.frameId"), frame_id, MAX_ID_BYTES, false)?;
893 validate_text(&format!("{path}.url"), url, MAX_URL_BYTES, false)
894}
895
896fn validate_evidence(path: &str, evidence: &[String]) -> Result<(), SemanticObservationError> {
897 if evidence.len() > MAX_EVIDENCE_ITEMS {
898 return Err(SemanticObservationError::new(
899 path,
900 format!("contains more than {MAX_EVIDENCE_ITEMS} evidence items"),
901 ));
902 }
903 for (index, item) in evidence.iter().enumerate() {
904 validate_text(&format!("{path}[{index}]"), item, MAX_EVIDENCE_BYTES, false)?;
905 }
906 Ok(())
907}
908
909fn validate_text(
910 path: &str,
911 value: &str,
912 maximum: usize,
913 allow_empty: bool,
914) -> Result<(), SemanticObservationError> {
915 if (!allow_empty && value.is_empty()) || value.len() > maximum {
916 let requirement = if allow_empty {
917 format!("at most {maximum} bytes")
918 } else {
919 format!("non-empty and at most {maximum} bytes")
920 };
921 return Err(SemanticObservationError::new(
922 path,
923 format!("must be {requirement}"),
924 ));
925 }
926 Ok(())
927}
928
929#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
931pub struct SemanticObservationError {
932 pub path: String,
933 pub reason: String,
934}
935
936impl SemanticObservationError {
937 fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
938 Self {
939 path: path.into(),
940 reason: reason.into(),
941 }
942 }
943}
944
945impl std::fmt::Display for SemanticObservationError {
946 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
947 write!(formatter, "{}: {}", self.path, self.reason)
948 }
949}
950
951impl std::error::Error for SemanticObservationError {}
952
953fn collect_regions(
954 node: &CompactAxNode,
955 revision: u64,
956 route: &SemanticRouteIdentity,
957 regions: &mut Vec<SemanticRegion>,
958 anchors: &mut Vec<(String, Option<String>)>,
959) {
960 if let Some(kind) = region_kind(&node.role) {
961 let ordinal = regions.iter().filter(|region| region.kind == kind).count() + 1;
962 let id = format!("region_{}_{}", region_kind_name(kind), ordinal);
963 let item_count = match kind {
964 SemanticRegionKind::Results
965 | SemanticRegionKind::Collection
966 | SemanticRegionKind::Table => Some(
967 node.children
968 .iter()
969 .filter(|child| matches!(child.role.as_str(), "listitem" | "row" | "option"))
970 .count(),
971 ),
972 _ => None,
973 };
974 regions.push(SemanticRegion {
975 id: id.clone(),
976 kind,
977 label: if node.name.is_empty() {
978 region_kind_label(kind).into()
979 } else {
980 bounded_semantic_text(&node.name, MAX_LABEL_BYTES)
981 },
982 interactive_count: count_interactive(node),
983 item_count,
984 confidence: SemanticConfidence::Exact,
985 evidence: vec![format!("aria-role={}", node.role)],
986 targets: Vec::new(),
987 expansion: Some(SemanticExpansionHandle {
988 region_id: id.clone(),
989 revision,
990 route: route.clone(),
991 }),
992 });
993 anchors.push((id, Some(format!("{}:{}", node.role, node.name))));
994 }
995 for child in &node.children {
996 collect_regions(child, revision, route, regions, anchors);
997 }
998}
999
1000fn populate_level(
1001 regions: &mut [SemanticRegion],
1002 controls: &[crate::browser::dom::CompactInteractiveElement],
1003 anchors: &[(String, Option<String>)],
1004 level: SemanticObservationLevel,
1005) {
1006 if !level.includes_targets() {
1007 return;
1008 }
1009 for control in controls.iter().take(MAX_TARGETS) {
1010 let region_index = anchors
1011 .iter()
1012 .enumerate()
1013 .filter(|(_, (_, anchor))| {
1014 anchor
1015 .as_ref()
1016 .is_some_and(|anchor| control.ancestor_path.iter().any(|item| item == anchor))
1017 })
1018 .map(|(index, _)| index)
1019 .next_back()
1020 .unwrap_or_else(|| regions.len().saturating_sub(1));
1021 if let Some(region) = regions.get_mut(region_index) {
1022 region.targets.push(SemanticTarget {
1023 reference: control.reference.clone(),
1024 role: bounded_semantic_text(&control.role, MAX_ROLE_BYTES),
1025 name: bounded_semantic_text(&control.name, MAX_LABEL_BYTES),
1026 input_type: control
1027 .input_type
1028 .as_deref()
1029 .map(|value| bounded_semantic_text(value, MAX_ROLE_BYTES)),
1030 });
1031 }
1032 }
1033}
1034
1035fn is_false(value: &bool) -> bool {
1036 !*value
1037}
1038
1039fn to_semantic_node(node: &CompactAxNode) -> SemanticAccessibilityNode {
1040 SemanticAccessibilityNode {
1041 role: bounded_semantic_text(&node.role, MAX_ROLE_BYTES),
1042 name: bounded_semantic_text(&node.name, MAX_LABEL_BYTES),
1043 children: node.children.iter().map(to_semantic_node).collect(),
1044 interactive: node.interactive,
1045 }
1046}
1047
1048fn count_interactive(node: &CompactAxNode) -> usize {
1049 usize::from(node.interactive) + node.children.iter().map(count_interactive).sum::<usize>()
1050}
1051
1052fn region_kind(role: &str) -> Option<SemanticRegionKind> {
1053 Some(match role {
1054 "navigation" => SemanticRegionKind::Navigation,
1055 "main" => SemanticRegionKind::Main,
1056 "search" => SemanticRegionKind::Search,
1057 "form" => SemanticRegionKind::Form,
1058 "dialog" => SemanticRegionKind::Dialog,
1059 "alert" => SemanticRegionKind::Alert,
1060 "status" => SemanticRegionKind::Status,
1061 "toolbar" => SemanticRegionKind::Toolbar,
1062 "complementary" => SemanticRegionKind::Sidebar,
1063 "article" => SemanticRegionKind::Article,
1064 "contentinfo" => SemanticRegionKind::Footer,
1065 "table" => SemanticRegionKind::Table,
1066 "list" => SemanticRegionKind::Collection,
1067 _ => return None,
1068 })
1069}
1070
1071fn region_kind_name(kind: SemanticRegionKind) -> &'static str {
1072 match kind {
1073 SemanticRegionKind::Navigation => "navigation",
1074 SemanticRegionKind::Main => "main",
1075 SemanticRegionKind::Search => "search",
1076 SemanticRegionKind::Form => "form",
1077 SemanticRegionKind::Dialog => "dialog",
1078 SemanticRegionKind::Alert => "alert",
1079 SemanticRegionKind::Status => "status",
1080 SemanticRegionKind::Toolbar => "toolbar",
1081 SemanticRegionKind::FilterPanel => "filter_panel",
1082 SemanticRegionKind::Results => "results",
1083 SemanticRegionKind::Collection => "collection",
1084 SemanticRegionKind::Table => "table",
1085 SemanticRegionKind::Pagination => "pagination",
1086 SemanticRegionKind::Article => "article",
1087 SemanticRegionKind::Sidebar => "sidebar",
1088 SemanticRegionKind::CheckoutSummary => "checkout_summary",
1089 SemanticRegionKind::Authentication => "authentication",
1090 SemanticRegionKind::Footer => "footer",
1091 SemanticRegionKind::Unknown => "unknown",
1092 }
1093}
1094
1095fn region_kind_label(kind: SemanticRegionKind) -> &'static str {
1096 match kind {
1097 SemanticRegionKind::Navigation => "Navigation",
1098 SemanticRegionKind::Main => "Main content",
1099 SemanticRegionKind::Search => "Search",
1100 SemanticRegionKind::Form => "Form",
1101 SemanticRegionKind::Dialog => "Dialog",
1102 SemanticRegionKind::Alert => "Alert",
1103 SemanticRegionKind::Status => "Status",
1104 SemanticRegionKind::Toolbar => "Toolbar",
1105 SemanticRegionKind::Sidebar => "Sidebar",
1106 SemanticRegionKind::Article => "Article",
1107 SemanticRegionKind::Footer => "Footer",
1108 SemanticRegionKind::Table => "Table",
1109 SemanticRegionKind::Collection => "Collection",
1110 _ => "Page region",
1111 }
1112}
1113
1114fn classify_page(
1115 context: &PageContext,
1116 regions: &[SemanticRegion],
1117) -> (SemanticPageKind, SemanticConfidence, Vec<String>) {
1118 let title = context.page.title.to_ascii_lowercase();
1119 let url = context.page.url.to_ascii_lowercase();
1120 let has = |kind| regions.iter().any(|region| region.kind == kind);
1121 if title.contains("sign in")
1122 || title.contains("log in")
1123 || url.contains("/login")
1124 || url.contains("/signin")
1125 || url.contains("/auth")
1126 {
1127 return (
1128 SemanticPageKind::Authentication,
1129 SemanticConfidence::High,
1130 vec!["title-or-url-signature=authentication".into()],
1131 );
1132 }
1133 if has(SemanticRegionKind::Search) {
1134 let kind = if url.contains("search") || title.contains("search") {
1135 SemanticPageKind::SearchResults
1136 } else {
1137 SemanticPageKind::Search
1138 };
1139 return (
1140 kind,
1141 SemanticConfidence::High,
1142 vec!["aria-role=search".into()],
1143 );
1144 }
1145 if has(SemanticRegionKind::Form) {
1146 return (
1147 SemanticPageKind::Form,
1148 SemanticConfidence::High,
1149 vec!["aria-role=form".into()],
1150 );
1151 }
1152 if has(SemanticRegionKind::Article) {
1153 return (
1154 SemanticPageKind::Article,
1155 SemanticConfidence::High,
1156 vec!["aria-role=article".into()],
1157 );
1158 }
1159 (
1160 SemanticPageKind::Generic,
1161 SemanticConfidence::Unknown,
1162 vec!["no-high-confidence-page-signature".into()],
1163 )
1164}
1165
1166fn bounded_semantic_text(value: &str, maximum: usize) -> String {
1167 if value.len() <= maximum {
1168 return value.to_string();
1169 }
1170 let mut end = maximum.saturating_sub(15);
1171 while end > 0 && !value.is_char_boundary(end) {
1172 end -= 1;
1173 }
1174 format!("{}…[truncated]", &value[..end])
1175}
1176
1177impl super::BrowserSession {
1178 pub async fn semantic_observe(
1180 &self,
1181 level: SemanticObservationLevel,
1182 ) -> super::types::BrowserResult<SemanticObservation> {
1183 let context = self.observe_fresh().await?;
1184 SemanticObservation::from_page_context(&context, level).map_err(Into::into)
1185 }
1186
1187 pub async fn semantic_observe_with_knowledge(
1194 &self,
1195 level: SemanticObservationLevel,
1196 store: &super::KnowledgeStore,
1197 options: super::KnowledgeLookupOptions,
1198 fresh_only: bool,
1199 ) -> super::types::BrowserResult<super::KnowledgeObservationReport> {
1200 let observation = self.semantic_observe(level).await?;
1201 if fresh_only {
1202 return Ok(super::KnowledgeObservationReport {
1203 observation,
1204 mode: super::KnowledgeObservationMode::FreshOnly,
1205 assessments: Vec::new(),
1206 eligible_record_ids: Vec::new(),
1207 stale_record_ids: Vec::new(),
1208 out_of_scope_record_ids: Vec::new(),
1209 });
1210 }
1211 let context = super::KnowledgeLookupContext::from_observation(&observation, options)?;
1212 let assessments = store.assess(&context);
1213 let mut eligible_record_ids = Vec::new();
1214 let mut stale_record_ids = Vec::new();
1215 let mut out_of_scope_record_ids = Vec::new();
1216 for assessment in &assessments {
1217 match assessment.status {
1218 super::KnowledgeAssessmentStatus::Eligible => {
1219 eligible_record_ids.push(assessment.record_id.clone())
1220 }
1221 super::KnowledgeAssessmentStatus::Stale => {
1222 stale_record_ids.push(assessment.record_id.clone())
1223 }
1224 super::KnowledgeAssessmentStatus::OutOfScope => {
1225 out_of_scope_record_ids.push(assessment.record_id.clone())
1226 }
1227 super::KnowledgeAssessmentStatus::Contradicted
1228 | super::KnowledgeAssessmentStatus::Quarantined => {}
1229 }
1230 }
1231 Ok(super::KnowledgeObservationReport {
1232 observation,
1233 mode: super::KnowledgeObservationMode::Assessed,
1234 assessments,
1235 eligible_record_ids,
1236 stale_record_ids,
1237 out_of_scope_record_ids,
1238 })
1239 }
1240
1241 pub async fn semantic_expand_region(
1243 &self,
1244 region_id: &str,
1245 revision: u64,
1246 level: SemanticObservationLevel,
1247 ) -> super::types::BrowserResult<SemanticObservation> {
1248 let context = self.observe_fresh().await?;
1249 if context.accessibility.revision != revision {
1250 return Err(SemanticObservationError::new(
1251 "revision",
1252 format!(
1253 "semantic region revision {revision} is stale; current revision is {}",
1254 context.accessibility.revision
1255 ),
1256 )
1257 .into());
1258 }
1259 SemanticObservation::scoped_region_from_page_context(&context, level, region_id)
1260 .map_err(Into::into)
1261 }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::*;
1267
1268 fn observation() -> SemanticObservation {
1269 let route = SemanticRouteIdentity {
1270 target_id: "target".into(),
1271 frame_id: "frame".into(),
1272 url: "https://example.test/search".into(),
1273 };
1274 SemanticObservation {
1275 schema_version: SEMANTIC_OBSERVATION_SCHEMA_VERSION,
1276 revision: 42,
1277 level: SemanticObservationLevel::Summary,
1278 page: SemanticPage {
1279 kind: SemanticPageKind::SearchResults,
1280 title: "Search".into(),
1281 url: route.url.clone(),
1282 target_id: route.target_id.clone(),
1283 frame_id: route.frame_id.clone(),
1284 confidence: SemanticConfidence::High,
1285 evidence: vec!["role=main".into()],
1286 },
1287 regions: vec![SemanticRegion {
1288 id: "region_results".into(),
1289 kind: SemanticRegionKind::Results,
1290 label: "Search results".into(),
1291 interactive_count: 2,
1292 item_count: Some(10),
1293 confidence: SemanticConfidence::High,
1294 evidence: vec!["repeated item structure".into()],
1295 targets: Vec::new(),
1296 expansion: Some(SemanticExpansionHandle {
1297 region_id: "region_results".into(),
1298 revision: 42,
1299 route: route.clone(),
1300 }),
1301 }],
1302 text: None,
1303 accessibility: None,
1304 raw_accessibility: None,
1305 changes: None,
1306 limits: SemanticObservationLimits::default(),
1307 route,
1308 }
1309 }
1310
1311 #[test]
1312 fn canonical_json_is_stable_and_camel_case() {
1313 let observation = observation();
1314 let first = observation.to_canonical_json().unwrap();
1315 let second = observation.to_canonical_json().unwrap();
1316 assert_eq!(first, second);
1317 assert!(first.contains("\"schemaVersion\":1"));
1318 assert!(first.contains("\"searchResults\""));
1319 assert!(first.contains("\"regionId\":\"region_results\""));
1320 }
1321
1322 #[test]
1323 fn rejects_duplicate_regions_and_route_mismatch() {
1324 let mut duplicate_regions = observation();
1325 duplicate_regions
1326 .regions
1327 .push(duplicate_regions.regions[0].clone());
1328 let error = duplicate_regions.validate().unwrap_err();
1329 assert_eq!(error.path, "regions[1].id");
1330
1331 let mut route_mismatch = observation();
1332 route_mismatch.page.url = "https://other.test".into();
1333 let error = route_mismatch.validate().unwrap_err();
1334 assert_eq!(error.path, "page");
1335 }
1336
1337 #[test]
1338 fn accepts_additive_unknown_response_fields() {
1339 let mut value: Value = serde_json::to_value(observation()).unwrap();
1340 value["futureField"] = Value::Bool(true);
1341 let decoded = SemanticObservation::from_json(&value.to_string()).unwrap();
1342 decoded.validate().unwrap();
1343 }
1344
1345 #[test]
1346 fn additive_response_fixture_remains_compatible() {
1347 let observation = SemanticObservation::from_json(include_str!(
1348 "../../../tests/fixtures/semantic-additive-v1.json"
1349 ))
1350 .unwrap();
1351 observation.validate().unwrap();
1352 assert_eq!(observation.revision, 7);
1353 }
1354
1355 #[test]
1356 fn computes_revision_changes_and_conservative_target_continuity() {
1357 let mut previous = observation();
1358 previous.level = SemanticObservationLevel::Interactive;
1359 previous.regions[0].targets.push(SemanticTarget {
1360 reference: "axr-42-9".into(),
1361 role: "button".into(),
1362 name: "Continue".into(),
1363 input_type: None,
1364 });
1365
1366 let mut current = previous.clone();
1367 current.revision = 43;
1368 current.regions[0].expansion.as_mut().unwrap().revision = 43;
1369 current.regions[0].targets[0].reference = "axr-43-9".into();
1370 current.changes = None;
1371
1372 let changes = current.diff_from(&previous).unwrap();
1373 assert_eq!(changes.from_revision, 42);
1374 assert_eq!(changes.to_revision, 43);
1375 assert_eq!(changes.targets.len(), 1);
1376 assert_eq!(changes.targets[0].kind, SemanticChangeKind::Updated);
1377 assert_eq!(changes.continuity.len(), 1);
1378 assert_eq!(changes.continuity[0].confidence, SemanticConfidence::Medium);
1379
1380 let enriched = current.with_changes_from(&previous).unwrap();
1381 assert!(enriched.changes.is_some());
1382 assert!(SemanticObservation::from_json(&enriched.to_canonical_json().unwrap()).is_ok());
1383 }
1384
1385 #[test]
1386 fn rejects_backwards_or_cross_route_semantic_diffs() {
1387 let previous = observation();
1388 let mut current = observation();
1389 current.revision = 41;
1390 current.regions[0].expansion.as_mut().unwrap().revision = 41;
1391 let error = current.diff_from(&previous).unwrap_err();
1392 assert_eq!(error.path, "revision");
1393
1394 current.revision = 42;
1395 current.regions[0].expansion.as_mut().unwrap().revision = 42;
1396 current.route.url = "https://other.test".into();
1397 current.page.url = "https://other.test".into();
1398 current.regions[0].expansion.as_mut().unwrap().route = current.route.clone();
1399 let error = current.diff_from(&previous).unwrap_err();
1400 assert_eq!(error.path, "route");
1401 }
1402
1403 #[test]
1404 fn classifies_landmarks_without_dropping_interactive_counts() {
1405 let page = super::super::types::PageInfo {
1406 url: "https://example.test/search".into(),
1407 title: "Search".into(),
1408 ready_state: "complete".into(),
1409 target_id: "target".into(),
1410 frame_id: "frame".into(),
1411 };
1412 let mut context = PageContext {
1413 page,
1414 text: "results".into(),
1415 dom: None,
1416 accessibility: super::super::types::CompactAccessibilitySnapshot {
1417 page: super::super::types::PageInfo {
1418 url: "https://example.test/search".into(),
1419 title: "Search".into(),
1420 ready_state: "complete".into(),
1421 target_id: "target".into(),
1422 frame_id: "frame".into(),
1423 },
1424 revision: 7,
1425 roots: vec![CompactAxNode {
1426 role: "search".into(),
1427 name: "Site search".into(),
1428 children: vec![CompactAxNode {
1429 role: "textbox".into(),
1430 name: "Query".into(),
1431 children: Vec::new(),
1432 interactive: true,
1433 }],
1434 interactive: false,
1435 }],
1436 interactive: Vec::new(),
1437 truncated: false,
1438 omitted_count: 0,
1439 ranking_applied: false,
1440 completeness: None,
1441 },
1442 consistency: super::super::types::ObservationConsistency {
1443 consistent: true,
1444 attempts: 1,
1445 start_revision: 7,
1446 end_revision: 7,
1447 start_mutation_revision: 0,
1448 end_mutation_revision: 0,
1449 },
1450 boundaries: Default::default(),
1451 incomplete: Vec::new(),
1452 screenshot: None,
1453 };
1454 context
1455 .accessibility
1456 .interactive
1457 .push(crate::browser::dom::CompactInteractiveElement {
1458 reference: "axr-7-9".into(),
1459 role: "textbox".into(),
1460 name: "Query".into(),
1461 backend_dom_node_id: 9,
1462 ancestor_path: vec!["search:Site search".into()],
1463 shadow_host_path: None,
1464 input_type: Some("search".into()),
1465 value: None,
1466 checked: None,
1467 selected_option: None,
1468 empty: false,
1469 read_only: false,
1470 required: false,
1471 });
1472 let semantic =
1473 SemanticObservation::from_page_context(&context, SemanticObservationLevel::Summary)
1474 .unwrap();
1475 assert_eq!(semantic.page.kind, SemanticPageKind::SearchResults);
1476 assert_eq!(semantic.regions[0].id, "region_search_1");
1477 assert_eq!(semantic.regions[0].interactive_count, 1);
1478 assert!(semantic.regions[0].targets.is_empty());
1479 assert!(!semantic.limits.truncated);
1480
1481 let interactive =
1482 SemanticObservation::from_page_context(&context, SemanticObservationLevel::Interactive)
1483 .unwrap();
1484 assert_eq!(interactive.regions[0].targets[0].reference, "axr-7-9");
1485 assert!(interactive.text.is_none());
1486
1487 let detailed =
1488 SemanticObservation::from_page_context(&context, SemanticObservationLevel::Detailed)
1489 .unwrap();
1490 assert_eq!(detailed.text.as_deref(), Some("results"));
1491 assert!(detailed.accessibility.is_some());
1492 assert!(detailed.raw_accessibility.is_none());
1493
1494 let scoped = SemanticObservation::scoped_region_from_page_context(
1495 &context,
1496 SemanticObservationLevel::Structured,
1497 "region_search_1",
1498 )
1499 .unwrap();
1500 assert_eq!(scoped.regions.len(), 1);
1501 assert_eq!(scoped.limits.omitted_regions, 0);
1502 }
1503}