1use crate::browser::dom::{CompactAxNode, DomNode};
7use crate::browser::session::PageContext;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::fmt::{Display, Formatter};
11
12pub const EXTRACTION_CONTRACT_SCHEMA_VERSION: u32 = 1;
14
15pub const MAX_EXTRACTION_NODES: u32 = 8_192;
17pub const MAX_EXTRACTION_TEXT_BYTES: u32 = 128 * 1024;
19pub const MAX_EXTRACTION_DEPTH: u16 = 64;
21pub const MAX_EXTRACTION_DURATION_MS: u64 = 15_000;
23pub const MAX_EXTRACTION_OUTPUT_BYTES: u32 = 256 * 1024;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub enum ExtractionScope {
30 Document,
32 Region { region_id: String },
34 Frame { frame_id: String },
36}
37
38impl ExtractionScope {
39 fn validate(&self) -> Result<(), ExtractionContractError> {
40 match self {
41 Self::Document => Ok(()),
42 Self::Region { region_id } => validate_identifier("scope.regionId", region_id),
43 Self::Frame { frame_id } => validate_identifier("scope.frameId", frame_id),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51pub enum EvidenceSource {
52 Dom,
53 Accessibility,
54 Layout,
55 Forms,
56 Navigation,
57 Tables,
58 Collections,
59 Dialogs,
60 Frames,
61 ShadowDom,
62 BoundedProbe,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct ExtractionBudgets {
69 pub max_nodes: u32,
70 pub max_text_bytes: u32,
71 pub max_depth: u16,
72 pub max_duration_ms: u64,
73 pub max_output_bytes: u32,
74}
75
76impl Default for ExtractionBudgets {
77 fn default() -> Self {
78 Self {
79 max_nodes: 2_048,
80 max_text_bytes: 32 * 1024,
81 max_depth: 32,
82 max_duration_ms: 5_000,
83 max_output_bytes: 64 * 1024,
84 }
85 }
86}
87
88impl ExtractionBudgets {
89 pub fn validate(&self) -> Result<(), ExtractionContractError> {
91 validate_positive_bounded("budgets.maxNodes", self.max_nodes, MAX_EXTRACTION_NODES)?;
92 validate_positive_bounded(
93 "budgets.maxTextBytes",
94 self.max_text_bytes,
95 MAX_EXTRACTION_TEXT_BYTES,
96 )?;
97 validate_positive_bounded("budgets.maxDepth", self.max_depth, MAX_EXTRACTION_DEPTH)?;
98 validate_positive_bounded(
99 "budgets.maxDurationMs",
100 self.max_duration_ms,
101 MAX_EXTRACTION_DURATION_MS,
102 )?;
103 validate_positive_bounded(
104 "budgets.maxOutputBytes",
105 self.max_output_bytes,
106 MAX_EXTRACTION_OUTPUT_BYTES,
107 )?;
108 Ok(())
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct ExtractionRequest {
116 pub schema_version: u32,
117 pub scope: ExtractionScope,
118 pub sources: Vec<EvidenceSource>,
119 pub budgets: ExtractionBudgets,
120}
121
122impl ExtractionRequest {
123 pub fn validate(&self) -> Result<(), ExtractionContractError> {
125 if self.schema_version != EXTRACTION_CONTRACT_SCHEMA_VERSION {
126 return Err(ExtractionContractError::new(
127 "schemaVersion",
128 "unsupported extraction contract schema version",
129 ));
130 }
131 self.scope.validate()?;
132 if self.sources.is_empty() {
133 return Err(ExtractionContractError::new(
134 "sources",
135 "at least one evidence source is required",
136 ));
137 }
138 let mut unique_sources = BTreeSet::new();
139 for source in &self.sources {
140 if !unique_sources.insert(*source) {
141 return Err(ExtractionContractError::new(
142 "sources",
143 "evidence sources must not be duplicated",
144 ));
145 }
146 }
147 self.budgets.validate()
148 }
149
150 pub fn from_json(input: &str) -> Result<Self, ExtractionContractError> {
152 let request: Self = serde_json::from_str(input).map_err(|error| {
153 ExtractionContractError::new("$", format!("invalid extraction request: {error}"))
154 })?;
155 request.validate()?;
156 Ok(request)
157 }
158
159 pub fn to_canonical_json(&self) -> Result<String, ExtractionContractError> {
161 self.validate()?;
162 serde_json::to_string(self).map_err(|error| {
163 ExtractionContractError::new(
164 "$",
165 format!("failed to serialize extraction request: {error}"),
166 )
167 })
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "lowercase")]
174pub enum EvidenceQuality {
175 Confirmed,
176 Strong,
177 Partial,
178 Inferred,
179 Conflicted,
180 Opaque,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub enum EvidenceRelationshipHint {
187 Contains,
188 Labels,
189 Owns,
190 Controls,
191 NavigatesTo,
192 Opens,
193 Confirms,
194 Cancels,
195 Continues,
196 Submits,
197 HeaderFor,
198 CellOf,
199 Selects,
200 RepeatsAs,
201 ScopedTo,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct EvidenceCoverage {
208 pub structural: EvidenceQuality,
209 pub semantic: EvidenceQuality,
210 pub interactive_entities_observed: u32,
211 pub opaque_regions: u32,
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 pub reasons: Vec<String>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct ExtractionEvidence {
220 pub schema_version: u32,
221 pub revision: u64,
222 pub scope: ExtractionScope,
223 pub sources: Vec<EvidenceSource>,
224 pub facts: Vec<EvidenceFact>,
225 pub limits: ExtractionEvidenceLimits,
226 pub coverage: EvidenceCoverage,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct EvidenceFact {
233 pub source: EvidenceSource,
234 pub kind: String,
235 pub quality: EvidenceQuality,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub role: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub name: Option<String>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub parent_role: Option<String>,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub relationship_hint: Option<EvidenceRelationshipHint>,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub input_type: Option<String>,
248 #[serde(skip_serializing_if = "Option::is_none")]
249 pub required: Option<bool>,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub read_only: Option<bool>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub empty: Option<bool>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub geometry_present: Option<bool>,
256}
257
258impl ExtractionEvidence {
259 pub fn validate_relationship_hints(&self) -> Result<(), ExtractionContractError> {
261 for (index, fact) in self.facts.iter().enumerate() {
262 let path = format!("facts[{index}].relationshipHint");
263 fact.validate_relationship_hint(&path)?;
264 }
265 Ok(())
266 }
267}
268
269impl EvidenceFact {
270 pub fn validate_relationship_hint(&self, path: &str) -> Result<(), ExtractionContractError> {
272 let Some(hint) = self.relationship_hint else {
273 return Ok(());
274 };
275 if self.parent_role.as_deref().is_none_or(str::is_empty) {
276 return Err(ExtractionContractError::new(
277 path,
278 "relationship hints require a bounded parent role",
279 ));
280 }
281 if !relationship_hint_allowed(self.source, hint) {
282 return Err(ExtractionContractError::new(
283 path,
284 format!(
285 "relationship hint {hint:?} is not supported by source {:?}",
286 self.source
287 ),
288 ));
289 }
290 Ok(())
291 }
292}
293
294fn relationship_hint_allowed(source: EvidenceSource, hint: EvidenceRelationshipHint) -> bool {
295 match source {
296 EvidenceSource::Accessibility => matches!(
297 hint,
298 EvidenceRelationshipHint::Contains
299 | EvidenceRelationshipHint::Labels
300 | EvidenceRelationshipHint::Owns
301 | EvidenceRelationshipHint::Controls
302 | EvidenceRelationshipHint::Opens
303 | EvidenceRelationshipHint::Confirms
304 | EvidenceRelationshipHint::Cancels
305 | EvidenceRelationshipHint::Continues
306 | EvidenceRelationshipHint::Selects
307 | EvidenceRelationshipHint::ScopedTo
308 ),
309 EvidenceSource::Dom => matches!(
310 hint,
311 EvidenceRelationshipHint::Contains
312 | EvidenceRelationshipHint::Labels
313 | EvidenceRelationshipHint::Owns
314 | EvidenceRelationshipHint::Controls
315 | EvidenceRelationshipHint::NavigatesTo
316 | EvidenceRelationshipHint::Opens
317 | EvidenceRelationshipHint::Confirms
318 | EvidenceRelationshipHint::Cancels
319 | EvidenceRelationshipHint::Continues
320 | EvidenceRelationshipHint::Submits
321 | EvidenceRelationshipHint::HeaderFor
322 | EvidenceRelationshipHint::CellOf
323 | EvidenceRelationshipHint::Selects
324 | EvidenceRelationshipHint::RepeatsAs
325 | EvidenceRelationshipHint::ScopedTo
326 ),
327 EvidenceSource::Forms => matches!(
328 hint,
329 EvidenceRelationshipHint::Contains
330 | EvidenceRelationshipHint::Labels
331 | EvidenceRelationshipHint::Owns
332 | EvidenceRelationshipHint::Controls
333 | EvidenceRelationshipHint::Submits
334 ),
335 EvidenceSource::Layout => matches!(
336 hint,
337 EvidenceRelationshipHint::Contains | EvidenceRelationshipHint::ScopedTo
338 ),
339 EvidenceSource::Navigation
340 | EvidenceSource::Tables
341 | EvidenceSource::Collections
342 | EvidenceSource::Dialogs
343 | EvidenceSource::Frames
344 | EvidenceSource::ShadowDom
345 | EvidenceSource::BoundedProbe => false,
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase")]
352pub struct ExtractionEvidenceLimits {
353 pub truncated: bool,
354 pub omitted_facts: u32,
355 pub text_bytes: u32,
356 pub missing_sources: Vec<EvidenceSource>,
357}
358
359pub fn extract_page_context(
365 context: &PageContext,
366 request: &ExtractionRequest,
367) -> Result<ExtractionEvidence, ExtractionContractError> {
368 request.validate()?;
369 if !matches!(request.scope, ExtractionScope::Document) {
370 return Err(ExtractionContractError::new(
371 "scope",
372 "region and frame extraction require a scoped page observation",
373 ));
374 }
375
376 let mut collector = EvidenceCollector::new(request.budgets);
377 let mut missing_sources = BTreeSet::new();
378 let incomplete_accessibility = context.incomplete.iter().any(|reason| {
379 matches!(
380 reason,
381 crate::browser::session::ObservationIncompleteReason::AccessibilityNode
382 | crate::browser::session::ObservationIncompleteReason::AccessibilityLabel
383 )
384 });
385
386 for source in &request.sources {
387 match source {
388 EvidenceSource::Accessibility => {
389 if incomplete_accessibility {
390 missing_sources.insert(*source);
391 } else {
392 for root in &context.accessibility.roots {
393 collect_accessibility(root, &mut collector, 0, None);
394 }
395 }
396 }
397 EvidenceSource::Dom => {
398 if let Some(root) = context.dom.as_ref() {
399 collect_dom(root, &mut collector, 0);
400 } else {
401 missing_sources.insert(*source);
402 }
403 }
404 EvidenceSource::Forms => {
405 for control in &context.accessibility.interactive {
406 if control.input_type.is_some() || is_form_control_role(&control.role) {
407 collector.push(EvidenceFact {
408 source: *source,
409 kind: "control".into(),
410 quality: EvidenceQuality::Strong,
411 role: Some(control.role.clone()),
412 name: Some(control.name.clone()),
413 input_type: control.input_type.clone(),
414 required: Some(control.required),
415 read_only: Some(control.read_only),
416 empty: Some(control.empty),
417 geometry_present: None,
418 parent_role: control
419 .ancestor_path
420 .last()
421 .and_then(|value| value.split(':').next())
422 .filter(|value| !value.is_empty())
423 .map(str::to_owned),
424 relationship_hint: custom_form_control_hint(
425 &control.role,
426 control
427 .ancestor_path
428 .last()
429 .and_then(|value| value.split(':').next()),
430 ),
431 });
432 }
433 }
434 }
435 EvidenceSource::Layout => {
436 if let Some(root) = context.dom.as_ref() {
437 collect_layout(root, &mut collector, *source, 0);
438 } else {
439 missing_sources.insert(*source);
440 }
441 }
442 _ => {
443 missing_sources.insert(*source);
444 }
445 }
446 }
447 collector.truncated |= context.accessibility.truncated || context.boundaries.truncated;
448 let missing_source_values: Vec<_> = missing_sources.iter().copied().collect();
449 let coverage = evidence_coverage(
450 context,
451 &request.sources,
452 &missing_sources,
453 collector.truncated,
454 );
455 let mut evidence = ExtractionEvidence {
456 schema_version: EXTRACTION_CONTRACT_SCHEMA_VERSION,
457 revision: context.accessibility.revision,
458 scope: request.scope.clone(),
459 sources: request.sources.clone(),
460 facts: collector.facts,
461 limits: ExtractionEvidenceLimits {
462 truncated: collector.truncated,
463 omitted_facts: collector.omitted_facts,
464 text_bytes: collector.text_bytes,
465 missing_sources: missing_source_values,
466 },
467 coverage,
468 };
469 trim_to_output_budget(&mut evidence, request.budgets.max_output_bytes)?;
470 Ok(evidence)
471}
472
473struct EvidenceCollector {
474 budgets: ExtractionBudgets,
475 facts: Vec<EvidenceFact>,
476 omitted_facts: u32,
477 text_bytes: u32,
478 truncated: bool,
479}
480
481impl EvidenceCollector {
482 fn new(budgets: ExtractionBudgets) -> Self {
483 Self {
484 budgets,
485 facts: Vec::new(),
486 omitted_facts: 0,
487 text_bytes: 0,
488 truncated: false,
489 }
490 }
491
492 fn allow_depth(&mut self, depth: u16) -> bool {
493 if depth >= self.budgets.max_depth {
494 self.omitted_facts = self.omitted_facts.saturating_add(1);
495 self.truncated = true;
496 false
497 } else {
498 true
499 }
500 }
501
502 fn push(&mut self, mut fact: EvidenceFact) {
503 if self.facts.len() as u32 >= self.budgets.max_nodes {
504 self.omitted_facts = self.omitted_facts.saturating_add(1);
505 self.truncated = true;
506 return;
507 }
508 fact.role = self.bound_text(fact.role.take());
509 fact.name = self.bound_text(fact.name.take());
510 fact.input_type = self.bound_text(fact.input_type.take());
511 self.facts.push(fact);
512 }
513
514 fn bound_text(&mut self, value: Option<String>) -> Option<String> {
515 let value = value?;
516 let remaining = self.budgets.max_text_bytes.saturating_sub(self.text_bytes) as usize;
517 if remaining == 0 {
518 self.truncated = true;
519 return None;
520 }
521 let bounded = truncate_utf8(&value, remaining);
522
523 self.text_bytes = self.text_bytes.saturating_add(bounded.len() as u32);
524 if bounded.len() < value.len() {
525 self.truncated = true;
526 }
527 Some(bounded)
528 }
529}
530fn evidence_coverage(
531 context: &PageContext,
532 sources: &[EvidenceSource],
533 missing_sources: &BTreeSet<EvidenceSource>,
534 truncated: bool,
535) -> EvidenceCoverage {
536 let requested = |source| sources.contains(&source);
537 let available = |source| requested(source) && !missing_sources.contains(&source);
538 let structural = if available(EvidenceSource::Dom) && !truncated {
539 EvidenceQuality::Strong
540 } else if available(EvidenceSource::Dom) || available(EvidenceSource::Accessibility) {
541 EvidenceQuality::Partial
542 } else {
543 EvidenceQuality::Opaque
544 };
545 let semantic = if available(EvidenceSource::Accessibility) && !truncated {
546 EvidenceQuality::Strong
547 } else if requested(EvidenceSource::Accessibility) {
548 EvidenceQuality::Partial
549 } else {
550 EvidenceQuality::Opaque
551 };
552 let opaque_regions = (context.boundaries.child_frames
553 + context.boundaries.shadow_roots
554 + context.boundaries.canvases) as u32;
555 let mut reasons = missing_sources
556 .iter()
557 .map(|source| format!("missingSource:{source:?}"))
558 .collect::<Vec<_>>();
559 if truncated {
560 reasons.push("budgetTruncated".into());
561 }
562 if context.boundaries.child_frames > 0 {
563 reasons.push("frameBoundary".into());
564 }
565 if context.boundaries.shadow_roots > 0 {
566 reasons.push("shadowBoundary".into());
567 }
568 if context.boundaries.canvases > 0 {
569 reasons.push("canvasBoundary".into());
570 }
571 reasons.truncate(16);
572 EvidenceCoverage {
573 structural,
574 semantic,
575 interactive_entities_observed: context.accessibility.interactive.len() as u32,
576 opaque_regions,
577 reasons,
578 }
579}
580
581fn is_form_control_role(role: &str) -> bool {
582 matches!(
583 role,
584 "checkbox"
585 | "combobox"
586 | "listbox"
587 | "radio"
588 | "slider"
589 | "spinbutton"
590 | "switch"
591 | "textbox"
592 )
593}
594
595fn custom_form_control_hint(
596 control_role: &str,
597 parent_role: Option<&str>,
598) -> Option<EvidenceRelationshipHint> {
599 if parent_role.is_some_and(|role| role.eq_ignore_ascii_case("form"))
600 && matches!(
601 control_role,
602 "checkbox" | "combobox" | "listbox" | "radio" | "slider" | "spinbutton" | "switch"
603 )
604 {
605 Some(EvidenceRelationshipHint::Controls)
606 } else {
607 None
608 }
609}
610fn is_region_role(role: &str) -> bool {
611 matches!(
612 role,
613 "article" | "complementary" | "main" | "navigation" | "region" | "search" | "toolbar"
614 )
615}
616
617fn collect_accessibility(
618 node: &CompactAxNode,
619 collector: &mut EvidenceCollector,
620 depth: u16,
621 parent_role: Option<&str>,
622) {
623 if !collector.allow_depth(depth) {
624 return;
625 }
626 collector.push(EvidenceFact {
627 source: EvidenceSource::Accessibility,
628 kind: "node".into(),
629 quality: EvidenceQuality::Confirmed,
630 role: Some(node.role.clone()),
631 name: Some(node.name.clone()),
632 input_type: None,
633 required: None,
634 read_only: None,
635 empty: None,
636 geometry_present: None,
637 parent_role: parent_role.map(str::to_owned),
638 relationship_hint: None,
639 });
640 let next_parent_role = if is_region_role(&node.role) {
641 Some(node.role.as_str())
642 } else {
643 parent_role
644 };
645 for child in &node.children {
646 collect_accessibility(child, collector, depth.saturating_add(1), next_parent_role);
647 }
648}
649
650fn collect_dom(node: &DomNode, collector: &mut EvidenceCollector, depth: u16) {
651 if !collector.allow_depth(depth) {
652 return;
653 }
654 collector.push(EvidenceFact {
655 source: EvidenceSource::Dom,
656 kind: "element".into(),
657 quality: EvidenceQuality::Confirmed,
658 role: None,
659 name: Some(node.node_name.clone()),
660 input_type: None,
661 required: None,
662 read_only: None,
663 empty: None,
664 geometry_present: None,
665 parent_role: None,
666 relationship_hint: None,
667 });
668 for child in &node.children {
669 collect_dom(child, collector, depth.saturating_add(1));
670 }
671}
672
673fn collect_layout(
674 node: &DomNode,
675 collector: &mut EvidenceCollector,
676 source: EvidenceSource,
677 depth: u16,
678) {
679 if !collector.allow_depth(depth) {
680 return;
681 }
682 collector.push(EvidenceFact {
683 source,
684 kind: "geometry".into(),
685 quality: EvidenceQuality::Strong,
686 role: None,
687 name: Some(node.node_name.clone()),
688 input_type: None,
689 required: None,
690 read_only: None,
691 empty: None,
692 geometry_present: Some(node.bounding_box.is_some()),
693 parent_role: None,
694 relationship_hint: None,
695 });
696 for child in &node.children {
697 collect_layout(child, collector, source, depth.saturating_add(1));
698 }
699}
700
701fn trim_to_output_budget(
702 evidence: &mut ExtractionEvidence,
703 max_output_bytes: u32,
704) -> Result<(), ExtractionContractError> {
705 while serde_json::to_vec(evidence)
706 .map_err(|error| ExtractionContractError::new("$", error.to_string()))?
707 .len()
708 > max_output_bytes as usize
709 {
710 if evidence.facts.pop().is_none() {
711 return Err(ExtractionContractError::new(
712 "budgets.maxOutputBytes",
713 "output budget is too small for extraction metadata",
714 ));
715 }
716 evidence.limits.omitted_facts = evidence.limits.omitted_facts.saturating_add(1);
717 evidence.limits.truncated = true;
718 }
719 Ok(())
720}
721
722fn truncate_utf8(value: &str, max_bytes: usize) -> String {
723 if value.len() <= max_bytes {
724 return value.to_owned();
725 }
726 value
727 .char_indices()
728 .take_while(|(index, _)| *index < max_bytes)
729 .map(|(_, character)| character)
730 .collect()
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
735#[serde(rename_all = "camelCase")]
736pub struct ExtractionContractError {
737 pub path: String,
738 pub reason: String,
739}
740
741impl ExtractionContractError {
742 fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
743 Self {
744 path: path.into(),
745 reason: reason.into(),
746 }
747 }
748}
749
750impl Display for ExtractionContractError {
751 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
752 write!(formatter, "{}: {}", self.path, self.reason)
753 }
754}
755
756impl std::error::Error for ExtractionContractError {}
757
758fn validate_identifier(path: &str, value: &str) -> Result<(), ExtractionContractError> {
759 if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
760 return Err(ExtractionContractError::new(
761 path,
762 "identifier must contain 1-256 non-control characters",
763 ));
764 }
765 Ok(())
766}
767
768fn validate_positive_bounded<T>(
769 path: &str,
770 value: T,
771 maximum: T,
772) -> Result<(), ExtractionContractError>
773where
774 T: Copy + PartialOrd + From<u8>,
775{
776 if value < T::from(1) || value > maximum {
777 return Err(ExtractionContractError::new(
778 path,
779 "value must be positive and within the extraction contract bound",
780 ));
781 }
782 Ok(())
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use serde_json::json;
789
790 fn request() -> ExtractionRequest {
791 ExtractionRequest {
792 schema_version: EXTRACTION_CONTRACT_SCHEMA_VERSION,
793 scope: ExtractionScope::Document,
794 sources: vec![EvidenceSource::Accessibility, EvidenceSource::Forms],
795 budgets: ExtractionBudgets::default(),
796 }
797 }
798
799 #[test]
800 fn request_accepts_bounded_non_mutating_sources() {
801 let request = request();
802 assert!(request.validate().is_ok());
803 assert_eq!(
804 request.to_canonical_json().unwrap(),
805 request.to_canonical_json().unwrap()
806 );
807 }
808
809 #[test]
810 fn request_rejects_duplicate_sources() {
811 let mut request = request();
812 request.sources.push(EvidenceSource::Forms);
813 let error = request.validate().unwrap_err();
814 assert_eq!(error.path, "sources");
815 }
816
817 #[test]
818 fn request_rejects_budget_overflow_and_zero() {
819 let mut request = request();
820 request.budgets.max_nodes = 0;
821 assert_eq!(request.validate().unwrap_err().path, "budgets.maxNodes");
822 request.budgets.max_nodes = MAX_EXTRACTION_NODES + 1;
823 assert_eq!(request.validate().unwrap_err().path, "budgets.maxNodes");
824 }
825
826 #[test]
827 fn request_rejects_unknown_authored_fields() {
828 let value = json!({
829 "schemaVersion": 1,
830 "scope": "document",
831 "sources": ["accessibility"],
832 "budgets": ExtractionBudgets::default(),
833 "futureField": true
834 });
835 assert!(ExtractionRequest::from_json(&value.to_string()).is_err());
836 }
837
838 #[test]
839 fn scoped_requests_require_bounded_identifiers() {
840 let mut request = request();
841 request.scope = ExtractionScope::Region {
842 region_id: "".into(),
843 };
844 assert_eq!(request.validate().unwrap_err().path, "scope.regionId");
845 }
846 fn page_context() -> PageContext {
847 use crate::browser::dom::{CompactAxNode, CompactInteractiveElement, DomNode};
848 use crate::browser::session::{
849 CompactAccessibilitySnapshot, ObservationBoundarySummary, ObservationConsistency,
850 PageInfo,
851 };
852
853 let page = PageInfo {
854 url: "https://example.test/form".into(),
855 title: "Example form".into(),
856 ready_state: "interactive".into(),
857 target_id: "target-1".into(),
858 frame_id: "frame-1".into(),
859 };
860 let accessibility = CompactAccessibilitySnapshot {
861 page: page.clone(),
862 revision: 7,
863 roots: vec![CompactAxNode {
864 role: "form".into(),
865 name: "Example form".into(),
866 children: vec![CompactAxNode {
867 role: "textbox".into(),
868 name: "Full name".into(),
869 children: Vec::new(),
870 interactive: true,
871 }],
872 interactive: false,
873 }],
874 interactive: vec![CompactInteractiveElement {
875 reference: "axr-1-1".into(),
876 role: "textbox".into(),
877 name: "Full name".into(),
878 backend_dom_node_id: 11,
879 ancestor_path: Vec::new(),
880 shadow_host_path: None,
881 input_type: Some("text".into()),
882 value: Some("secret-value".into()),
883 checked: None,
884 selected_option: None,
885 empty: true,
886 read_only: false,
887 required: true,
888 }],
889 truncated: false,
890 omitted_count: 0,
891 ranking_applied: false,
892 completeness: None,
893 };
894 PageContext {
895 page,
896 text: "secret-value".into(),
897 dom: Some(DomNode {
898 node_id: 1,
899 node_name: "HTML".into(),
900 node_value: String::new(),
901 children: vec![DomNode {
902 node_id: 2,
903 node_name: "INPUT".into(),
904 node_value: String::new(),
905 children: Vec::new(),
906 attributes: vec!["type".into(), "text".into()],
907 bounding_box: Some([0.0, 0.0, 120.0, 24.0]),
908 }],
909 attributes: Vec::new(),
910 bounding_box: Some([0.0, 0.0, 800.0, 600.0]),
911 }),
912 accessibility,
913 consistency: ObservationConsistency {
914 consistent: true,
915 attempts: 1,
916 start_revision: 7,
917 end_revision: 7,
918 start_mutation_revision: 0,
919 end_mutation_revision: 0,
920 },
921 boundaries: ObservationBoundarySummary::default(),
922 incomplete: Vec::new(),
923 screenshot: None,
924 }
925 }
926
927 #[test]
928 fn extraction_collects_supported_sources_and_reports_missing_sources() {
929 let mut request = request();
930 request.sources = vec![
931 EvidenceSource::Accessibility,
932 EvidenceSource::Dom,
933 EvidenceSource::Forms,
934 EvidenceSource::Layout,
935 EvidenceSource::Navigation,
936 ];
937 let evidence = extract_page_context(&page_context(), &request).unwrap();
938 assert_eq!(evidence.revision, 7);
939 assert!(!evidence.facts.is_empty());
940 assert_eq!(evidence.coverage.structural, EvidenceQuality::Strong);
941 assert_eq!(evidence.coverage.semantic, EvidenceQuality::Strong);
942 assert_eq!(
943 evidence.facts.first().map(|fact| fact.quality),
944 Some(EvidenceQuality::Confirmed)
945 );
946 assert!(
947 evidence
948 .coverage
949 .reasons
950 .iter()
951 .any(|reason| reason == "missingSource:Navigation")
952 );
953 assert_eq!(
954 evidence.limits.missing_sources,
955 vec![EvidenceSource::Navigation]
956 );
957 assert!(
958 !serde_json::to_string(&evidence)
959 .unwrap()
960 .contains("secret-value")
961 );
962 }
963 #[test]
964 fn extraction_emits_controls_hint_for_custom_form_roles() {
965 let mut plain_request = request();
966 plain_request.sources = vec![EvidenceSource::Forms];
967 let plain_evidence = extract_page_context(&page_context(), &plain_request).unwrap();
968 assert!(
969 plain_evidence
970 .facts
971 .iter()
972 .all(|fact| fact.relationship_hint.is_none())
973 );
974
975 let mut context = page_context();
976 context.accessibility.interactive[0].role = "combobox".into();
977 context.accessibility.interactive[0].ancestor_path = vec!["form:Example form".into()];
978 let mut request = request();
979 request.sources = vec![EvidenceSource::Forms];
980 let evidence = extract_page_context(&context, &request).unwrap();
981 let fact = evidence
982 .facts
983 .iter()
984 .find(|fact| fact.source == EvidenceSource::Forms)
985 .expect("custom form control should produce a form fact");
986 assert_eq!(fact.parent_role.as_deref(), Some("form"));
987 assert_eq!(
988 fact.relationship_hint,
989 Some(EvidenceRelationshipHint::Controls)
990 );
991 }
992
993 #[test]
994 fn extraction_emits_controls_hints_for_extended_custom_form_roles() {
995 for role in ["listbox", "slider", "spinbutton", "switch"] {
996 let mut context = page_context();
997 context.accessibility.interactive[0].role = role.into();
998 context.accessibility.interactive[0].ancestor_path = vec!["form:Example form".into()];
999 let mut request = request();
1000 request.sources = vec![EvidenceSource::Forms];
1001 let evidence = extract_page_context(&context, &request).unwrap();
1002 let fact = evidence
1003 .facts
1004 .iter()
1005 .find(|fact| fact.source == EvidenceSource::Forms)
1006 .expect("extended custom form control should produce a form fact");
1007 assert_eq!(
1008 fact.relationship_hint,
1009 Some(EvidenceRelationshipHint::Controls)
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn extracted_custom_control_hint_reconciles_to_controls_edge() {
1016 let mut context = page_context();
1017 context.accessibility.interactive[0].role = "combobox".into();
1018 context.accessibility.interactive[0].ancestor_path = vec!["form:Example form".into()];
1019 let evidence = extract_page_context(&context, &request()).unwrap();
1020 let draft = crate::web_ir::reconcile_evidence(&evidence).unwrap();
1021 assert_eq!(draft.relationship_hint_diagnostics.len(), 1);
1022 assert_eq!(
1023 draft.relationship_hint_diagnostics[0].status,
1024 crate::web_ir::RelationshipHintDiagnosticStatus::Emitted
1025 );
1026 assert!(draft.relationships.iter().any(
1027 |relationship| relationship.kind == crate::web_ir::DraftRelationshipKind::Controls
1028 ));
1029 }
1030
1031 #[test]
1032 fn extraction_enforces_node_and_depth_budgets() {
1033 let mut request = request();
1034 request.sources = vec![EvidenceSource::Dom];
1035 request.budgets.max_nodes = 1;
1036 request.budgets.max_depth = 1;
1037 let evidence = extract_page_context(&page_context(), &request).unwrap();
1038 assert_eq!(evidence.facts.len(), 1);
1039 assert!(evidence.limits.truncated);
1040 assert!(evidence.limits.omitted_facts > 0);
1041 }
1042
1043 #[test]
1044 fn extraction_rejects_unbound_scopes_until_scoped_observation_exists() {
1045 let mut request = request();
1046 request.scope = ExtractionScope::Region {
1047 region_id: "region-main".into(),
1048 };
1049 let error = extract_page_context(&page_context(), &request).unwrap_err();
1050 assert_eq!(error.path, "scope");
1051 }
1052 #[test]
1053 fn extraction_reports_opaque_boundaries_without_claiming_completeness() {
1054 let mut context = page_context();
1055 context.boundaries.child_frames = 1;
1056 let evidence = extract_page_context(&context, &request()).unwrap();
1057 assert_eq!(evidence.coverage.opaque_regions, 1);
1058 assert!(
1059 evidence
1060 .coverage
1061 .reasons
1062 .iter()
1063 .any(|reason| reason == "frameBoundary")
1064 );
1065 assert_ne!(evidence.coverage.semantic, EvidenceQuality::Opaque);
1066 }
1067}