x
").expect("document input"); parser.finish().expect("document finish"); - parser.inject_patch_for_conformance_test(crate::DomPatch::AppendChild { - parent: crate::PatchKey(u32::MAX - 1), - child: crate::PatchKey(u32::MAX), - }); + parser + .inject_patch_for_conformance_test(crate::DomPatch::AppendChild { + parent: crate::PatchKey(u32::MAX - 1), + child: crate::PatchKey(u32::MAX), + }) + .expect("unobserved injected patch"); assert_eq!( - finalize_document_parser(parser, true), + finalize_document_parser( + parser, + true, + ObservationRequest::NotRequested, + ObservationRequest::NotRequested, + ), Err(ParserObservationExecutionError::ParserInvariant) ); } + #[test] + fn materialization_failure_returns_execution_failure_without_canonical_output() { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(64), + ) + .unwrap(); + parser.push_str("x
").unwrap(); + parser.finish().unwrap(); + let _ = parser.take_patches().unwrap(); + parser.force_materialization_failure_for_test(); + let error = parser.into_output_with_observations().unwrap_err(); + assert_eq!( + parser_error_without_live_parser(error), + ParserObservationExecutionError::ParserInvariant + ); + } + + #[test] + fn requested_patch_observation_without_session_capture_is_an_execution_failure() { + let mut parser = HtmlParser::new_with_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + ) + .unwrap(); + parser.push_str("x
").unwrap(); + parser.finish().unwrap(); + assert_eq!( + finalize_document_parser( + parser, + false, + ObservationRequest::NotRequested, + ObservationRequest::Capture { capacity: 64 }, + ), + Err(ParserObservationExecutionError::PatchHistoryCaptureMissing) + ); + } + + #[test] + fn post_parse_projection_allocation_failure_suppresses_canonical_result() { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::default(), + ) + .unwrap(); + parser.push_str("x
").unwrap(); + parser.finish().unwrap(); + let mut allocations = + ObservationAllocationController::with_failure(ObservationFailureInjection { + step: ObservationAllocationStep::CanonicalTreeChildStorage, + occurrence: NonZeroU64::MIN, + }); + assert_eq!( + finalize_document_parser_with_allocations( + parser, + false, + ObservationRequest::Capture { capacity: 16 }, + ObservationRequest::NotRequested, + &mut allocations, + ), + Err(ParserObservationExecutionError::ResourceExhaustion( + ObservationResourceExhaustion::at( + ObservationReservationSite::CanonicalTreeProjection + ) + )) + ); + } + + #[test] + fn live_patch_history_invariant_is_stable_fatal_but_exact_for_conformance() { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(0), + ) + .expect("parser"); + parser.force_patch_history_dropped_for_test(u64::MAX); + let stable = parser + .inject_patch_for_conformance_test(crate::DomPatch::Clear) + .unwrap_err(); + assert_eq!( + stable, + crate::HtmlParseError::Fatal(crate::ParserFatalError::EngineInvariant) + ); + assert_eq!( + document_parser_operation_error(&parser, stable), + ParserObservationExecutionError::ObservationInvariant( + ParserObservationInvariantError::PatchDroppedCountOverflow + ) + ); + assert_eq!( + parser.take_patches(), + Err(crate::HtmlParseError::Fatal( + crate::ParserFatalError::EngineInvariant + )) + ); + assert_eq!( + parser.document_mode_for_conformance(), + Err(crate::HtmlParseError::Fatal( + crate::ParserFatalError::EngineInvariant + )) + ); + assert_eq!( + parser.take_observations_for_conformance(), + Err(crate::HtmlParseError::Fatal( + crate::ParserFatalError::EngineInvariant + )) + ); + } + + #[cfg(feature = "parser-failure-injection")] + #[test] + fn live_patch_history_resource_failure_keeps_exact_parser_fatal_identity() { + use crate::html5::shared::ParserFailureInjection; + + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(8), + ) + .expect("parser"); + parser.set_patch_history_failure_injection_for_test(ParserFailureInjection::new( + crate::ParserReservationSite::PatchHistoryObservationStorage, + NonZeroU64::MIN, + )); + let exhaustion = crate::ParserResourceExhaustion::at( + crate::ParserReservationSite::PatchHistoryObservationStorage, + ); + let fatal = crate::ParserFatalError::ResourceExhaustion(exhaustion); + assert_eq!( + parser.inject_patch_for_conformance_test(crate::DomPatch::Clear), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert_eq!( + parser.take_patches(), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert_eq!( + document_parser_operation_error(&parser, crate::HtmlParseError::Fatal(fatal)), + ParserObservationExecutionError::ParserFatal(fatal) + ); + } + + #[cfg(feature = "parser-failure-injection")] + #[test] + fn live_capture_failure_stops_before_next_token_and_blocks_all_output() { + use crate::html5::shared::ParserFailureInjection; + + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(128), + ) + .unwrap(); + parser.set_patch_history_failure_injection_for_test(ParserFailureInjection::new( + crate::ParserReservationSite::PatchHistoryObservationStorage, + NonZeroU64::MIN, + )); + parser.push_str("never
"), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert_eq!( + parser.take_patch_batch(), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert!(matches!( + parser.into_output_with_observations(), + Err(crate::HtmlParseError::Fatal(error)) if error == fatal + )); + } + #[test] fn observation_invariants_are_typed_execution_failures() { let mut capture = empty_capture(); @@ -849,7 +1262,12 @@ mod tests { ObservationOccurrenceSequence::ParseErrors, )); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::ParseErrorOccurrenceOverflow )) @@ -858,7 +1276,12 @@ mod tests { let mut capture = empty_capture(); capture.invariant = Some(ParserObservationInvariant::InvalidNormalizedPositionOffset); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::InvalidNormalizedPositionOffset )) @@ -867,7 +1290,12 @@ mod tests { let mut capture = empty_capture(); capture.invariant = Some(ParserObservationInvariant::NormalizedPositionIndexMissing); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::NormalizedPositionIndexMissing )) @@ -898,7 +1326,12 @@ mod tests { "an invalid normalized offset must not retain a false unavailable-position event" ); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::InvalidNormalizedPositionOffset )) @@ -930,7 +1363,12 @@ mod tests { "missing-index corruption must not retain a false unavailable event" ); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::NormalizedPositionIndexMissing )) @@ -1043,6 +1481,8 @@ mod tests { parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::Capture { capacity: 1 }, document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .unwrap(); let ObservationState::Incomplete { partial, reason } = result.implementation_diagnostics @@ -1449,6 +1889,8 @@ mod tests { parse_errors: ObservationRequest::Capture { capacity: 8 }, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("document parser observation"); assert!(captured(&text_mode.parse_errors).iter().any(|event| { @@ -2860,6 +3302,8 @@ mod tests { capacity: diagnostic_capacity, }, document_mode, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("document observation should complete") } @@ -3153,6 +3597,8 @@ mod tests { parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: ScalarObservationRequest::Capture, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("scalar-only production execution"); assert_eq!(scalar_only.document_mode, result.document_mode); @@ -3168,6 +3614,8 @@ mod tests { parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: ScalarObservationRequest::Capture, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("standalone tokenizer execution"); assert_eq!( @@ -3177,4 +3625,421 @@ mod tests { } ); } + + #[test] + fn canonical_document_tree_preserves_production_payloads_and_namespaces() { + let result = execute_parser_observation(ParserObservationRequest { + target: ParserObservationTarget::DocumentParser, + input: ParserObservationInput::Utf8( + "\ + \ + outer\ + inner", + ), + tokens: ObservationRequest::NotRequested, + parse_errors: ObservationRequest::NotRequested, + implementation_diagnostics: ObservationRequest::NotRequested, + document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::Capture { capacity: 256 }, + patches: ObservationRequest::Capture { capacity: 512 }, + }) + .expect("canonical document observation"); + assert!(matches!(result.tokens, ObservationState::NotRequested)); + assert!(matches!( + result.parse_errors, + ObservationState::NotRequested + )); + assert!(matches!( + result.implementation_diagnostics, + ObservationState::NotRequested + )); + let ObservationState::Captured(tree) = result.tree else { + panic!("tree must be complete"); + }; + let [ObservedTreeNode::Document { children }] = tree.roots.as_slice() else { + panic!("document must remain the sole canonical root"); + }; + assert!(matches!( + &children[0], + ObservedTreeNode::DocumentType { + name: Some(name), + public_id: Some(public_id), + system_id: Some(system_id), + } if name == "html" && public_id == "pub" && system_id == "sys" + )); + assert!(children.iter().any(|node| matches!( + node, + ObservedTreeNode::ProcessingInstruction { target, data } + if target == "pi" && data == "data" + ))); + + let html = children + .iter() + .find(|node| { + matches!( + node, + ObservedTreeNode::Element { + namespace: crate::ElementNamespace::Html, + local_name, + .. + } if local_name == "html" + ) + }) + .expect("html element"); + let mut stack = vec![html]; + let mut saw_comment = false; + let mut saw_svg = false; + let mut saw_math = false; + let mut template_depths = Vec::new(); + while let Some(node) = stack.pop() { + match node { + ObservedTreeNode::Comment { data } => saw_comment |= data == "comment", + ObservedTreeNode::Text { .. } + | ObservedTreeNode::DocumentType { .. } + | ObservedTreeNode::ProcessingInstruction { .. } => {} + ObservedTreeNode::Document { children } + | ObservedTreeNode::Element { children, .. } => { + if let ObservedTreeNode::Element { + namespace, + local_name, + attributes, + .. + } = node + { + if *namespace == crate::ElementNamespace::Svg && local_name == "svg" { + saw_svg = attributes.iter().any(|attribute| { + attribute.namespace == crate::AttributeNamespace::XLink + && attribute.prefix.as_deref() == Some("xlink") + && attribute.local_name == "href" + && attribute.value == "#x" + }) && attributes + .first() + .is_some_and(|attribute| attribute.local_name == "viewBox"); + } + saw_math |= + *namespace == crate::ElementNamespace::MathMl && local_name == "math"; + } + stack.extend(children.iter().rev()); + } + ObservedTreeNode::HtmlTemplateElement { + ordinary_children, + contents, + .. + } => { + template_depths.push(contents.children.len()); + stack.extend(ordinary_children.iter().rev()); + stack.extend(contents.children.iter().rev()); + } + } + } + assert!(saw_comment && saw_svg && saw_math); + assert_eq!( + template_depths.len(), + 2, + "nested template contents retained" + ); + + let ObservationState::Captured(patches) = result.patches else { + panic!("patches must be complete"); + }; + assert!(!patches.operations.is_empty()); + assert!( + patches + .operations + .iter() + .all(|operation| { !format!("{operation:?}").contains("PatchKey") }) + ); + } + + #[test] + fn tree_and_patch_only_sessions_do_not_enable_diagnostic_observation() { + for patch_config in [ + PatchHistoryObservationConfig::default(), + PatchHistoryObservationConfig::capture(128), + ] { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + patch_config, + ) + .expect("parser"); + assert!(!parser.diagnostic_observation_enabled_for_test()); + assert_eq!(parser.take_observations_for_conformance().unwrap(), None); + parser.push_str("x
").unwrap(); + parser.finish().unwrap(); + assert!(!parser.diagnostic_observation_enabled_for_test()); + let (output, diagnostics, _) = parser.into_output_with_observations().unwrap(); + assert_eq!(diagnostics, None); + + let ordinary = crate::parse_document("x
", HtmlParseOptions::default()).unwrap(); + assert_eq!(output.patches, ordinary.patches); + assert_eq!(output.counters, ordinary.counters); + } + } + + #[derive(Clone, Copy)] + enum PatchDrainSchedule { + WholeInput, + Chunked, + TakePatches, + TakePatchBatch, + } + + fn captured_raw_patches(schedule: PatchDrainSchedule) -> crate::html5::RawPatchHistoryCapture { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(512), + ) + .unwrap(); + let chunks: &[&str] = match schedule { + PatchDrainSchedule::WholeInput => &["x
"), + tokens: ObservationRequest::NotRequested, + parse_errors: ObservationRequest::NotRequested, + implementation_diagnostics: ObservationRequest::NotRequested, + document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::Capture { capacity }, + }) + .unwrap() + .patches + } + + #[test] + fn patch_capacity_zero_exact_and_one_below_keep_semantic_prefixes() { + let ObservationState::Captured(complete) = observe_patch_capacity(256) else { + panic!("large capacity"); + }; + let required = complete.operations.len(); + assert!(required > 1); + assert_eq!( + observe_patch_capacity(required), + ObservationState::Captured(complete.clone()) + ); + + let ObservationState::Incomplete { partial, reason } = observe_patch_capacity(required - 1) + else { + panic!("one below must be incomplete"); + }; + assert_eq!(partial.operations, complete.operations[..required - 1]); + assert_eq!( + reason, + IncompleteObservationReason::StorageLimitExceeded { + retained: required - 1, + dropped: 1, + } + ); + + let ObservationState::Incomplete { partial, reason } = observe_patch_capacity(0) else { + panic!("zero capacity must be incomplete"); + }; + assert!(partial.operations.is_empty()); + assert_eq!( + reason, + IncompleteObservationReason::StorageLimitExceeded { + retained: 0, + dropped: required as u64, + } + ); + } + + #[test] + fn standalone_tree_and_patch_requests_are_not_applicable_without_diagnostics() { + let result = execute_parser_observation(ParserObservationRequest { + target: ParserObservationTarget::StandaloneTokenizer, + input: ParserObservationInput::Utf8("x"), + tokens: ObservationRequest::NotRequested, + parse_errors: ObservationRequest::NotRequested, + implementation_diagnostics: ObservationRequest::NotRequested, + document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::Capture { capacity: 32 }, + patches: ObservationRequest::Capture { capacity: 32 }, + }) + .unwrap(); + assert!(matches!(result.tokens, ObservationState::NotRequested)); + assert!(matches!( + result.tree, + ObservationState::NotApplicable { + reason: NotApplicableReason::StandaloneTokenizerRun + } + )); + assert!(matches!( + result.patches, + ObservationState::NotApplicable { + reason: NotApplicableReason::StandaloneTokenizerRun + } + )); + } + + #[test] + fn integrated_parser_depth_within_materialization_limit_projects_successfully() { + let depth = 900; + let mut source = String::new(); + source.try_reserve(depth * 11).unwrap(); + for _ in 0..depth { + source.push_str("