diff --git a/crates/html/src/conformance/execution.rs b/crates/html/src/conformance/execution.rs index fa7cc3ba..6aa7b26f 100644 --- a/crates/html/src/conformance/execution.rs +++ b/crates/html/src/conformance/execution.rs @@ -26,6 +26,167 @@ pub enum ObservationRequest { }, } +#[cfg(test)] +mod execution_identity_tests { + use super::*; + + #[test] + fn every_closed_parser_observation_error_identity_is_preserved_without_text_classification() { + for (error, identity) in [ + ( + ParserObservationExecutionError::ParserInvariant, + ParserObservationExecutionIdentity::ParserInvariant, + ), + ( + ParserObservationExecutionError::TokenCanonicalizationInvariant, + ParserObservationExecutionIdentity::TokenCanonicalizationInvariant, + ), + ( + ParserObservationExecutionError::TreeTransitionTokenCanonicalizationInvariant, + ParserObservationExecutionIdentity::TreeTransitionTokenCanonicalizationInvariant, + ), + ( + ParserObservationExecutionError::ObservationRecorderMissing, + ParserObservationExecutionIdentity::ObservationRecorderMissing, + ), + ( + ParserObservationExecutionError::PatchHistoryCaptureMissing, + ParserObservationExecutionIdentity::PatchHistoryCaptureMissing, + ), + ] { + assert_eq!(error.identity(), identity); + } + + assert_eq!( + ParserObservationExecutionError::ParserFatal(crate::ParserFatalError::EngineInvariant) + .identity(), + ParserObservationExecutionIdentity::ParserFatal(ParserFatalIdentity::EngineInvariant), + ); + for (site, identity) in [ + ( + crate::ParserReservationSite::KnownTagAtomStorage, + ParserReservationSiteIdentity::KnownTagAtomStorage, + ), + ( + crate::ParserReservationSite::KnownTagLookupStorage, + ParserReservationSiteIdentity::KnownTagLookupStorage, + ), + ( + crate::ParserReservationSite::TemplateChildStorage, + ParserReservationSiteIdentity::TemplateChildStorage, + ), + ( + crate::ParserReservationSite::PatchHistoryObservationStorage, + ParserReservationSiteIdentity::PatchHistoryObservationStorage, + ), + ] { + let error = crate::ParserResourceExhaustion::at(site); + assert_eq!( + ParserObservationExecutionError::ParserFatal(error.into()).identity(), + ParserObservationExecutionIdentity::ParserFatal( + ParserFatalIdentity::ResourceExhaustion(identity) + ) + ); + } + + for code in [ + ParserTokenizerInvariantError::SelfClosingFlagMissingSolidusPosition, + ParserTokenizerInvariantError::SolidusPositionWithoutPendingTag, + ParserTokenizerInvariantError::SolidusPositionOutsideCurrentPendingTag, + ParserTokenizerInvariantError::SolidusPositionDoesNotReferenceConsumedSlash, + ParserTokenizerInvariantError::DoctypeNameStartMissingForNameState, + ParserTokenizerInvariantError::DoctypeNameStartMissingForTailScan, + ParserTokenizerInvariantError::DoctypeNameStartMissingForResourceObservation, + ParserTokenizerInvariantError::DoctypeNameStartAfterCursor, + ParserTokenizerInvariantError::DoctypeNameRangeInvalid, + ParserTokenizerInvariantError::DoctypeTailRangeInvalid, + ParserTokenizerInvariantError::AsciiPrefixCandidateRangeInvalid, + ParserTokenizerInvariantError::CommentStateMissingPendingStart, + ParserTokenizerInvariantError::CommentPendingRangeInvalid, + ParserTokenizerInvariantError::CommentPendingDelimiterOutsideCurrentRange, + ParserTokenizerInvariantError::CommentPendingDelimiterDoesNotMatchState, + ParserTokenizerInvariantError::TextModeEndTagCandidateRangeInvalid, + ParserTokenizerInvariantError::TextModeEndTagAttributePositionInvalid, + ParserTokenizerInvariantError::TextModeEndTagSolidusPositionInvalid, + ParserTokenizerInvariantError::PendingTextRangeInvalid, + ParserTokenizerInvariantError::CdataStateMissingPendingTextStart, + ParserTokenizerInvariantError::CdataEndDelimiterOutsidePendingTextRange, + ParserTokenizerInvariantError::CdataEndDelimiterDoesNotMatchState, + ParserTokenizerInvariantError::ProcessingInstructionStateMissingPendingMetadata, + ParserTokenizerInvariantError::ProcessingInstructionMetadataOutsideState, + ParserTokenizerInvariantError::ProcessingInstructionTargetRangeInvalid, + ParserTokenizerInvariantError::ProcessingInstructionDataRangeInvalid, + ParserTokenizerInvariantError::ProcessingInstructionTargetStartAfterCursor, + ParserTokenizerInvariantError::ProcessingInstructionDataStartAfterCursor, + ] { + assert_eq!( + ParserObservationExecutionError::TokenizerInvariant(code).identity(), + ParserObservationExecutionIdentity::TokenizerInvariant(code) + ); + } + + for code in [ + UnsupportedFeatureObservationInvariantError::TokenAttributeNameUnavailable, + UnsupportedFeatureObservationInvariantError::ExistingHtmlElementSemanticsUnavailable, + UnsupportedFeatureObservationInvariantError::ExistingBodyElementSemanticsUnavailable, + UnsupportedFeatureObservationInvariantError::ExistingElementIdentityContradiction, + ] { + assert_eq!( + ParserObservationExecutionError::UnsupportedFeatureObservationInvariant(code) + .identity(), + ParserObservationExecutionIdentity::UnsupportedFeatureObservationInvariant(code) + ); + } + + for code in [ + ParserObservationInvariantError::ParseErrorOccurrenceOverflow, + ParserObservationInvariantError::ImplementationDiagnosticOccurrenceOverflow, + ParserObservationInvariantError::TreeTransitionOccurrenceOverflow, + ParserObservationInvariantError::UnsupportedFeatureOccurrenceOverflow, + ParserObservationInvariantError::TokenDroppedCountOverflow, + ParserObservationInvariantError::ParseErrorDroppedCountOverflow, + ParserObservationInvariantError::ImplementationDiagnosticDroppedCountOverflow, + ParserObservationInvariantError::TreeTransitionDroppedCountOverflow, + ParserObservationInvariantError::UnsupportedFeatureDroppedCountOverflow, + ParserObservationInvariantError::NormalizedPositionOverflow, + ParserObservationInvariantError::NormalizedPositionIndexDiscontinuity, + ParserObservationInvariantError::NormalizedPositionIndexMissing, + ParserObservationInvariantError::InvalidNormalizedPositionOffset, + ParserObservationInvariantError::PatchDroppedCountOverflow, + ParserObservationInvariantError::CanonicalTreeUnitCountOverflow, + ParserObservationInvariantError::CanonicalTreeRootNotDocument, + ParserObservationInvariantError::UnexpectedLegacyDocumentDoctypeMetadata, + ParserObservationInvariantError::MissingHtmlTemplateContents, + ParserObservationInvariantError::InvalidTemplateContentsKind, + ParserObservationInvariantError::CanonicalTreeTraversalContradiction, + ParserObservationInvariantError::CanonicalTreePreflightProjectionMismatch, + ParserObservationInvariantError::InvalidPatchKey, + ParserObservationInvariantError::DuplicatePatchCreation, + ParserObservationInvariantError::MissingPatchCreationHistory, + ParserObservationInvariantError::SnapshotLabelSequenceOverflow, + ] { + assert_eq!( + ParserObservationExecutionError::ObservationInvariant(code).identity(), + ParserObservationExecutionIdentity::ObservationInvariant(code) + ); + } + + for site in [ + ObservationReservationSite::CanonicalTreeProjection, + ObservationReservationSite::CanonicalPatchProjection, + ObservationReservationSite::SnapshotLabelStorage, + ] { + assert_eq!( + ParserObservationExecutionError::ResourceExhaustion( + ObservationResourceExhaustion::at(site) + ) + .identity(), + ParserObservationExecutionIdentity::ResourceExhaustion(site) + ); + } + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ScalarObservationRequest { #[default] @@ -82,6 +243,104 @@ pub enum ParserObservationExecutionError { ResourceExhaustion(ObservationResourceExhaustion), } +/// Closed, message-independent identity for fixture disposition matching. +/// +/// `ParserFatalError` and its reservation site are deliberately non-exhaustive +/// at the ordinary parser API boundary. Canonical test support therefore asks +/// the owning HTML subsystem for this feature-gated identity instead of +/// classifying `Display` or `Debug` text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParserObservationExecutionIdentity { + ParserFatal(ParserFatalIdentity), + ParserInvariant, + TokenizerInvariant(ParserTokenizerInvariantError), + TokenCanonicalizationInvariant, + TreeTransitionTokenCanonicalizationInvariant, + UnsupportedFeatureObservationInvariant(UnsupportedFeatureObservationInvariantError), + ObservationRecorderMissing, + PatchHistoryCaptureMissing, + ObservationInvariant(ParserObservationInvariantError), + ResourceExhaustion(ObservationReservationSite), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParserFatalIdentity { + EngineInvariant, + ResourceExhaustion(ParserReservationSiteIdentity), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParserReservationSiteIdentity { + KnownTagAtomStorage, + KnownTagLookupStorage, + TemplateChildStorage, + PatchHistoryObservationStorage, +} + +impl ParserObservationExecutionError { + #[must_use] + pub const fn identity(self) -> ParserObservationExecutionIdentity { + match self { + Self::ParserFatal(error) => { + ParserObservationExecutionIdentity::ParserFatal(parser_fatal_identity(error)) + } + Self::ParserInvariant => ParserObservationExecutionIdentity::ParserInvariant, + Self::TokenizerInvariant(error) => { + ParserObservationExecutionIdentity::TokenizerInvariant(error) + } + Self::TokenCanonicalizationInvariant => { + ParserObservationExecutionIdentity::TokenCanonicalizationInvariant + } + Self::TreeTransitionTokenCanonicalizationInvariant => { + ParserObservationExecutionIdentity::TreeTransitionTokenCanonicalizationInvariant + } + Self::UnsupportedFeatureObservationInvariant(error) => { + ParserObservationExecutionIdentity::UnsupportedFeatureObservationInvariant(error) + } + Self::ObservationRecorderMissing => { + ParserObservationExecutionIdentity::ObservationRecorderMissing + } + Self::PatchHistoryCaptureMissing => { + ParserObservationExecutionIdentity::PatchHistoryCaptureMissing + } + Self::ObservationInvariant(error) => { + ParserObservationExecutionIdentity::ObservationInvariant(error) + } + Self::ResourceExhaustion(error) => { + ParserObservationExecutionIdentity::ResourceExhaustion(error.site()) + } + } + } +} + +const fn parser_fatal_identity(error: crate::ParserFatalError) -> ParserFatalIdentity { + match error { + crate::ParserFatalError::EngineInvariant => ParserFatalIdentity::EngineInvariant, + crate::ParserFatalError::ResourceExhaustion(error) => { + ParserFatalIdentity::ResourceExhaustion(parser_reservation_site_identity(error.site())) + } + } +} + +const fn parser_reservation_site_identity( + site: crate::ParserReservationSite, +) -> ParserReservationSiteIdentity { + match site { + crate::ParserReservationSite::KnownTagAtomStorage => { + ParserReservationSiteIdentity::KnownTagAtomStorage + } + crate::ParserReservationSite::KnownTagLookupStorage => { + ParserReservationSiteIdentity::KnownTagLookupStorage + } + crate::ParserReservationSite::TemplateChildStorage => { + ParserReservationSiteIdentity::TemplateChildStorage + } + crate::ParserReservationSite::PatchHistoryObservationStorage => { + ParserReservationSiteIdentity::PatchHistoryObservationStorage + } + } +} + /// Fallible allocation boundary owned by post-parse canonical observation. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ObservationReservationSite { diff --git a/crates/html/src/conformance/mod.rs b/crates/html/src/conformance/mod.rs index 537fb184..dfad7daa 100644 --- a/crates/html/src/conformance/mod.rs +++ b/crates/html/src/conformance/mod.rs @@ -24,8 +24,9 @@ pub use crate::html5::shared::{ }; pub use execution::{ ObservationRequest, ObservationReservationSite, ObservationResourceExhaustion, - ParserObservationExecutionError, ParserObservationInput, ParserObservationInvariantError, - ParserObservationRequest, ParserObservationTarget, ParserTokenizerInvariantError, + ParserFatalIdentity, ParserObservationExecutionError, ParserObservationExecutionIdentity, + ParserObservationInput, ParserObservationInvariantError, ParserObservationRequest, + ParserObservationTarget, ParserReservationSiteIdentity, ParserTokenizerInvariantError, ScalarObservationRequest, UnsupportedFeatureObservationInvariantError, execute_parser_observation, }; diff --git a/crates/html/tests/fixtures/html5/conformance/README.md b/crates/html/tests/fixtures/html5/conformance/README.md index 850a9fc4..8cd4e09c 100644 --- a/crates/html/tests/fixtures/html5/conformance/README.md +++ b/crates/html/tests/fixtures/html5/conformance/README.md @@ -1,6 +1,6 @@ # Canonical HTML Parser Conformance Fixtures -This is the native fixture root for `borrowser-html-parser-fixture-v1`. Discovery +This is the native fixture root for `borrowser-html-parser-fixture-v2`. Discovery is recursive and sorted by normalized repository-relative bundle path. Add a directory containing `fixture.toml`, exact input, and declared snapshots; no Rust registration is required. @@ -12,7 +12,7 @@ directory containing `fixture.toml` is a leaf; nested bundles are rejected. Native fixtures in this directory must be `source = native` and `disposition.status = active`. Xfail, skip, and expected-unsupported entries belong only to later external/adapted inputs or a separately identified -quarantine source. Fixture-v1 permits skips only for an exact unsupported +quarantine source. Fixture-v2 permits skips only for an exact unsupported capability; broad external-source and environment skips are rejected. Use `input.html` only for valid UTF-8 input whose intended checkout form has LF @@ -21,11 +21,19 @@ byte delivery, and any byte-sensitive case. `input.html` containing a carriage return is rejected. Always update the mandatory SHA-256 from the exact stored bytes; the loader never trims input. -AE13a executes only whole-input standalone-tokenizer fixtures with a declared -`tokens.txt` in `html5-token-v1`. Other fixture-v1 surfaces are declarable but -fail explicitly as unsupported expectations until their owning AE13 slice lands. +AE13b5 executes supported whole-input standalone-tokenizer and document +fixtures from typed canonical observations. Ordinary surfaces are unioned on +the reference delivery; transition expectations may name another declared +whole delivery. Each planned delivery executes once. Unused declared whole +deliveries are capability-checked but do not execute. -See `docs/html5/parser-fixture-format-v1.md` for the complete schema and +Canonical sidecars use the exact AE13b5 formats, including `html5-token-v2`, +`html5-dom-v3`, and `html5-dompatch-v3`. Header-only diagnostic, transition, +unsupported-feature, tree, and patch snapshots represent requested empty +collections. Fixture-v1 remains an isolated compatibility format. + +See `docs/html5/parser-fixture-format-v2.md` and +`docs/html5/ae13b5-parser-snapshot-formats.md` for the schema/codecs, and `docs/html5/ae13-parser-conformance-regression-harness.md` for ownership and slice boundaries. diff --git a/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/fixture.toml b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/fixture.toml new file mode 100644 index 00000000..56abd453 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/fixture.toml @@ -0,0 +1,32 @@ +format = "borrowser-html-parser-fixture-v2" +id = "document-recovery-diagnostics" + +[source] +kind = "native" + +[input] +path = "input.html" +kind = "utf8-text" +sha256 = "f6a8bd0ddf91049b25303c3e04f39f422817603e9a9b4ed051a8d0832294bb8a" + +[execution] +reference_delivery = "whole" + +[execution.target] +kind = "document" +scripting = "disabled" + +[[execution.deliveries]] +name = "whole" +unit = "unicode-scalars" +strategy = "whole" + +[expectations] +parse_errors = "parse-errors.txt" +implementation_diagnostics = "implementation-diagnostics.txt" + +[disposition] +status = "active" + +[metadata] +description = "Duplicate-attribute and non-void self-closing recovery use typed canonical diagnostics." diff --git a/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/implementation-diagnostics.txt b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/implementation-diagnostics.txt new file mode 100644 index 00000000..877e6785 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/implementation-diagnostics.txt @@ -0,0 +1,2 @@ +# format: html5-implementation-diagnostics-v1 +IMPLEMENTATION_DIAGNOSTIC occurrence=1 stage=tree-construction code=tree-construction:non-void-html-self-closing-flag-altered-stack-disposition payload=none position=unavailable:parser-did-not-provide-position context=present context-token=start-tag context-mode=in-body context-namespace=html diff --git a/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/input.html b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/input.html new file mode 100644 index 00000000..9a709df8 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/input.html @@ -0,0 +1 @@ +
diff --git a/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/parse-errors.txt b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/parse-errors.txt new file mode 100644 index 00000000..8ba8621f --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-recovery-diagnostics/parse-errors.txt @@ -0,0 +1,3 @@ +# format: html5-parse-errors-v1 +PARSE_ERROR occurrence=1 stage=tokenizer code=standard:duplicate-attribute recovery=drop-duplicate-attribute position=normalized-utf8:30:1:31:source-unavailable:no-input-provenance-map context=absent context-token=null context-mode=null context-namespace=null +PARSE_ERROR occurrence=2 stage=tree-construction code=tree-construction:unacknowledged-self-closing-flag recovery=null position=unavailable:parser-did-not-provide-position context=present context-token=start-tag context-mode=in-body context-namespace=html diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/document-mode.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/document-mode.txt new file mode 100644 index 00000000..87d01a65 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/document-mode.txt @@ -0,0 +1,2 @@ +# format: html5-document-mode-v1 +MODE value=no-quirks diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/fixture.toml b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/fixture.toml new file mode 100644 index 00000000..6a711924 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/fixture.toml @@ -0,0 +1,46 @@ +format = "borrowser-html-parser-fixture-v2" +id = "document-structured-observations" + +[source] +kind = "native" + +[input] +path = "input.html" +kind = "utf8-text" +sha256 = "0da2862be1c0ca4cfcebe04b6619baebf478aa3009bddbfb32d5347cd7e3a1ae" + +[execution] +reference_delivery = "whole" + +[execution.target] +kind = "document" +scripting = "disabled" + +[[execution.deliveries]] +name = "whole" +unit = "unicode-scalars" +strategy = "whole" + +[[execution.deliveries]] +name = "trace-whole" +unit = "unicode-scalars" +strategy = "whole" + +[expectations] +tokens = "tokens.txt" +parse_errors = "parse-errors.txt" +implementation_diagnostics = "implementation-diagnostics.txt" +document_mode = "document-mode.txt" +tree = "tree.txt" +patches = "patches.txt" +unsupported_features = "unsupported-features.txt" + +[[expectations.transitions]] +delivery = "trace-whole" +path = "transitions.trace-whole.txt" + +[disposition] +status = "active" + +[metadata] +description = "Canonical document observations preserve template contents, foreign namespaces, production patch order, and delivery-specific transitions." diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/implementation-diagnostics.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/implementation-diagnostics.txt new file mode 100644 index 00000000..6d1b1577 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/implementation-diagnostics.txt @@ -0,0 +1 @@ +# format: html5-implementation-diagnostics-v1 diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/input.html b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/input.html new file mode 100644 index 00000000..bb705737 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/input.html @@ -0,0 +1 @@ + diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/parse-errors.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/parse-errors.txt new file mode 100644 index 00000000..c245bdef --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/parse-errors.txt @@ -0,0 +1 @@ +# format: html5-parse-errors-v1 diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/patches.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/patches.txt new file mode 100644 index 00000000..410db372 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/patches.txt @@ -0,0 +1,23 @@ +# format: html5-dompatch-v3 +PATCH operation=1 kind=create-document node="node-1" legacy-doctype=null +PATCH operation=2 kind=create-document-type node="node-2" name="html" public-id=null system-id=null +PATCH operation=3 kind=append-child parent="node-1" child="node-2" +PATCH operation=4 kind=create-element node="node-3" namespace=html local-name="html" +PATCH operation=5 kind=append-child parent="node-1" child="node-3" +PATCH operation=6 kind=create-element node="node-4" namespace=html local-name="head" +PATCH operation=7 kind=append-child parent="node-3" child="node-4" +PATCH operation=8 kind=create-element node="node-5" namespace=html local-name="template" +PATCH_ATTRIBUTE operation=8 index=0 namespace=none prefix=null local-name="id" value="t" +PATCH operation=9 kind=create-template-contents host="node-5" contents="node-6" +PATCH operation=10 kind=append-child parent="node-4" child="node-5" +PATCH operation=11 kind=create-element node="node-7" namespace=svg local-name="svg" +PATCH_ATTRIBUTE operation=11 index=0 namespace=none prefix=null local-name="viewBox" value="0 0 1 1" +PATCH operation=12 kind=append-child parent="node-6" child="node-7" +PATCH operation=13 kind=create-element node="node-8" namespace=svg local-name="title" +PATCH operation=14 kind=append-child parent="node-7" child="node-8" +PATCH operation=15 kind=create-text node="node-9" text="x" +PATCH operation=16 kind=append-child parent="node-8" child="node-9" +PATCH operation=17 kind=create-text node="node-10" text="\n" +PATCH operation=18 kind=append-child parent="node-4" child="node-10" +PATCH operation=19 kind=create-element node="node-11" namespace=html local-name="body" +PATCH operation=20 kind=append-child parent="node-3" child="node-11" diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/tokens.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/tokens.txt new file mode 100644 index 00000000..da3a8d0c --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/tokens.txt @@ -0,0 +1,13 @@ +# format: html5-token-v2 +TOKEN ordinal=1 kind=doctype name="html" public-id=null system-id=null force-quirks=false +TOKEN ordinal=2 kind=start-tag name="template" self-closing=false +TOKEN_ATTRIBUTE token=2 index=0 name="id" value="t" +TOKEN ordinal=3 kind=start-tag name="svg" self-closing=false +TOKEN_ATTRIBUTE token=3 index=0 name="viewbox" value="0 0 1 1" +TOKEN ordinal=4 kind=start-tag name="title" self-closing=false +TOKEN ordinal=5 kind=character data="x" +TOKEN ordinal=6 kind=end-tag name="title" +TOKEN ordinal=7 kind=end-tag name="svg" +TOKEN ordinal=8 kind=end-tag name="template" +TOKEN ordinal=9 kind=character data="\n" +TOKEN ordinal=10 kind=eof diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/transitions.trace-whole.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/transitions.trace-whole.txt new file mode 100644 index 00000000..06126577 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/transitions.trace-whole.txt @@ -0,0 +1,16 @@ +# format: html5-tree-transitions-v1 +TRANSITION occurrence=1 token-kind=doctype token-name=null token-data=null token-self-closing=null mode-before=initial dispatch=html-insertion-mode:initial mode-after=before-html reprocessed=false +TRANSITION occurrence=2 token-kind=start-tag token-name="template" token-data=null token-self-closing=false mode-before=before-html dispatch=html-insertion-mode:before-html mode-after=before-head reprocessed=false +TRANSITION occurrence=3 token-kind=start-tag token-name="template" token-data=null token-self-closing=false mode-before=before-head dispatch=html-insertion-mode:before-head mode-after=in-head reprocessed=true +TRANSITION occurrence=4 token-kind=start-tag token-name="template" token-data=null token-self-closing=false mode-before=in-head dispatch=shared-template-rules mode-after=in-template reprocessed=true +TRANSITION occurrence=5 token-kind=start-tag token-name="svg" token-data=null token-self-closing=false mode-before=in-template dispatch=html-insertion-mode:in-template mode-after=in-body reprocessed=false +TRANSITION occurrence=6 token-kind=start-tag token-name="svg" token-data=null token-self-closing=false mode-before=in-body dispatch=html-insertion-mode:in-body mode-after=in-body reprocessed=true +TRANSITION occurrence=7 token-kind=start-tag token-name="title" token-data=null token-self-closing=false mode-before=in-body dispatch=foreign-content mode-after=in-body reprocessed=false +TRANSITION occurrence=8 token-kind=character token-name=null token-data="x" token-self-closing=null mode-before=in-body dispatch=html-insertion-mode:in-body mode-after=in-body reprocessed=false +TRANSITION occurrence=9 token-kind=end-tag token-name="title" token-data=null token-self-closing=null mode-before=in-body dispatch=foreign-content mode-after=in-body reprocessed=false +TRANSITION occurrence=10 token-kind=end-tag token-name="svg" token-data=null token-self-closing=null mode-before=in-body dispatch=foreign-content mode-after=in-body reprocessed=false +TRANSITION occurrence=11 token-kind=end-tag token-name="template" token-data=null token-self-closing=null mode-before=in-body dispatch=shared-template-rules mode-after=in-head reprocessed=false +TRANSITION occurrence=12 token-kind=character token-name=null token-data="\n" token-self-closing=null mode-before=in-head dispatch=html-insertion-mode:in-head mode-after=in-head reprocessed=false +TRANSITION occurrence=13 token-kind=eof token-name=null token-data=null token-self-closing=null mode-before=in-head dispatch=html-insertion-mode:in-head mode-after=after-head reprocessed=false +TRANSITION occurrence=14 token-kind=eof token-name=null token-data=null token-self-closing=null mode-before=after-head dispatch=html-insertion-mode:after-head mode-after=in-body reprocessed=true +TRANSITION occurrence=15 token-kind=eof token-name=null token-data=null token-self-closing=null mode-before=in-body dispatch=html-insertion-mode:in-body mode-after=in-body reprocessed=true diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/tree.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/tree.txt new file mode 100644 index 00000000..ac88e2cd --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/tree.txt @@ -0,0 +1,14 @@ +# format: html5-dom-v3 +NODE path=/root[0] kind=document +NODE path=/root[0]/child[0] kind=document-type name="html" public-id=null system-id=null +NODE path=/root[0]/child[1] kind=element namespace=html local-name="html" +NODE path=/root[0]/child[1]/child[0] kind=element namespace=html local-name="head" +NODE path=/root[0]/child[1]/child[0]/child[0] kind=html-template-host +ATTRIBUTE path=/root[0]/child[1]/child[0]/child[0] index=0 namespace=none prefix=null local-name="id" value="t" +TEMPLATE_CONTENTS path=/root[0]/child[1]/child[0]/child[0]/contents host=/root[0]/child[1]/child[0]/child[0] +NODE path=/root[0]/child[1]/child[0]/child[0]/contents/child[0] kind=element namespace=svg local-name="svg" +ATTRIBUTE path=/root[0]/child[1]/child[0]/child[0]/contents/child[0] index=0 namespace=none prefix=null local-name="viewBox" value="0 0 1 1" +NODE path=/root[0]/child[1]/child[0]/child[0]/contents/child[0]/child[0] kind=element namespace=svg local-name="title" +NODE path=/root[0]/child[1]/child[0]/child[0]/contents/child[0]/child[0]/child[0] kind=text data="x" +NODE path=/root[0]/child[1]/child[0]/child[1] kind=text data="\n" +NODE path=/root[0]/child[1]/child[1] kind=element namespace=html local-name="body" diff --git a/crates/html/tests/fixtures/html5/conformance/document-structured-observations/unsupported-features.txt b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/unsupported-features.txt new file mode 100644 index 00000000..95d96670 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-structured-observations/unsupported-features.txt @@ -0,0 +1 @@ +# format: html5-unsupported-features-v1 diff --git a/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/fixture.toml b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/fixture.toml new file mode 100644 index 00000000..8c1431f5 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/fixture.toml @@ -0,0 +1,35 @@ +format = "borrowser-html-parser-fixture-v2" +id = "document-unsupported-features" + +[source] +kind = "native" + +[input] +path = "input.html" +kind = "utf8-text" +sha256 = "a5a2563a71c0fea391a581fb1eae5f1355d73104a04a8baae3e633afd419c853" + +[execution] +reference_delivery = "whole" + +[execution.target] +kind = "document" +scripting = "disabled" + +[[execution.deliveries]] +name = "whole" +unit = "unicode-scalars" +strategy = "whole" + +[expectations] +unsupported_features = "unsupported-features.txt" + +[[expectations.transitions]] +delivery = "whole" +path = "transitions.whole.txt" + +[disposition] +status = "active" + +[metadata] +description = "Unsupported-feature identity and transition order come from one unioned production observation." diff --git a/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/input.html b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/input.html new file mode 100644 index 00000000..c92de793 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/input.html @@ -0,0 +1 @@ + diff --git a/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/transitions.whole.txt b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/transitions.whole.txt new file mode 100644 index 00000000..b7bdb9c6 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/transitions.whole.txt @@ -0,0 +1,9 @@ +# format: html5-tree-transitions-v1 +TRANSITION occurrence=1 token-kind=doctype token-name=null token-data=null token-self-closing=null mode-before=initial dispatch=html-insertion-mode:initial mode-after=before-html reprocessed=false +TRANSITION occurrence=2 token-kind=start-tag token-name="body" token-data=null token-self-closing=false mode-before=before-html dispatch=html-insertion-mode:before-html mode-after=before-head reprocessed=false +TRANSITION occurrence=3 token-kind=start-tag token-name="body" token-data=null token-self-closing=false mode-before=before-head dispatch=html-insertion-mode:before-head mode-after=in-head reprocessed=true +TRANSITION occurrence=4 token-kind=start-tag token-name="body" token-data=null token-self-closing=false mode-before=in-head dispatch=html-insertion-mode:in-head mode-after=after-head reprocessed=true +TRANSITION occurrence=5 token-kind=start-tag token-name="body" token-data=null token-self-closing=false mode-before=after-head dispatch=html-insertion-mode:after-head mode-after=in-body reprocessed=true +TRANSITION occurrence=6 token-kind=start-tag token-name="body" token-data=null token-self-closing=false mode-before=in-body dispatch=html-insertion-mode:in-body mode-after=in-body reprocessed=false +TRANSITION occurrence=7 token-kind=character token-name=null token-data="\n" token-self-closing=null mode-before=in-body dispatch=html-insertion-mode:in-body mode-after=in-body reprocessed=false +TRANSITION occurrence=8 token-kind=eof token-name=null token-data=null token-self-closing=null mode-before=in-body dispatch=html-insertion-mode:in-body mode-after=in-body reprocessed=false diff --git a/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/unsupported-features.txt b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/unsupported-features.txt new file mode 100644 index 00000000..937245be --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/document-unsupported-features/unsupported-features.txt @@ -0,0 +1,2 @@ +# format: html5-unsupported-features-v1 +UNSUPPORTED_FEATURE occurrence=1 subsystem=tree-construction feature=mark-frameset-not-ok-for-repeated-body-start-tag context-token=start-tag context-mode=in-body context-namespace=html diff --git a/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/fixture.toml b/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/fixture.toml index fced1253..8a5e4e84 100644 --- a/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/fixture.toml +++ b/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/fixture.toml @@ -1,4 +1,4 @@ -format = "borrowser-html-parser-fixture-v1" +format = "borrowser-html-parser-fixture-v2" id = "tokenizer-character-data" [source] @@ -27,4 +27,4 @@ tokens = "tokens.txt" status = "active" [metadata] -description = "Ordinary standalone-tokenizer character data through the canonical AE13a runner." +description = "Ordinary standalone-tokenizer character data through the canonical AE13 runner." diff --git a/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/tokens.txt b/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/tokens.txt index 29eec290..60996d9b 100644 --- a/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/tokens.txt +++ b/crates/html/tests/fixtures/html5/conformance/tokenizer-character-data/tokens.txt @@ -1,3 +1,3 @@ -# format: html5-token-v1 -CHAR text="Hello, parser fixtures!\n" -EOF +# format: html5-token-v2 +TOKEN ordinal=1 kind=character data="Hello, parser fixtures!\n" +TOKEN ordinal=2 kind=eof diff --git a/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/fixture.toml b/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/fixture.toml new file mode 100644 index 00000000..fdee9914 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/fixture.toml @@ -0,0 +1,30 @@ +format = "borrowser-html-parser-fixture-v2" +id = "tokenizer-doctype-null-identity" + +[source] +kind = "native" + +[input] +path = "input.html" +kind = "utf8-text" +sha256 = "19607cbfde051163f480d2d8b4f27fc03c3046d855a27e2c88c3e261865da2c6" + +[execution] +reference_delivery = "whole" + +[execution.target] +kind = "standalone-tokenizer" + +[[execution.deliveries]] +name = "whole" +unit = "unicode-scalars" +strategy = "whole" + +[expectations] +tokens = "tokens.txt" + +[disposition] +status = "active" + +[metadata] +description = "Token v2 distinguishes an absent doctype name from the literal name null." diff --git a/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/input.html b/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/input.html new file mode 100644 index 00000000..1c4e4e0d --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/input.html @@ -0,0 +1 @@ + diff --git a/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/tokens.txt b/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/tokens.txt new file mode 100644 index 00000000..32daf8c1 --- /dev/null +++ b/crates/html/tests/fixtures/html5/conformance/tokenizer-doctype-null-identity/tokens.txt @@ -0,0 +1,5 @@ +# format: html5-token-v2 +TOKEN ordinal=1 kind=doctype name=null public-id=null system-id=null force-quirks=true +TOKEN ordinal=2 kind=doctype name="null" public-id=null system-id=null force-quirks=false +TOKEN ordinal=3 kind=character data="\n" +TOKEN ordinal=4 kind=eof diff --git a/crates/html/tests/html5_parser_conformance.rs b/crates/html/tests/html5_parser_conformance.rs index f612dcfa..36ab3688 100644 --- a/crates/html/tests/html5_parser_conformance.rs +++ b/crates/html/tests/html5_parser_conformance.rs @@ -40,4 +40,35 @@ fn canonical_parser_conformance_corpus_executes_every_discovered_fixture() { ObservedToken::Eof, ]) ); + + let structured = reports + .iter() + .find(|report| report.fixture_id().as_str() == "document-structured-observations") + .expect("structured document fixture report must exist"); + assert_eq!( + structured + .delivery_results() + .iter() + .map(|delivery| delivery.delivery().as_str()) + .collect::>(), + ["whole", "trace-whole"] + ); + let whole = structured.delivery_results()[0].result(); + assert!(matches!(whole.transitions, ObservationState::NotRequested)); + assert!(matches!(whole.tree, ObservationState::Captured(_))); + let trace = structured.delivery_results()[1].result(); + assert!(matches!(trace.transitions, ObservationState::Captured(_))); + assert!(matches!(trace.tree, ObservationState::NotRequested)); + + let unsupported = reports + .iter() + .find(|report| report.fixture_id().as_str() == "document-unsupported-features") + .expect("unsupported-feature fixture report must exist"); + assert_eq!(unsupported.delivery_results().len(), 1); + let unioned = unsupported.delivery_results()[0].result(); + assert!(matches!(unioned.transitions, ObservationState::Captured(_))); + assert!(matches!( + unioned.unsupported_features, + ObservationState::Captured(_) + )); } diff --git a/crates/html_test_support/src/lib.rs b/crates/html_test_support/src/lib.rs index bfb864e1..52a68e2c 100644 --- a/crates/html_test_support/src/lib.rs +++ b/crates/html_test_support/src/lib.rs @@ -75,6 +75,9 @@ pub mod token_snapshot; #[cfg(feature = "parser-fixtures")] pub mod parser_fixture; +#[cfg(feature = "parser-fixtures")] +pub mod parser_snapshot; + #[cfg(feature = "html5")] pub mod tokenizer_text_mode; diff --git a/crates/html_test_support/src/parser_fixture/disposition.rs b/crates/html_test_support/src/parser_fixture/disposition.rs index 10e91cb1..0210208c 100644 --- a/crates/html_test_support/src/parser_fixture/disposition.rs +++ b/crates/html_test_support/src/parser_fixture/disposition.rs @@ -1,16 +1,19 @@ +use super::failure_spelling::execution_failure_name; use super::model::{ DispositionEvaluation, ExecutionFailureClass, ExpectationSurface, - ExpectedFailureClassification, FixtureCapability, FixtureDisposition, FixtureExecutionOutcome, + ExpectedFailureClassification, ExpectedFailureClassificationV2, FixtureCapability, + FixtureDisposition, FixtureExecutionOutcome, LegacyExecutionFailureClass, SkipClassification, }; use html::conformance::InvariantFailureCode; #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum FixtureOutcomeClassification { - NotExecuted, + NotExecuted(SkipClassification), Completed, UnsupportedFixtureSemantics(FixtureCapability), UnsupportedExpectation(ExpectationSurface), - ExecutionFailed(ExecutionFailureClass), + ExecutionFailedV1(LegacyExecutionFailureClass), + ExecutionFailedV2(ExecutionFailureClass), ExpectationMismatch(ExpectationSurface), InvariantFailure(Vec), IncompleteObservation, @@ -21,7 +24,8 @@ pub(super) enum DispositionExpectation { Completed, Unsupported(FixtureCapability), Failure(ExpectedFailureClassification), - NotExecuted, + FailureV2(ExpectedFailureClassificationV2), + NotExecuted(SkipClassification), } #[derive(Clone, Debug, PartialEq, Eq)] @@ -40,7 +44,12 @@ impl std::fmt::Display for DispositionEvaluationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::UnexpectedOutcome { expected, actual } => { - write!(f, "fixture outcome {actual:?} did not match {expected:?}") + write!( + f, + "fixture outcome '{}' did not match '{}'", + outcome_name(actual), + expectation_name(expected) + ) } Self::IncompleteObservation => { f.write_str("fixture result contains an incomplete non-authoritative observation") @@ -48,7 +57,8 @@ impl std::fmt::Display for DispositionEvaluationError { Self::Xpass { expected } => { write!( f, - "fixture unexpectedly passed (XPASS; declared {expected:?})" + "fixture unexpectedly passed (XPASS; declared '{}')", + expectation_name(expected) ) } } @@ -107,12 +117,23 @@ pub(super) fn evaluate_disposition( Err(DispositionEvaluationError::UnexpectedOutcome { expected, actual }) } } - FixtureDisposition::Skipped { .. } => { - if actual == FixtureOutcomeClassification::NotExecuted { + FixtureDisposition::ExpectedFailureV2 { failure, .. } => { + let expected = DispositionExpectation::FailureV2(failure.clone()); + if actual == FixtureOutcomeClassification::Completed { + return Err(DispositionEvaluationError::Xpass { expected }); + } + if failure_matches_v2(failure, &actual) { + Ok(DispositionEvaluation::Pass) + } else { + Err(DispositionEvaluationError::UnexpectedOutcome { expected, actual }) + } + } + FixtureDisposition::Skipped { classification, .. } => { + if actual == FixtureOutcomeClassification::NotExecuted(classification.clone()) { Ok(DispositionEvaluation::Skip) } else { Err(DispositionEvaluationError::UnexpectedOutcome { - expected: DispositionExpectation::NotExecuted, + expected: DispositionExpectation::NotExecuted(classification.clone()), actual, }) } @@ -122,9 +143,14 @@ pub(super) fn evaluate_disposition( fn classify_outcome(outcome: &FixtureExecutionOutcome) -> FixtureOutcomeClassification { match outcome { - FixtureExecutionOutcome::NotExecuted => FixtureOutcomeClassification::NotExecuted, - FixtureExecutionOutcome::Completed { .. } => FixtureOutcomeClassification::Completed, - FixtureExecutionOutcome::ExpectationMismatch { surface, .. } => { + FixtureExecutionOutcome::NotExecuted { classification } => { + FixtureOutcomeClassification::NotExecuted(classification.clone()) + } + FixtureExecutionOutcome::Completed { .. } | FixtureExecutionOutcome::CompletedV2 { .. } => { + FixtureOutcomeClassification::Completed + } + FixtureExecutionOutcome::ExpectationMismatch { surface, .. } + | FixtureExecutionOutcome::ExpectationMismatchV2 { surface, .. } => { FixtureOutcomeClassification::ExpectationMismatch(*surface) } FixtureExecutionOutcome::UnsupportedExpectation { surface } => { @@ -134,12 +160,16 @@ fn classify_outcome(outcome: &FixtureExecutionOutcome) -> FixtureOutcomeClassifi FixtureOutcomeClassification::UnsupportedFixtureSemantics(capability.clone()) } FixtureExecutionOutcome::ExecutionFailed { class, .. } => { - FixtureOutcomeClassification::ExecutionFailed(*class) + FixtureOutcomeClassification::ExecutionFailedV1(*class) + } + FixtureExecutionOutcome::ExecutionFailedV2 { class, .. } => { + FixtureOutcomeClassification::ExecutionFailedV2(*class) } FixtureExecutionOutcome::InvariantFailed { failures, .. } => { FixtureOutcomeClassification::InvariantFailure(failures.clone()) } - FixtureExecutionOutcome::IncompleteObservation { .. } => { + FixtureExecutionOutcome::IncompleteObservation { .. } + | FixtureExecutionOutcome::IncompleteObservationV2 { .. } => { FixtureOutcomeClassification::IncompleteObservation } } @@ -152,7 +182,7 @@ fn failure_matches( match (expected, actual) { ( ExpectedFailureClassification::Execution(expected), - FixtureOutcomeClassification::ExecutionFailed(actual), + FixtureOutcomeClassification::ExecutionFailedV1(actual), ) => expected == actual, ( ExpectedFailureClassification::ExpectationMismatch(expected), @@ -165,3 +195,99 @@ fn failure_matches( _ => false, } } + +fn failure_matches_v2( + expected: &ExpectedFailureClassificationV2, + actual: &FixtureOutcomeClassification, +) -> bool { + match (expected, actual) { + ( + ExpectedFailureClassificationV2::Execution(expected), + FixtureOutcomeClassification::ExecutionFailedV2(actual), + ) => expected == actual, + ( + ExpectedFailureClassificationV2::ExpectationMismatch(expected), + FixtureOutcomeClassification::ExpectationMismatch(actual), + ) => expected == actual, + ( + ExpectedFailureClassificationV2::FinalInvariant(expected), + FixtureOutcomeClassification::InvariantFailure(actual), + ) => actual.as_slice() == [*expected], + _ => false, + } +} + +fn expectation_name(value: &DispositionExpectation) -> String { + match value { + DispositionExpectation::Completed => "completed".to_string(), + DispositionExpectation::Unsupported(capability) => { + format!("unsupported:{}", capability_name(capability)) + } + DispositionExpectation::Failure(_) => "fixture-v1 expected failure".to_string(), + DispositionExpectation::FailureV2(failure) => match failure { + ExpectedFailureClassificationV2::Execution(class) => { + format!( + "fixture-v2 expected failure: {}", + execution_failure_name(*class) + ) + } + ExpectedFailureClassificationV2::ExpectationMismatch(surface) => format!( + "fixture-v2 expected failure: expectation-mismatch:{}", + surface.name() + ), + ExpectedFailureClassificationV2::FinalInvariant(_) => { + "fixture-v2 expected failure: final-invariant".to_string() + } + }, + DispositionExpectation::NotExecuted(classification) => { + format!("not-executed:{}", skip_name(classification)) + } + } +} + +fn outcome_name(value: &FixtureOutcomeClassification) -> String { + match value { + FixtureOutcomeClassification::NotExecuted(classification) => { + format!("not-executed:{}", skip_name(classification)) + } + FixtureOutcomeClassification::Completed => "completed".to_string(), + FixtureOutcomeClassification::UnsupportedFixtureSemantics(capability) => { + format!("unsupported:{}", capability_name(capability)) + } + FixtureOutcomeClassification::UnsupportedExpectation(surface) => { + format!("unsupported-expectation:{}", surface.name()) + } + FixtureOutcomeClassification::ExecutionFailedV1(_) => { + "fixture-v1 execution failure".to_string() + } + FixtureOutcomeClassification::ExecutionFailedV2(class) => execution_failure_name(*class), + FixtureOutcomeClassification::ExpectationMismatch(surface) => { + format!("expectation-mismatch:{}", surface.name()) + } + FixtureOutcomeClassification::InvariantFailure(_) => "final-invariant-failure".to_string(), + FixtureOutcomeClassification::IncompleteObservation => "incomplete-observation".to_string(), + } +} + +fn skip_name(value: &SkipClassification) -> String { + match value { + SkipClassification::UnsupportedCapability(capability) => { + format!("unsupported:{}", capability_name(capability)) + } + } +} + +fn capability_name(value: &FixtureCapability) -> String { + match value { + FixtureCapability::RawByteInput => "raw-byte-input".to_string(), + FixtureCapability::ByteDelivery => "byte-delivery".to_string(), + FixtureCapability::UnicodeScalarChunking => "unicode-scalar-chunking".to_string(), + FixtureCapability::DocumentExecution => "document-execution".to_string(), + FixtureCapability::FragmentParsing => "fragment-parsing".to_string(), + FixtureCapability::ScriptingEnabled => "scripting-enabled".to_string(), + FixtureCapability::UnknownRequiredExtension(id) => { + format!("unknown-required-extension:{id}") + } + FixtureCapability::Expectation(surface) => format!("{}-expectation", surface.name()), + } +} diff --git a/crates/html_test_support/src/parser_fixture/execution.rs b/crates/html_test_support/src/parser_fixture/execution.rs new file mode 100644 index 00000000..71f6241c --- /dev/null +++ b/crates/html_test_support/src/parser_fixture/execution.rs @@ -0,0 +1,184 @@ +use super::model::*; +use super::validate::ValidatedFixtureSpec; +use html::conformance::{ObservationRequest, ParserObservationRequest, ScalarObservationRequest}; +use std::collections::BTreeSet; + +/// Private canonical fixture-runner observation guardrails. These are +/// defensive harness policy, not production parser limits, and fixture TOML or +/// sidecars cannot configure them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct FixtureObservationGuardrails { + pub(super) tokens: usize, + pub(super) parse_errors: usize, + pub(super) implementation_diagnostics: usize, + pub(super) unsupported_features: usize, + pub(super) canonical_tree_units: usize, + pub(super) transitions: usize, + pub(super) patch_operations: usize, +} + +impl FixtureObservationGuardrails { + pub(super) const PRODUCTION: Self = Self { + tokens: 65_536, + parse_errors: 65_536, + implementation_diagnostics: 65_536, + unsupported_features: 65_536, + canonical_tree_units: 131_072, + transitions: 262_144, + patch_operations: 262_144, + }; +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct RequestedSurfaces { + pub(super) tokens: bool, + pub(super) parse_errors: bool, + pub(super) implementation_diagnostics: bool, + pub(super) document_mode: bool, + pub(super) tree: bool, + pub(super) patches: bool, + pub(super) transitions: bool, + pub(super) unsupported_features: bool, +} + +impl RequestedSurfaces { + pub(super) fn ordinary(expectations: &EnabledExpectations) -> Self { + Self { + tokens: expectations.is_declared(ExpectationSurface::Tokens), + parse_errors: expectations.is_declared(ExpectationSurface::ParseErrors), + implementation_diagnostics: expectations + .is_declared(ExpectationSurface::ImplementationDiagnostics), + document_mode: expectations.is_declared(ExpectationSurface::DocumentMode), + tree: expectations.is_declared(ExpectationSurface::Tree), + patches: expectations.is_declared(ExpectationSurface::Patches), + transitions: false, + unsupported_features: expectations.is_declared(ExpectationSurface::UnsupportedFeatures), + } + } + + fn union(&mut self, other: Self) { + self.tokens |= other.tokens; + self.parse_errors |= other.parse_errors; + self.implementation_diagnostics |= other.implementation_diagnostics; + self.document_mode |= other.document_mode; + self.tree |= other.tree; + self.patches |= other.patches; + self.transitions |= other.transitions; + self.unsupported_features |= other.unsupported_features; + } + + pub(super) fn any(self) -> bool { + self.tokens + || self.parse_errors + || self.implementation_diagnostics + || self.document_mode + || self.tree + || self.patches + || self.transitions + || self.unsupported_features + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PlannedDelivery { + pub(super) name: DeliveryName, + pub(super) surfaces: RequestedSurfaces, +} + +pub(super) fn build_delivery_plan( + fixture: &ValidatedFixtureSpec, +) -> Result, ValidatedFixtureInvariantCode> { + let ordinary = RequestedSurfaces::ordinary(fixture.expectations()); + let mut requested = Vec::<(DeliveryName, RequestedSurfaces)>::new(); + if ordinary.any() { + requested.push((fixture.execution().reference_delivery().clone(), ordinary)); + } + if let ExpectedSurface::Compare(transitions) = fixture.expectations().transitions() { + for transition in transitions { + requested.push(( + transition.delivery().clone(), + RequestedSurfaces { + transitions: true, + ..RequestedSurfaces::default() + }, + )); + } + } + + let mut plan = Vec::new(); + for delivery in fixture.execution().deliveries() { + let mut surfaces = RequestedSurfaces::default(); + for (name, requested_surfaces) in &requested { + if name == delivery.name() { + surfaces.union(*requested_surfaces); + } + } + if surfaces.any() { + if plan + .iter() + .any(|planned: &PlannedDelivery| planned.name == *delivery.name()) + { + return Err(ValidatedFixtureInvariantCode::DuplicatePlannedDelivery); + } + plan.push(PlannedDelivery { + name: delivery.name().clone(), + surfaces, + }); + } + } + for (name, _) in requested { + if !plan.iter().any(|planned| planned.name == name) { + return Err(if name == *fixture.execution().reference_delivery() { + ValidatedFixtureInvariantCode::PlannedReferenceDeliveryMissing + } else { + ValidatedFixtureInvariantCode::PlannedDeliveryMissing + }); + } + } + let unique = plan + .iter() + .map(|planned| planned.name.clone()) + .collect::>(); + if unique.len() != plan.len() { + return Err(ValidatedFixtureInvariantCode::DuplicatePlannedDelivery); + } + Ok(plan) +} + +pub(super) fn observation_request<'a>( + target: html::conformance::ParserObservationTarget, + text: &'a str, + surfaces: RequestedSurfaces, + guardrails: FixtureObservationGuardrails, +) -> ParserObservationRequest<'a> { + ParserObservationRequest { + target, + input: html::conformance::ParserObservationInput::Utf8(text), + tokens: request(surfaces.tokens, guardrails.tokens), + parse_errors: request(surfaces.parse_errors, guardrails.parse_errors), + implementation_diagnostics: request( + surfaces.implementation_diagnostics, + guardrails.implementation_diagnostics, + ), + transitions: request(surfaces.transitions, guardrails.transitions), + unsupported_features: request( + surfaces.unsupported_features, + guardrails.unsupported_features, + ), + document_mode: if surfaces.document_mode { + ScalarObservationRequest::Capture + } else { + ScalarObservationRequest::NotRequested + }, + tree: request(surfaces.tree, guardrails.canonical_tree_units), + patches: request(surfaces.patches, guardrails.patch_operations), + } +} + +const fn request(enabled: bool, capacity: usize) -> ObservationRequest { + if enabled { + ObservationRequest::Capture { capacity } + } else { + ObservationRequest::NotRequested + } +} diff --git a/crates/html_test_support/src/parser_fixture/failure_spelling.rs b/crates/html_test_support/src/parser_fixture/failure_spelling.rs new file mode 100644 index 00000000..666e6e1f --- /dev/null +++ b/crates/html_test_support/src/parser_fixture/failure_spelling.rs @@ -0,0 +1,362 @@ +use super::model::{ + ExecutionFailureClass, ParserObservationFailureClass, ValidatedFixtureInvariantCode, +}; +use html::conformance::{ + ObservationReservationSite, ParserFatalIdentity, ParserObservationExecutionIdentity as I, + ParserObservationInvariantError as O, ParserReservationSiteIdentity as P, + ParserTokenizerInvariantError as T, UnsupportedFeatureObservationInvariantError as U, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct ParserObservationFailureSpelling { + pub(super) identity: &'static str, + pub(super) code: Option<&'static str>, + pub(super) site: Option<&'static str>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum FailureSpellingError { + ContradictoryIdentityFields, + UnknownParserObservationIdentity, + UnknownTokenizerInvariant, + UnknownUnsupportedFeatureObservationInvariant, + UnknownObservationInvariant, + UnknownParserReservationSite, + UnknownObservationReservationSite, + UnknownRunnerInvariant, +} + +impl std::fmt::Display for FailureSpellingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ContradictoryIdentityFields => { + "parser-observation identity fields are incomplete or contradictory" + } + Self::UnknownParserObservationIdentity => "unknown parser-observation identity", + Self::UnknownTokenizerInvariant => "unknown tokenizer invariant code", + Self::UnknownUnsupportedFeatureObservationInvariant => { + "unknown unsupported-feature observation invariant code" + } + Self::UnknownObservationInvariant => "unknown parser observation invariant code", + Self::UnknownParserReservationSite => "unknown parser-fatal reservation site", + Self::UnknownObservationReservationSite => "unknown observation reservation site", + Self::UnknownRunnerInvariant => "unknown validated-runner invariant code", + }) + } +} + +macro_rules! closed_codec { + ($format:ident, $parse:ident, $all:ident, $ty:ty, { $($variant:path => $name:literal),+ $(,)? }) => { + pub(super) const fn $format(value: $ty) -> &'static str { + match value { + $($variant => $name),+ + } + } + + fn $parse(value: &str) -> Option<$ty> { + match value { + $($name => Some($variant),)+ + _ => None, + } + } + + #[cfg(test)] + pub(super) const fn $all() -> &'static [$ty] { + &[$($variant),+] + } + }; +} + +closed_codec!( + tokenizer_invariant_name, + parse_tokenizer_invariant, + all_tokenizer_invariants, + T, + { + T::SelfClosingFlagMissingSolidusPosition => "self-closing-flag-missing-solidus-position", + T::SolidusPositionWithoutPendingTag => "solidus-position-without-pending-tag", + T::SolidusPositionOutsideCurrentPendingTag => "solidus-position-outside-current-pending-tag", + T::SolidusPositionDoesNotReferenceConsumedSlash => "solidus-position-does-not-reference-consumed-slash", + T::DoctypeNameStartMissingForNameState => "doctype-name-start-missing-for-name-state", + T::DoctypeNameStartMissingForTailScan => "doctype-name-start-missing-for-tail-scan", + T::DoctypeNameStartMissingForResourceObservation => "doctype-name-start-missing-for-resource-observation", + T::DoctypeNameStartAfterCursor => "doctype-name-start-after-cursor", + T::DoctypeNameRangeInvalid => "doctype-name-range-invalid", + T::DoctypeTailRangeInvalid => "doctype-tail-range-invalid", + T::AsciiPrefixCandidateRangeInvalid => "ascii-prefix-candidate-range-invalid", + T::CommentStateMissingPendingStart => "comment-state-missing-pending-start", + T::CommentPendingRangeInvalid => "comment-pending-range-invalid", + T::CommentPendingDelimiterOutsideCurrentRange => "comment-pending-delimiter-outside-current-range", + T::CommentPendingDelimiterDoesNotMatchState => "comment-pending-delimiter-does-not-match-state", + T::TextModeEndTagCandidateRangeInvalid => "text-mode-end-tag-candidate-range-invalid", + T::TextModeEndTagAttributePositionInvalid => "text-mode-end-tag-attribute-position-invalid", + T::TextModeEndTagSolidusPositionInvalid => "text-mode-end-tag-solidus-position-invalid", + T::PendingTextRangeInvalid => "pending-text-range-invalid", + T::CdataStateMissingPendingTextStart => "cdata-state-missing-pending-text-start", + T::CdataEndDelimiterOutsidePendingTextRange => "cdata-end-delimiter-outside-pending-text-range", + T::CdataEndDelimiterDoesNotMatchState => "cdata-end-delimiter-does-not-match-state", + T::ProcessingInstructionStateMissingPendingMetadata => "processing-instruction-state-missing-pending-metadata", + T::ProcessingInstructionMetadataOutsideState => "processing-instruction-metadata-outside-state", + T::ProcessingInstructionTargetRangeInvalid => "processing-instruction-target-range-invalid", + T::ProcessingInstructionDataRangeInvalid => "processing-instruction-data-range-invalid", + T::ProcessingInstructionTargetStartAfterCursor => "processing-instruction-target-start-after-cursor", + T::ProcessingInstructionDataStartAfterCursor => "processing-instruction-data-start-after-cursor", + } +); + +closed_codec!( + unsupported_observation_invariant_name, + parse_unsupported_observation_invariant, + all_unsupported_observation_invariants, + U, + { + U::TokenAttributeNameUnavailable => "token-attribute-name-unavailable", + U::ExistingHtmlElementSemanticsUnavailable => "existing-html-element-semantics-unavailable", + U::ExistingBodyElementSemanticsUnavailable => "existing-body-element-semantics-unavailable", + U::ExistingElementIdentityContradiction => "existing-element-identity-contradiction", + } +); + +closed_codec!( + observation_invariant_name, + parse_observation_invariant, + all_observation_invariants, + O, + { + O::ParseErrorOccurrenceOverflow => "parse-error-occurrence-overflow", + O::ImplementationDiagnosticOccurrenceOverflow => "implementation-diagnostic-occurrence-overflow", + O::TreeTransitionOccurrenceOverflow => "tree-transition-occurrence-overflow", + O::UnsupportedFeatureOccurrenceOverflow => "unsupported-feature-occurrence-overflow", + O::TokenDroppedCountOverflow => "token-dropped-count-overflow", + O::ParseErrorDroppedCountOverflow => "parse-error-dropped-count-overflow", + O::ImplementationDiagnosticDroppedCountOverflow => "implementation-diagnostic-dropped-count-overflow", + O::TreeTransitionDroppedCountOverflow => "tree-transition-dropped-count-overflow", + O::UnsupportedFeatureDroppedCountOverflow => "unsupported-feature-dropped-count-overflow", + O::NormalizedPositionOverflow => "normalized-position-overflow", + O::NormalizedPositionIndexDiscontinuity => "normalized-position-index-discontinuity", + O::NormalizedPositionIndexMissing => "normalized-position-index-missing", + O::InvalidNormalizedPositionOffset => "invalid-normalized-position-offset", + O::PatchDroppedCountOverflow => "patch-dropped-count-overflow", + O::CanonicalTreeUnitCountOverflow => "canonical-tree-unit-count-overflow", + O::CanonicalTreeRootNotDocument => "canonical-tree-root-not-document", + O::UnexpectedLegacyDocumentDoctypeMetadata => "unexpected-legacy-document-doctype-metadata", + O::MissingHtmlTemplateContents => "missing-html-template-contents", + O::InvalidTemplateContentsKind => "invalid-template-contents-kind", + O::CanonicalTreeTraversalContradiction => "canonical-tree-traversal-contradiction", + O::CanonicalTreePreflightProjectionMismatch => "canonical-tree-preflight-projection-mismatch", + O::InvalidPatchKey => "invalid-patch-key", + O::DuplicatePatchCreation => "duplicate-patch-creation", + O::MissingPatchCreationHistory => "missing-patch-creation-history", + O::SnapshotLabelSequenceOverflow => "snapshot-label-sequence-overflow", + } +); + +closed_codec!( + parser_reservation_site_name, + parse_parser_reservation_site, + all_parser_reservation_sites, + P, + { + P::KnownTagAtomStorage => "known-tag-atom-storage", + P::KnownTagLookupStorage => "known-tag-lookup-storage", + P::TemplateChildStorage => "template-child-storage", + P::PatchHistoryObservationStorage => "patch-history-observation-storage", + } +); + +closed_codec!( + observation_reservation_site_name, + parse_observation_reservation_site, + all_observation_reservation_sites, + ObservationReservationSite, + { + ObservationReservationSite::CanonicalTreeProjection => "canonical-tree-projection", + ObservationReservationSite::CanonicalPatchProjection => "canonical-patch-projection", + ObservationReservationSite::SnapshotLabelStorage => "snapshot-label-storage", + } +); + +closed_codec!( + runner_invariant_name, + parse_runner_invariant_inner, + all_runner_invariants, + ValidatedFixtureInvariantCode, + { + ValidatedFixtureInvariantCode::PlannedReferenceDeliveryMissing => "planned-reference-delivery-missing", + ValidatedFixtureInvariantCode::PlannedDeliveryMissing => "planned-delivery-missing", + ValidatedFixtureInvariantCode::DuplicatePlannedDelivery => "duplicate-planned-delivery", + ValidatedFixtureInvariantCode::RequestedSurfaceUnexpectedlyNotRequested => "requested-surface-unexpectedly-not-requested", + ValidatedFixtureInvariantCode::RequestedSurfaceUnexpectedlyNotApplicable => "requested-surface-unexpectedly-not-applicable", + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyCaptured => "unrequested-surface-unexpectedly-captured", + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyIncomplete => "unrequested-surface-unexpectedly-incomplete", + ValidatedFixtureInvariantCode::SnapshotVariantSurfaceContradiction => "snapshot-variant-surface-contradiction", + ValidatedFixtureInvariantCode::CanonicalSerializerSurfaceContradiction => "canonical-serializer-surface-contradiction", + ValidatedFixtureInvariantCode::ComparisonSurfaceContradiction => "comparison-surface-contradiction", + ValidatedFixtureInvariantCode::MissingExecutedDeliveryResult => "missing-executed-delivery-result", + ValidatedFixtureInvariantCode::DuplicateExecutedDeliveryResult => "duplicate-executed-delivery-result", + ValidatedFixtureInvariantCode::DuplicateExpectationIdentity => "duplicate-expectation-identity", + } +); + +pub(super) fn parse_parser_observation_failure( + identity: &str, + code: Option<&str>, + site: Option<&str>, +) -> Result { + let value = match (identity, code, site) { + ("parser-fatal-engine-invariant", None, None) => { + I::ParserFatal(ParserFatalIdentity::EngineInvariant) + } + ("parser-fatal-resource-exhaustion", None, Some(site)) => { + I::ParserFatal(ParserFatalIdentity::ResourceExhaustion( + parse_parser_reservation_site(site) + .ok_or(FailureSpellingError::UnknownParserReservationSite)?, + )) + } + ("parser-invariant", None, None) => I::ParserInvariant, + ("tokenizer-invariant", Some(code), None) => I::TokenizerInvariant( + parse_tokenizer_invariant(code) + .ok_or(FailureSpellingError::UnknownTokenizerInvariant)?, + ), + ("token-canonicalization-invariant", None, None) => I::TokenCanonicalizationInvariant, + ("tree-transition-token-canonicalization-invariant", None, None) => { + I::TreeTransitionTokenCanonicalizationInvariant + } + ("unsupported-feature-observation-invariant", Some(code), None) => { + I::UnsupportedFeatureObservationInvariant( + parse_unsupported_observation_invariant(code) + .ok_or(FailureSpellingError::UnknownUnsupportedFeatureObservationInvariant)?, + ) + } + ("observation-recorder-missing", None, None) => I::ObservationRecorderMissing, + ("patch-history-capture-missing", None, None) => I::PatchHistoryCaptureMissing, + ("observation-invariant", Some(code), None) => I::ObservationInvariant( + parse_observation_invariant(code) + .ok_or(FailureSpellingError::UnknownObservationInvariant)?, + ), + ("observation-resource-exhaustion", None, Some(site)) => I::ResourceExhaustion( + parse_observation_reservation_site(site) + .ok_or(FailureSpellingError::UnknownObservationReservationSite)?, + ), + (identity, _, _) if !is_parser_observation_identity(identity) => { + return Err(FailureSpellingError::UnknownParserObservationIdentity); + } + _ => return Err(FailureSpellingError::ContradictoryIdentityFields), + }; + Ok(value) +} + +fn is_parser_observation_identity(identity: &str) -> bool { + matches!( + identity, + "parser-fatal-engine-invariant" + | "parser-fatal-resource-exhaustion" + | "parser-invariant" + | "tokenizer-invariant" + | "token-canonicalization-invariant" + | "tree-transition-token-canonicalization-invariant" + | "unsupported-feature-observation-invariant" + | "observation-recorder-missing" + | "patch-history-capture-missing" + | "observation-invariant" + | "observation-resource-exhaustion" + ) +} + +pub(super) const fn parser_observation_failure_spelling( + identity: ParserObservationFailureClass, +) -> ParserObservationFailureSpelling { + match identity { + I::ParserFatal(ParserFatalIdentity::EngineInvariant) => ParserObservationFailureSpelling { + identity: "parser-fatal-engine-invariant", + code: None, + site: None, + }, + I::ParserFatal(ParserFatalIdentity::ResourceExhaustion(site)) => { + ParserObservationFailureSpelling { + identity: "parser-fatal-resource-exhaustion", + code: None, + site: Some(parser_reservation_site_name(site)), + } + } + I::ParserInvariant => ParserObservationFailureSpelling { + identity: "parser-invariant", + code: None, + site: None, + }, + I::TokenizerInvariant(code) => ParserObservationFailureSpelling { + identity: "tokenizer-invariant", + code: Some(tokenizer_invariant_name(code)), + site: None, + }, + I::TokenCanonicalizationInvariant => ParserObservationFailureSpelling { + identity: "token-canonicalization-invariant", + code: None, + site: None, + }, + I::TreeTransitionTokenCanonicalizationInvariant => ParserObservationFailureSpelling { + identity: "tree-transition-token-canonicalization-invariant", + code: None, + site: None, + }, + I::UnsupportedFeatureObservationInvariant(code) => ParserObservationFailureSpelling { + identity: "unsupported-feature-observation-invariant", + code: Some(unsupported_observation_invariant_name(code)), + site: None, + }, + I::ObservationRecorderMissing => ParserObservationFailureSpelling { + identity: "observation-recorder-missing", + code: None, + site: None, + }, + I::PatchHistoryCaptureMissing => ParserObservationFailureSpelling { + identity: "patch-history-capture-missing", + code: None, + site: None, + }, + I::ObservationInvariant(code) => ParserObservationFailureSpelling { + identity: "observation-invariant", + code: Some(observation_invariant_name(code)), + site: None, + }, + I::ResourceExhaustion(site) => ParserObservationFailureSpelling { + identity: "observation-resource-exhaustion", + code: None, + site: Some(observation_reservation_site_name(site)), + }, + } +} + +pub(super) fn parser_observation_failure_name(identity: ParserObservationFailureClass) -> String { + let spelling = parser_observation_failure_spelling(identity); + match (spelling.code, spelling.site) { + (Some(code), None) => format!("{}:{code}", spelling.identity), + (None, Some(site)) => format!("{}:{site}", spelling.identity), + (None, None) => spelling.identity.to_string(), + (Some(_), Some(_)) => unreachable!("sealed failure spelling has one owned field"), + } +} + +pub(super) fn parse_runner_invariant( + value: &str, +) -> Result { + parse_runner_invariant_inner(value).ok_or(FailureSpellingError::UnknownRunnerInvariant) +} + +pub(super) fn execution_failure_name(value: ExecutionFailureClass) -> String { + match value { + ExecutionFailureClass::SnapshotRead(surface) => { + format!("snapshot-read:{}", surface.name()) + } + ExecutionFailureClass::SnapshotFormat(surface) => { + format!("snapshot-format:{}", surface.name()) + } + ExecutionFailureClass::ParserObservation(identity) => format!( + "parser-observation:{}", + parser_observation_failure_name(identity) + ), + ExecutionFailureClass::ValidatedFixtureInvariant(code) => { + format!("validated-runner-invariant:{}", runner_invariant_name(code)) + } + } +} diff --git a/crates/html_test_support/src/parser_fixture/load.rs b/crates/html_test_support/src/parser_fixture/load.rs index 7461f3e2..dd234ce7 100644 --- a/crates/html_test_support/src/parser_fixture/load.rs +++ b/crates/html_test_support/src/parser_fixture/load.rs @@ -1,6 +1,6 @@ -use super::model::{FixtureBundle, FixtureId}; -use super::schema::FixtureFileV1; -use super::validate::{ValidatedFixtureSpec, validate_fixture}; +use super::model::{FIXTURE_FORMAT_V1, FIXTURE_FORMAT_V2, FixtureBundle, FixtureId}; +use super::schema::{FixtureFileV1, FixtureFileV2, FixtureFormatEnvelope}; +use super::validate::{ValidatedFixtureSpec, validate_fixture_v1, validate_fixture_v2}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -18,6 +18,41 @@ pub struct FixtureRepository { pub policy: FixtureRepositoryPolicy, } +pub(super) trait FixtureFileAccess { + fn validate_regular_file_metadata( + &mut self, + bundle: &FixtureBundle, + relative: &str, + ) -> Result<(), FixtureLoadError>; + + fn read_regular_file( + &mut self, + bundle: &FixtureBundle, + relative: &str, + ) -> Result, FixtureLoadError>; +} + +#[derive(Default)] +pub(super) struct ProductionFixtureFileAccess; + +impl FixtureFileAccess for ProductionFixtureFileAccess { + fn validate_regular_file_metadata( + &mut self, + bundle: &FixtureBundle, + relative: &str, + ) -> Result<(), FixtureLoadError> { + validate_regular_file_metadata(bundle, relative) + } + + fn read_regular_file( + &mut self, + bundle: &FixtureBundle, + relative: &str, + ) -> Result, FixtureLoadError> { + read_regular_file(bundle, relative) + } +} + impl FixtureRepository { pub fn native(repository_root: impl Into, fixture_root: impl Into) -> Self { Self { @@ -75,7 +110,7 @@ impl std::fmt::Display for FixtureLoadError { } FixtureLoadErrorKind::Io(message) => write!(f, "I/O error: {message}"), FixtureLoadErrorKind::InvalidFixtureToml(message) => { - write!(f, "invalid fixture-v1 TOML: {message}") + write!(f, "invalid versioned fixture TOML: {message}") } FixtureLoadErrorKind::UnsupportedFixtureFormat(value) => { write!(f, "unsupported fixture format '{value}'") @@ -154,6 +189,13 @@ impl std::error::Error for FixtureLoadError {} pub fn discover_and_load( repository: &FixtureRepository, +) -> Result, FixtureLoadError> { + discover_and_load_with_access(repository, &mut ProductionFixtureFileAccess) +} + +pub(super) fn discover_and_load_with_access( + repository: &FixtureRepository, + file_access: &mut impl FixtureFileAccess, ) -> Result, FixtureLoadError> { let fixture_root_relative = repository .fixture_root @@ -194,35 +236,32 @@ pub fn discover_and_load( let mut declarations = Vec::with_capacity(bundles.len()); let mut case_ids = BTreeMap::::new(); for bundle in bundles { - let fixture_toml = read_regular_file(&bundle, "fixture.toml")?; + let fixture_toml = file_access.read_regular_file(&bundle, "fixture.toml")?; let fixture_text = std::str::from_utf8(&fixture_toml).map_err(|_| FixtureLoadError { path: format!("{}/fixture.toml", bundle.repository_relative_path()), kind: FixtureLoadErrorKind::InvalidFixtureToml( "fixture metadata must be UTF-8".to_string(), ), })?; - let parsed: FixtureFileV1 = - toml::from_str(fixture_text).map_err(|err| FixtureLoadError { - path: format!("{}/fixture.toml", bundle.repository_relative_path()), - kind: FixtureLoadErrorKind::InvalidFixtureToml(err.to_string()), - })?; - let folded = parsed.id.to_ascii_lowercase(); + let parsed = parse_versioned_fixture(fixture_text, &bundle)?; + let folded = parsed.id().to_ascii_lowercase(); if let Some((first_id, first_path)) = case_ids.insert( folded, ( - parsed.id.clone(), + parsed.id().to_string(), bundle.repository_relative_path().to_string(), ), ) { - let kind = if first_id == parsed.id { + let kind = if first_id == parsed.id() { FixtureLoadErrorKind::DuplicateFixtureId(format!( "{} (first declared at {first_path})", - parsed.id + parsed.id() )) } else { FixtureLoadErrorKind::CaseCollidingFixtureId(format!( "'{}' at {first_path} and '{}'", - first_id, parsed.id + first_id, + parsed.id() )) }; return Err(FixtureLoadError { @@ -236,7 +275,14 @@ pub fn discover_and_load( let mut loaded = Vec::with_capacity(declarations.len()); let mut ids = BTreeMap::::new(); for (bundle, parsed) in declarations { - let fixture = validate_fixture(parsed, bundle, repository.policy)?; + let fixture = match parsed { + ParsedFixtureFile::V1(parsed) => { + validate_fixture_v1(parsed, bundle, repository.policy, file_access)? + } + ParsedFixtureFile::V2(parsed) => { + validate_fixture_v2(parsed, bundle, repository.policy, file_access)? + } + }; if let Some(first_path) = ids.insert( fixture.id().clone(), fixture.repository_relative_path().to_string(), @@ -254,6 +300,55 @@ pub fn discover_and_load( Ok(loaded) } +#[derive(Clone, Debug)] +enum ParsedFixtureFile { + V1(FixtureFileV1), + V2(FixtureFileV2), +} + +impl ParsedFixtureFile { + fn id(&self) -> &str { + match self { + Self::V1(value) => &value.id, + Self::V2(value) => &value.id, + } + } +} + +fn parse_versioned_fixture( + fixture_text: &str, + bundle: &FixtureBundle, +) -> Result { + let path = format!("{}/fixture.toml", bundle.repository_relative_path()); + let envelope: FixtureFormatEnvelope = + toml::from_str(fixture_text).map_err(|err| FixtureLoadError { + path: path.clone(), + kind: FixtureLoadErrorKind::InvalidFixtureToml(format!("format envelope: {err}")), + })?; + match envelope.format.as_str() { + FIXTURE_FORMAT_V1 => toml::from_str(fixture_text) + .map(ParsedFixtureFile::V1) + .map_err(|err| FixtureLoadError { + path, + kind: FixtureLoadErrorKind::InvalidFixtureToml(format!( + "{FIXTURE_FORMAT_V1}: {err}" + )), + }), + FIXTURE_FORMAT_V2 => toml::from_str(fixture_text) + .map(ParsedFixtureFile::V2) + .map_err(|err| FixtureLoadError { + path, + kind: FixtureLoadErrorKind::InvalidFixtureToml(format!( + "{FIXTURE_FORMAT_V2}: {err}" + )), + }), + _ => Err(FixtureLoadError { + path, + kind: FixtureLoadErrorKind::UnsupportedFixtureFormat(envelope.format), + }), + } +} + fn discover_recursive( repository_root: &Path, directory: &Path, @@ -376,6 +471,21 @@ pub(super) fn read_regular_file( bundle: &FixtureBundle, relative: &str, ) -> Result, FixtureLoadError> { + let current = regular_file_path(bundle, relative)?; + fs::read(¤t).map_err(|err| FixtureLoadError { + path: bundle.repository_relative_path().to_string(), + kind: FixtureLoadErrorKind::Io(err.to_string()), + }) +} + +pub(super) fn validate_regular_file_metadata( + bundle: &FixtureBundle, + relative: &str, +) -> Result<(), FixtureLoadError> { + regular_file_path(bundle, relative).map(|_| ()) +} + +fn regular_file_path(bundle: &FixtureBundle, relative: &str) -> Result { validate_relative_path(relative).map_err(|kind| FixtureLoadError { path: bundle.repository_relative_path().to_string(), kind, @@ -419,10 +529,7 @@ pub(super) fn read_regular_file( kind: FixtureLoadErrorKind::DeclaredPathNotFile(relative.to_string()), }); } - fs::read(¤t).map_err(|err| FixtureLoadError { - path: bundle.repository_relative_path().to_string(), - kind: FixtureLoadErrorKind::Io(err.to_string()), - }) + Ok(current) } pub(super) fn validate_relative_path(relative: &str) -> Result<(), FixtureLoadErrorKind> { diff --git a/crates/html_test_support/src/parser_fixture/mismatch.rs b/crates/html_test_support/src/parser_fixture/mismatch.rs new file mode 100644 index 00000000..d329540c --- /dev/null +++ b/crates/html_test_support/src/parser_fixture/mismatch.rs @@ -0,0 +1,124 @@ +use super::model::{DeliveryName, ExpectationSurface, SnapshotPath, ValidatedFixtureInvariantCode}; +use super::validate::ValidatedFixtureSpec; +use crate::parser_snapshot::{CanonicalSnapshot, ParsedSnapshot}; +use std::fmt::Write; + +pub(super) fn compare_snapshots( + fixture: &ValidatedFixtureSpec, + delivery: Option<&DeliveryName>, + expected_path: &SnapshotPath, + expected: &ParsedSnapshot, + actual: &CanonicalSnapshot, +) -> Result, ValidatedFixtureInvariantCode> { + if expected.surface() != actual.surface() || expected.format() != actual.format() { + return Err(ValidatedFixtureInvariantCode::ComparisonSurfaceContradiction); + } + let expected_records = expected.snapshot(); + let actual_records = actual.snapshot(); + let shared = expected_records + .record_count() + .min(actual_records.record_count()); + let first = (0..shared) + .find(|index| { + expected_records.record(*index).map(|record| record.line) + != actual_records.record(*index).map(|record| record.line) + }) + .or_else(|| { + (expected_records.record_count() != actual_records.record_count()).then_some(shared) + }); + let Some(first) = first else { + return Ok(None); + }; + let missing = ""; + let expected_record = expected_records.record(first); + let actual_record = actual_records.record(first); + let location = expected_record + .map(|record| record.location) + .or_else(|| actual_record.map(|record| record.location)) + .unwrap_or("end of snapshot"); + let mut message = String::new(); + let _ = writeln!(&mut message, "fixture: {}", fixture.id().as_str()); + let _ = writeln!( + &mut message, + "fixture path: {}", + fixture.repository_relative_path() + ); + let _ = writeln!( + &mut message, + "expectation surface: {}", + expected.surface().name() + ); + if let Some(delivery) = delivery { + let _ = writeln!(&mut message, "transition delivery: {}", delivery.as_str()); + } + let _ = writeln!( + &mut message, + "expected snapshot: {}/{}", + fixture.repository_relative_path(), + expected_path.as_str() + ); + let _ = writeln!( + &mut message, + "snapshot format: {}", + expected.format().name() + ); + let _ = writeln!( + &mut message, + "first meaningful difference: record {} ({location})", + first + 1 + ); + let _ = writeln!( + &mut message, + "expected: {}", + expected_record.map(|record| record.line).unwrap_or(missing) + ); + let _ = writeln!( + &mut message, + "actual: {}", + actual_record.map(|record| record.line).unwrap_or(missing) + ); + let start = first.saturating_sub(2); + let end = (first + 3).min( + expected_records + .record_count() + .max(actual_records.record_count()), + ); + let _ = writeln!(&mut message, "nearby context:"); + for index in start..end { + let marker = if index == first { ">" } else { " " }; + let left = expected_records + .record(index) + .map(|record| record.line) + .unwrap_or(missing); + let right = actual_records + .record(index) + .map(|record| record.line) + .unwrap_or(missing); + let _ = writeln!(&mut message, "{marker} {} expected: {left}", index + 1); + let _ = writeln!(&mut message, "{marker} {} actual: {right}", index + 1); + } + let _ = writeln!( + &mut message, + "expected record count: {}", + expected_records.record_count() + ); + let _ = writeln!( + &mut message, + "actual record count: {}", + actual_records.record_count() + ); + Ok(Some(message)) +} + +pub(super) const fn comparison_order() -> [ExpectationSurface; 8] { + [ + ExpectationSurface::Tokens, + ExpectationSurface::ParseErrors, + ExpectationSurface::ImplementationDiagnostics, + ExpectationSurface::DocumentMode, + ExpectationSurface::Tree, + ExpectationSurface::Patches, + ExpectationSurface::Transitions, + ExpectationSurface::UnsupportedFeatures, + ] +} diff --git a/crates/html_test_support/src/parser_fixture/mod.rs b/crates/html_test_support/src/parser_fixture/mod.rs index 8fbc19e4..6a282f68 100644 --- a/crates/html_test_support/src/parser_fixture/mod.rs +++ b/crates/html_test_support/src/parser_fixture/mod.rs @@ -1,5 +1,8 @@ mod disposition; +mod execution; +mod failure_spelling; mod load; +mod mismatch; mod model; mod runner; mod schema; @@ -9,9 +12,10 @@ pub use load::{ FixtureLoadError, FixtureLoadErrorKind, FixtureRepository, FixtureRepositoryPolicy, discover_and_load, }; +pub(crate) use model::ExpectationSurface; pub use model::{ - DeliveryName, DispositionEvaluation, FixtureId, FixtureRunReport, FixtureSourceKind, - ParserTargetKind, ScriptingMode, SnapshotPath, + DeliveryName, DispositionEvaluation, FixtureDeliveryRunReport, FixtureId, FixtureRunReport, + FixtureSourceKind, ParserTargetKind, ScriptingMode, SnapshotPath, }; pub use runner::{ FixtureCorpusFailure, FixtureCorpusRunError, FixtureRunError, run_fixture, run_fixture_corpus, diff --git a/crates/html_test_support/src/parser_fixture/model.rs b/crates/html_test_support/src/parser_fixture/model.rs index be345c94..0a1b2dba 100644 --- a/crates/html_test_support/src/parser_fixture/model.rs +++ b/crates/html_test_support/src/parser_fixture/model.rs @@ -1,8 +1,17 @@ use html::ElementNamespace; -use html::conformance::{CanonicalParserResult, InvariantFailureCode}; +use html::conformance::{ + CanonicalParserResult, InvariantFailureCode, ParserObservationExecutionIdentity, +}; use std::path::PathBuf; pub const FIXTURE_FORMAT_V1: &str = "borrowser-html-parser-fixture-v1"; +pub const FIXTURE_FORMAT_V2: &str = "borrowser-html-parser-fixture-v2"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum FixtureFormatVersion { + V1, + V2, +} #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FixtureId(String); @@ -399,7 +408,7 @@ pub(super) enum FixtureCapability { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum ExpectationSurface { +pub(crate) enum ExpectationSurface { Tokens, ParseErrors, ImplementationDiagnostics, @@ -411,21 +420,71 @@ pub(super) enum ExpectationSurface { FinalInvariants, } +impl ExpectationSurface { + pub(crate) const fn name(self) -> &'static str { + match self { + Self::Tokens => "tokens", + Self::ParseErrors => "parse-errors", + Self::ImplementationDiagnostics => "implementation-diagnostics", + Self::DocumentMode => "document-mode", + Self::Tree => "tree", + Self::Patches => "patches", + Self::Transitions => "transitions", + Self::UnsupportedFeatures => "unsupported-features", + Self::FinalInvariants => "final-invariants", + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum ExpectedFailureClassification { - Execution(ExecutionFailureClass), + Execution(LegacyExecutionFailureClass), ExpectationMismatch(ExpectationSurface), InvariantFailure(InvariantFailureCode), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum ExecutionFailureClass { +pub(super) enum LegacyExecutionFailureClass { SnapshotRead(ExpectationSurface), SnapshotFormat(ExpectationSurface), TokenizerDriver, ValidatedFixtureInvariant, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ExecutionFailureClass { + SnapshotRead(ExpectationSurface), + SnapshotFormat(ExpectationSurface), + ParserObservation(ParserObservationFailureClass), + ValidatedFixtureInvariant(ValidatedFixtureInvariantCode), +} + +pub(super) type ParserObservationFailureClass = ParserObservationExecutionIdentity; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ValidatedFixtureInvariantCode { + PlannedReferenceDeliveryMissing, + PlannedDeliveryMissing, + DuplicatePlannedDelivery, + RequestedSurfaceUnexpectedlyNotRequested, + RequestedSurfaceUnexpectedlyNotApplicable, + UnrequestedSurfaceUnexpectedlyCaptured, + UnrequestedSurfaceUnexpectedlyIncomplete, + SnapshotVariantSurfaceContradiction, + CanonicalSerializerSurfaceContradiction, + ComparisonSurfaceContradiction, + MissingExecutedDeliveryResult, + DuplicateExecutedDeliveryResult, + DuplicateExpectationIdentity, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ExpectedFailureClassificationV2 { + Execution(ExecutionFailureClass), + ExpectationMismatch(ExpectationSurface), + FinalInvariant(InvariantFailureCode), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum SkipClassification { UnsupportedCapability(FixtureCapability), @@ -450,6 +509,11 @@ pub(super) enum FixtureDisposition { failure: ExpectedFailureClassification, reference: DispositionReference, }, + ExpectedFailureV2 { + reason: String, + failure: ExpectedFailureClassificationV2, + reference: DispositionReference, + }, Skipped { reason: String, classification: SkipClassification, @@ -459,15 +523,26 @@ pub(super) enum FixtureDisposition { #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum FixtureExecutionOutcome { - NotExecuted, + NotExecuted { + classification: SkipClassification, + }, Completed { result: Box, }, + CompletedV2 { + deliveries: Vec, + reference_delivery: Option, + }, ExpectationMismatch { result: Box, surface: ExpectationSurface, diff: String, }, + ExpectationMismatchV2 { + delivery: DeliveryName, + surface: ExpectationSurface, + diff: String, + }, UnsupportedExpectation { surface: ExpectationSurface, }, @@ -475,6 +550,10 @@ pub(super) enum FixtureExecutionOutcome { capability: FixtureCapability, }, ExecutionFailed { + class: LegacyExecutionFailureClass, + message: String, + }, + ExecutionFailedV2 { class: ExecutionFailureClass, message: String, }, @@ -485,6 +564,13 @@ pub(super) enum FixtureExecutionOutcome { IncompleteObservation { result: Box, }, + IncompleteObservationV2 { + delivery: DeliveryName, + surface: ExpectationSurface, + reason: html::conformance::IncompleteObservationReason, + retained: usize, + dropped: u64, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -498,7 +584,35 @@ pub struct FixtureRunReport { fixture_id: FixtureId, repository_relative_path: String, disposition: DispositionEvaluation, - result: Option, + completed_results: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum CompletedFixtureResults { + V1(Box), + V2 { + reference_delivery: Option, + deliveries: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FixtureDeliveryRunReport { + delivery: DeliveryName, + result: CanonicalParserResult, +} + +impl FixtureDeliveryRunReport { + pub(super) fn new(delivery: DeliveryName, result: CanonicalParserResult) -> Self { + Self { delivery, result } + } + + pub fn delivery(&self) -> &DeliveryName { + &self.delivery + } + pub fn result(&self) -> &CanonicalParserResult { + &self.result + } } impl FixtureRunReport { @@ -512,7 +626,25 @@ impl FixtureRunReport { fixture_id, repository_relative_path, disposition, - result, + completed_results: result.map(|result| CompletedFixtureResults::V1(Box::new(result))), + } + } + + pub(super) fn new_v2( + fixture_id: FixtureId, + repository_relative_path: String, + disposition: DispositionEvaluation, + reference_delivery: Option<&DeliveryName>, + delivery_results: Vec, + ) -> Self { + Self { + fixture_id, + repository_relative_path, + disposition, + completed_results: Some(CompletedFixtureResults::V2 { + reference_delivery: reference_delivery.cloned(), + deliveries: delivery_results, + }), } } @@ -529,6 +661,24 @@ impl FixtureRunReport { } pub fn result(&self) -> Option<&CanonicalParserResult> { - self.result.as_ref() + match self.completed_results.as_ref()? { + CompletedFixtureResults::V1(result) => Some(result), + CompletedFixtureResults::V2 { + reference_delivery, + deliveries, + } => reference_delivery.as_ref().and_then(|reference| { + deliveries + .iter() + .find(|delivery| delivery.delivery() == reference) + .map(FixtureDeliveryRunReport::result) + }), + } + } + + pub fn delivery_results(&self) -> &[FixtureDeliveryRunReport] { + match &self.completed_results { + Some(CompletedFixtureResults::V2 { deliveries, .. }) => deliveries, + Some(CompletedFixtureResults::V1(_)) | None => &[], + } } } diff --git a/crates/html_test_support/src/parser_fixture/runner.rs b/crates/html_test_support/src/parser_fixture/runner.rs index 04ef9dcf..92957298 100644 --- a/crates/html_test_support/src/parser_fixture/runner.rs +++ b/crates/html_test_support/src/parser_fixture/runner.rs @@ -1,11 +1,23 @@ use super::disposition::{DispositionEvaluationError, evaluate_disposition}; -use super::load::read_regular_file; +use super::execution::{ + FixtureObservationGuardrails, RequestedSurfaces, build_delivery_plan, observation_request, +}; +use super::failure_spelling::{parser_observation_failure_name, runner_invariant_name}; +use super::load::{FixtureFileAccess, ProductionFixtureFileAccess}; +use super::mismatch::{compare_snapshots, comparison_order}; use super::model::*; use super::validate::ValidatedFixtureSpec; use crate::diff_lines; +use crate::parser_snapshot::{ + CanonicalSnapshot, ParsedSnapshot, read_snapshot, serialize_snapshot, +}; use crate::token_snapshot::read_html5_token_v1; use crate::wpt_tokenizer::run_tokenizer_whole_observed; -use html::conformance::{CanonicalParserResult, ObservationState}; +use html::conformance::{ + CanonicalParserResult, IncompleteObservationReason, ObservationState, + ParserObservationExecutionError, ParserObservationRequest, ParserObservationTarget, + execute_parser_observation, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct FixtureRunError { @@ -28,7 +40,7 @@ impl std::fmt::Display for FixtureRunError { match &self.details { Some(FixtureFailureDetails::Message(message)) => write!(f, "\n{message}"), Some(FixtureFailureDetails::ExpectationDiff { surface, diff }) => { - write!(f, "\n{surface:?} expectation mismatch\n{diff}") + write!(f, "\n{} expectation mismatch\n{diff}", surface.name()) } None => Ok(()), } @@ -110,29 +122,90 @@ pub fn run_fixture_corpus( } pub fn run_fixture(fixture: &ValidatedFixtureSpec) -> Result { - let outcome = if matches!(fixture.disposition(), FixtureDisposition::Skipped { .. }) { - FixtureExecutionOutcome::NotExecuted + run_fixture_with_executor_and_access( + fixture, + &mut ProductionObservationExecutor, + &mut ProductionFixtureFileAccess, + ) +} + +#[cfg(test)] +pub(super) fn run_fixture_with_executor( + fixture: &ValidatedFixtureSpec, + executor: &mut impl ParserObservationExecutor, +) -> Result { + run_fixture_with_executor_and_access(fixture, executor, &mut ProductionFixtureFileAccess) +} + +pub(super) fn run_fixture_with_executor_and_access( + fixture: &ValidatedFixtureSpec, + executor: &mut impl ParserObservationExecutor, + file_access: &mut impl FixtureFileAccess, +) -> Result { + let outcome = if let FixtureDisposition::Skipped { classification, .. } = fixture.disposition() + { + FixtureExecutionOutcome::NotExecuted { + classification: classification.clone(), + } } else { - execute_fixture(fixture) + match fixture.format() { + FixtureFormatVersion::V1 => execute_fixture_v1(fixture, file_access), + FixtureFormatVersion::V2 => { + execute_fixture_v2_with_access(fixture, executor, file_access) + } + } }; - let details = failure_details(&outcome); + let details = failure_details(fixture, &outcome); let disposition = evaluate_disposition(fixture.disposition(), &outcome) .map_err(|policy| FixtureRunError { policy, details })?; - let result = match (fixture.disposition(), outcome) { + match (fixture.disposition(), outcome) { (FixtureDisposition::Active, FixtureExecutionOutcome::Completed { result }) => { - Some(*result) + Ok(FixtureRunReport::new( + fixture.id().clone(), + fixture.repository_relative_path().to_string(), + disposition, + Some(*result), + )) } - _ => None, - }; - Ok(FixtureRunReport::new( - fixture.id().clone(), - fixture.repository_relative_path().to_string(), - disposition, - result, - )) + ( + FixtureDisposition::Active, + FixtureExecutionOutcome::CompletedV2 { + deliveries, + reference_delivery, + }, + ) => Ok(FixtureRunReport::new_v2( + fixture.id().clone(), + fixture.repository_relative_path().to_string(), + disposition, + reference_delivery.as_ref(), + deliveries, + )), + _ => Ok(FixtureRunReport::new( + fixture.id().clone(), + fixture.repository_relative_path().to_string(), + disposition, + None, + )), + } } +#[cfg(test)] pub(super) fn execute_fixture(fixture: &ValidatedFixtureSpec) -> FixtureExecutionOutcome { + let mut file_access = ProductionFixtureFileAccess; + match fixture.format() { + FixtureFormatVersion::V1 => execute_fixture_v1(fixture, &mut file_access), + FixtureFormatVersion::V2 => execute_fixture_v2_with_access( + fixture, + &mut ProductionObservationExecutor, + &mut file_access, + ), + } +} + +fn execute_fixture_v1( + fixture: &ValidatedFixtureSpec, + file_access: &mut impl FixtureFileAccess, +) -> FixtureExecutionOutcome { if let Some(extension) = fixture.required_unknown_extensions().first() { return FixtureExecutionOutcome::UnsupportedFixtureSemantics { capability: FixtureCapability::UnknownRequiredExtension(extension.clone()), @@ -162,8 +235,8 @@ pub(super) fn execute_fixture(fixture: &ValidatedFixtureSpec) -> FixtureExecutio .iter() .find(|delivery| delivery.name() == fixture.execution().reference_delivery()) else { - return execution_failed( - ExecutionFailureClass::ValidatedFixtureInvariant, + return execution_failed_v1( + LegacyExecutionFailureClass::ValidatedFixtureInvariant, "validated reference delivery is missing", ); }; @@ -174,11 +247,11 @@ pub(super) fn execute_fixture(fixture: &ValidatedFixtureSpec) -> FixtureExecutio let expected = match fixture.expectations().tokens() { ExpectedSurface::NotDeclared => None, ExpectedSurface::Compare(path) => { - let bytes = match read_regular_file(fixture.bundle(), path.as_str()) { + let bytes = match file_access.read_regular_file(fixture.bundle(), path.as_str()) { Ok(bytes) => bytes, Err(error) => { - return execution_failed( - ExecutionFailureClass::SnapshotRead(ExpectationSurface::Tokens), + return execution_failed_v1( + LegacyExecutionFailureClass::SnapshotRead(ExpectationSurface::Tokens), &error.to_string(), ); } @@ -186,8 +259,8 @@ pub(super) fn execute_fixture(fixture: &ValidatedFixtureSpec) -> FixtureExecutio match read_html5_token_v1(&bytes) { Ok(lines) => Some(lines), Err(error) => { - return execution_failed( - ExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens), + return execution_failed_v1( + LegacyExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens), &format!( "fixture {}/{}: {error}", fixture.repository_relative_path(), @@ -201,7 +274,7 @@ pub(super) fn execute_fixture(fixture: &ValidatedFixtureSpec) -> FixtureExecutio let run = match run_tokenizer_whole_observed(text, fixture.id().as_str()) { Ok(run) => run, Err(error) => { - return execution_failed(ExecutionFailureClass::TokenizerDriver, &error); + return execution_failed_v1(LegacyExecutionFailureClass::TokenizerDriver, &error); } }; let result = CanonicalParserResult { @@ -230,6 +303,567 @@ pub(super) fn execute_fixture(fixture: &ValidatedFixtureSpec) -> FixtureExecutio finalize_result(result, mismatch) } +#[derive(Debug)] +struct ParsedExpectation { + surface: ExpectationSurface, + path: SnapshotPath, + transition_delivery: Option, + snapshot: ParsedSnapshot, +} + +#[derive(Debug)] +struct ExecutedDelivery { + name: DeliveryName, + surfaces: RequestedSurfaces, + result: CanonicalParserResult, +} + +#[derive(Debug)] +struct SerializedDelivery { + name: DeliveryName, + snapshots: Vec, +} + +pub(super) trait ParserObservationExecutor { + fn execute( + &mut self, + request: ParserObservationRequest<'_>, + ) -> Result; +} + +struct ProductionObservationExecutor; + +impl ParserObservationExecutor for ProductionObservationExecutor { + fn execute( + &mut self, + request: ParserObservationRequest<'_>, + ) -> Result { + execute_parser_observation(request) + } +} + +#[cfg(test)] +pub(super) fn execute_fixture_v2_with( + fixture: &ValidatedFixtureSpec, + executor: &mut impl ParserObservationExecutor, +) -> FixtureExecutionOutcome { + execute_fixture_v2_with_access(fixture, executor, &mut ProductionFixtureFileAccess) +} + +pub(super) fn execute_fixture_v2_with_access( + fixture: &ValidatedFixtureSpec, + executor: &mut impl ParserObservationExecutor, + file_access: &mut impl FixtureFileAccess, +) -> FixtureExecutionOutcome { + execute_fixture_v2_with_guardrails_and_access( + fixture, + executor, + FixtureObservationGuardrails::PRODUCTION, + file_access, + ) +} + +#[cfg(test)] +pub(super) fn execute_fixture_v2_with_guardrails( + fixture: &ValidatedFixtureSpec, + executor: &mut impl ParserObservationExecutor, + guardrails: FixtureObservationGuardrails, +) -> FixtureExecutionOutcome { + execute_fixture_v2_with_guardrails_and_access( + fixture, + executor, + guardrails, + &mut ProductionFixtureFileAccess, + ) +} + +pub(super) fn execute_fixture_v2_with_guardrails_and_access( + fixture: &ValidatedFixtureSpec, + executor: &mut impl ParserObservationExecutor, + guardrails: FixtureObservationGuardrails, + file_access: &mut impl FixtureFileAccess, +) -> FixtureExecutionOutcome { + if let Some(extension) = fixture.required_unknown_extensions().first() { + return unsupported(FixtureCapability::UnknownRequiredExtension( + extension.clone(), + )); + } + if let Some(surface) = first_unsupported_expectation_v2(fixture) { + return FixtureExecutionOutcome::UnsupportedExpectation { surface }; + } + if let Some(capability) = first_unsupported_semantics_v2(fixture) { + return unsupported(capability); + } + + let expectations = match read_expected_snapshots_v2(fixture, file_access) { + Ok(expectations) => expectations, + Err(outcome) => return outcome, + }; + let plan = match build_delivery_plan(fixture) { + Ok(plan) => plan, + Err(code) => { + return execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant(code), + runner_invariant_name(code), + ); + } + }; + let ExactInput::Utf8Text { text, .. } = fixture.input() else { + return unsupported(FixtureCapability::RawByteInput); + }; + let target = match fixture.execution().target() { + ValidatedParserTarget::StandaloneTokenizer => ParserObservationTarget::StandaloneTokenizer, + ValidatedParserTarget::Document { .. } => ParserObservationTarget::DocumentParser, + ValidatedParserTarget::Fragment { .. } => { + return unsupported(FixtureCapability::FragmentParsing); + } + }; + + // Execute every planned delivery before state validation or comparison. + let mut executed = Vec::with_capacity(plan.len()); + for planned in &plan { + let request = observation_request(target, text, planned.surfaces, guardrails); + match executor.execute(request) { + Ok(result) => executed.push(ExecutedDelivery { + name: planned.name.clone(), + surfaces: planned.surfaces, + result, + }), + Err(error) => { + let identity = error.identity(); + return execution_failed_v2( + ExecutionFailureClass::ParserObservation(identity), + &format!( + "fixture {} delivery {}: parser observation failure {}", + fixture.id().as_str(), + planned.name.as_str(), + parser_observation_failure_name(identity) + ), + ); + } + } + } + + for delivery in &executed { + if let Some(issue) = first_state_issue(&delivery.result, delivery.surfaces) { + return match issue { + StateIssue::Incomplete { + surface, + reason, + retained, + dropped, + } => FixtureExecutionOutcome::IncompleteObservationV2 { + delivery: delivery.name.clone(), + surface, + reason, + retained, + dropped, + }, + StateIssue::Invariant(code) => execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant(code), + &format!( + "fixture {} delivery {}: {}", + fixture.id().as_str(), + delivery.name.as_str(), + runner_invariant_name(code) + ), + ), + }; + } + } + + // Serialize every requested surface before the first comparison. + let mut serialized = Vec::with_capacity(executed.len()); + for delivery in &executed { + let mut snapshots = Vec::new(); + for surface in requested_surface_order(delivery.surfaces) { + let snapshot = + match serialize_snapshot(surface, &delivery.result) { + Ok(snapshot) => snapshot, + Err(()) => return execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant( + ValidatedFixtureInvariantCode::CanonicalSerializerSurfaceContradiction, + ), + runner_invariant_name( + ValidatedFixtureInvariantCode::CanonicalSerializerSurfaceContradiction, + ), + ), + }; + if snapshot.surface() != surface { + return execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant( + ValidatedFixtureInvariantCode::CanonicalSerializerSurfaceContradiction, + ), + runner_invariant_name( + ValidatedFixtureInvariantCode::CanonicalSerializerSurfaceContradiction, + ), + ); + } + snapshots.push(snapshot); + } + serialized.push(SerializedDelivery { + name: delivery.name.clone(), + snapshots, + }); + } + + for expected in &expectations { + let delivery_name = expected + .transition_delivery + .as_ref() + .unwrap_or_else(|| fixture.execution().reference_delivery()); + let Some(delivery) = serialized.iter().find(|value| &value.name == delivery_name) else { + return execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant( + ValidatedFixtureInvariantCode::MissingExecutedDeliveryResult, + ), + runner_invariant_name(ValidatedFixtureInvariantCode::MissingExecutedDeliveryResult), + ); + }; + let Some(actual) = delivery + .snapshots + .iter() + .find(|snapshot| snapshot.surface() == expected.surface) + else { + return execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant( + ValidatedFixtureInvariantCode::CanonicalSerializerSurfaceContradiction, + ), + runner_invariant_name( + ValidatedFixtureInvariantCode::CanonicalSerializerSurfaceContradiction, + ), + ); + }; + match compare_snapshots( + fixture, + expected.transition_delivery.as_ref(), + &expected.path, + &expected.snapshot, + actual, + ) { + Ok(None) => {} + Ok(Some(diff)) => { + return FixtureExecutionOutcome::ExpectationMismatchV2 { + delivery: delivery_name.clone(), + surface: expected.surface, + diff, + }; + } + Err(code) => { + return execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant(code), + runner_invariant_name(code), + ); + } + } + } + + let deliveries = executed + .into_iter() + .map(|delivery| FixtureDeliveryRunReport::new(delivery.name, delivery.result)) + .collect(); + FixtureExecutionOutcome::CompletedV2 { + deliveries, + reference_delivery: RequestedSurfaces::ordinary(fixture.expectations()) + .any() + .then(|| fixture.execution().reference_delivery().clone()), + } +} + +fn first_unsupported_expectation_v2(fixture: &ValidatedFixtureSpec) -> Option { + if fixture + .expectations() + .is_declared(ExpectationSurface::FinalInvariants) + { + return Some(ExpectationSurface::FinalInvariants); + } + if matches!( + fixture.execution().target(), + ValidatedParserTarget::StandaloneTokenizer + ) { + for surface in [ + ExpectationSurface::DocumentMode, + ExpectationSurface::Tree, + ExpectationSurface::Patches, + ExpectationSurface::Transitions, + ] { + if fixture.expectations().is_declared(surface) { + return Some(surface); + } + } + } + None +} + +fn first_unsupported_semantics_v2(fixture: &ValidatedFixtureSpec) -> Option { + match fixture.execution().target() { + ValidatedParserTarget::Fragment { .. } => return Some(FixtureCapability::FragmentParsing), + ValidatedParserTarget::StandaloneTokenizer | ValidatedParserTarget::Document { .. } => {} + } + if matches!(fixture.input(), ExactInput::RawBytes { .. }) { + return Some(FixtureCapability::RawByteInput); + } + if matches!( + fixture.execution().target(), + ValidatedParserTarget::Document { + scripting: ScriptingMode::Enabled + } + ) { + return Some(FixtureCapability::ScriptingEnabled); + } + for delivery in fixture.execution().deliveries() { + match delivery { + ValidatedDelivery::WholeBytes { .. } | ValidatedDelivery::ByteBoundaries { .. } => { + return Some(FixtureCapability::ByteDelivery); + } + ValidatedDelivery::UnicodeScalarBoundaries { .. } => { + return Some(FixtureCapability::UnicodeScalarChunking); + } + ValidatedDelivery::WholeUnicodeScalars { .. } => {} + } + } + None +} + +fn read_expected_snapshots_v2( + fixture: &ValidatedFixtureSpec, + file_access: &mut impl FixtureFileAccess, +) -> Result, FixtureExecutionOutcome> { + let mut parsed = Vec::new(); + for (surface, expected) in [ + (ExpectationSurface::Tokens, fixture.expectations().tokens()), + ( + ExpectationSurface::ParseErrors, + fixture.expectations().parse_errors(), + ), + ( + ExpectationSurface::ImplementationDiagnostics, + fixture.expectations().implementation_diagnostics(), + ), + ( + ExpectationSurface::DocumentMode, + fixture.expectations().document_mode(), + ), + (ExpectationSurface::Tree, fixture.expectations().tree()), + ( + ExpectationSurface::Patches, + fixture.expectations().patches(), + ), + ] { + if let ExpectedSurface::Compare(path) = expected { + parsed.push(read_one_expected( + fixture, + surface, + path, + None, + file_access, + )?); + } + } + if let ExpectedSurface::Compare(transitions) = fixture.expectations().transitions() { + for transition in transitions { + parsed.push(read_one_expected( + fixture, + ExpectationSurface::Transitions, + transition.path(), + Some(transition.delivery().clone()), + file_access, + )?); + } + } + if let ExpectedSurface::Compare(path) = fixture.expectations().unsupported_features() { + parsed.push(read_one_expected( + fixture, + ExpectationSurface::UnsupportedFeatures, + path, + None, + file_access, + )?); + } + Ok(parsed) +} + +fn read_one_expected( + fixture: &ValidatedFixtureSpec, + surface: ExpectationSurface, + path: &SnapshotPath, + transition_delivery: Option, + file_access: &mut impl FixtureFileAccess, +) -> Result { + let bytes = file_access + .read_regular_file(fixture.bundle(), path.as_str()) + .map_err(|error| { + execution_failed_v2( + ExecutionFailureClass::SnapshotRead(surface), + &format!( + "fixture {} surface {} expected snapshot {}/{}: {}", + fixture.id().as_str(), + surface.name(), + fixture.repository_relative_path(), + path.as_str(), + error + ), + ) + })?; + let snapshot = read_snapshot(surface, &bytes).map_err(|error| { + execution_failed_v2( + ExecutionFailureClass::SnapshotFormat(surface), + &format!( + "fixture {} surface {} expected snapshot {}/{} format {}: {}", + fixture.id().as_str(), + surface.name(), + fixture.repository_relative_path(), + path.as_str(), + snapshot_format_name(surface), + error + ), + ) + })?; + if snapshot.surface() != surface { + return Err(execution_failed_v2( + ExecutionFailureClass::ValidatedFixtureInvariant( + ValidatedFixtureInvariantCode::SnapshotVariantSurfaceContradiction, + ), + runner_invariant_name( + ValidatedFixtureInvariantCode::SnapshotVariantSurfaceContradiction, + ), + )); + } + Ok(ParsedExpectation { + surface, + path: path.clone(), + transition_delivery, + snapshot, + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum StateIssue { + Incomplete { + surface: ExpectationSurface, + reason: IncompleteObservationReason, + retained: usize, + dropped: u64, + }, + Invariant(ValidatedFixtureInvariantCode), +} + +pub(super) fn first_state_issue( + result: &CanonicalParserResult, + requested: RequestedSurfaces, +) -> Option { + macro_rules! check { + ($surface:expr, $state:expr, $requested:expr) => { + match ($requested, $state) { + (true, ObservationState::Incomplete { reason, .. }) => { + let (retained, dropped) = incomplete_counts(reason); + return Some(StateIssue::Incomplete { + surface: $surface, + reason: reason.clone(), + retained, + dropped, + }); + } + (false, ObservationState::Incomplete { .. }) => { + return Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyIncomplete, + )); + } + (true, ObservationState::NotRequested) => { + return Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::RequestedSurfaceUnexpectedlyNotRequested, + )) + } + (true, ObservationState::NotApplicable { .. }) => { + return Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::RequestedSurfaceUnexpectedlyNotApplicable, + )) + } + (false, ObservationState::Captured(_)) => { + return Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyCaptured, + )) + } + (true, ObservationState::Captured(_)) + | (false, ObservationState::NotRequested) + | (false, ObservationState::NotApplicable { .. }) => {} + } + }; + } + check!(ExpectationSurface::Tokens, &result.tokens, requested.tokens); + check!( + ExpectationSurface::ParseErrors, + &result.parse_errors, + requested.parse_errors + ); + check!( + ExpectationSurface::ImplementationDiagnostics, + &result.implementation_diagnostics, + requested.implementation_diagnostics + ); + check!( + ExpectationSurface::DocumentMode, + &result.document_mode, + requested.document_mode + ); + check!(ExpectationSurface::Tree, &result.tree, requested.tree); + check!( + ExpectationSurface::Patches, + &result.patches, + requested.patches + ); + check!( + ExpectationSurface::Transitions, + &result.transitions, + requested.transitions + ); + check!( + ExpectationSurface::UnsupportedFeatures, + &result.unsupported_features, + requested.unsupported_features + ); + match &result.final_invariants { + ObservationState::NotRequested => {} + ObservationState::Incomplete { .. } => { + return Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyIncomplete, + )); + } + ObservationState::Captured(_) => { + return Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyCaptured, + )); + } + ObservationState::NotApplicable { .. } => {} + } + None +} + +fn incomplete_counts(reason: &IncompleteObservationReason) -> (usize, u64) { + match reason { + IncompleteObservationReason::StorageLimitExceeded { retained, dropped } => { + (*retained, *dropped) + } + } +} + +fn requested_surface_order(requested: RequestedSurfaces) -> Vec { + comparison_order() + .into_iter() + .filter(|surface| match surface { + ExpectationSurface::Tokens => requested.tokens, + ExpectationSurface::ParseErrors => requested.parse_errors, + ExpectationSurface::ImplementationDiagnostics => requested.implementation_diagnostics, + ExpectationSurface::DocumentMode => requested.document_mode, + ExpectationSurface::Tree => requested.tree, + ExpectationSurface::Patches => requested.patches, + ExpectationSurface::Transitions => requested.transitions, + ExpectationSurface::UnsupportedFeatures => requested.unsupported_features, + ExpectationSurface::FinalInvariants => false, + }) + .collect() +} + fn finalize_result( result: CanonicalParserResult, mismatch: Option<(ExpectationSurface, String)>, @@ -258,9 +892,13 @@ fn finalize_result( } } -fn failure_details(outcome: &FixtureExecutionOutcome) -> Option { +pub(super) fn failure_details( + fixture: &ValidatedFixtureSpec, + outcome: &FixtureExecutionOutcome, +) -> Option { match outcome { - FixtureExecutionOutcome::ExpectationMismatch { surface, diff, .. } => { + FixtureExecutionOutcome::ExpectationMismatch { surface, diff, .. } + | FixtureExecutionOutcome::ExpectationMismatchV2 { surface, diff, .. } => { Some(FixtureFailureDetails::ExpectationDiff { surface: *surface, diff: diff.clone(), @@ -269,12 +907,38 @@ fn failure_details(outcome: &FixtureExecutionOutcome) -> Option { Some(FixtureFailureDetails::Message(message.clone())) } - FixtureExecutionOutcome::NotExecuted + FixtureExecutionOutcome::NotExecuted { .. } | FixtureExecutionOutcome::Completed { .. } + | FixtureExecutionOutcome::CompletedV2 { .. } | FixtureExecutionOutcome::UnsupportedExpectation { .. } | FixtureExecutionOutcome::UnsupportedFixtureSemantics { .. } | FixtureExecutionOutcome::InvariantFailed { .. } | FixtureExecutionOutcome::IncompleteObservation { .. } => None, + FixtureExecutionOutcome::IncompleteObservationV2 { + delivery, + surface, + reason, + retained, + dropped, + } => Some(FixtureFailureDetails::Message(format!( + "fixture {} path {}: incomplete observation; delivery: {}; surface: {}; reason: {}; retained count: {}; dropped count: {}", + fixture.id().as_str(), + fixture.repository_relative_path(), + delivery.as_str(), + surface.name(), + incomplete_reason_name(reason), + retained, + dropped + ))), + FixtureExecutionOutcome::ExecutionFailedV2 { message, .. } => { + Some(FixtureFailureDetails::Message(message.clone())) + } + } +} + +fn incomplete_reason_name(reason: &IncompleteObservationReason) -> &'static str { + match reason { + IncompleteObservationReason::StorageLimitExceeded { .. } => "storage-limit-exceeded", } } @@ -312,9 +976,33 @@ fn unsupported(capability: FixtureCapability) -> FixtureExecutionOutcome { FixtureExecutionOutcome::UnsupportedFixtureSemantics { capability } } -fn execution_failed(class: ExecutionFailureClass, message: &str) -> FixtureExecutionOutcome { +fn execution_failed_v1( + class: LegacyExecutionFailureClass, + message: &str, +) -> FixtureExecutionOutcome { FixtureExecutionOutcome::ExecutionFailed { class, message: message.to_string(), } } + +fn execution_failed_v2(class: ExecutionFailureClass, message: &str) -> FixtureExecutionOutcome { + FixtureExecutionOutcome::ExecutionFailedV2 { + class, + message: message.to_string(), + } +} + +fn snapshot_format_name(surface: ExpectationSurface) -> &'static str { + match surface { + ExpectationSurface::Tokens => "html5-token-v2", + ExpectationSurface::ParseErrors => "html5-parse-errors-v1", + ExpectationSurface::ImplementationDiagnostics => "html5-implementation-diagnostics-v1", + ExpectationSurface::DocumentMode => "html5-document-mode-v1", + ExpectationSurface::Tree => "html5-dom-v3", + ExpectationSurface::Patches => "html5-dompatch-v3", + ExpectationSurface::Transitions => "html5-tree-transitions-v1", + ExpectationSurface::UnsupportedFeatures => "html5-unsupported-features-v1", + ExpectationSurface::FinalInvariants => "unsupported-final-invariants", + } +} diff --git a/crates/html_test_support/src/parser_fixture/schema.rs b/crates/html_test_support/src/parser_fixture/schema.rs index c66911e9..7225081e 100644 --- a/crates/html_test_support/src/parser_fixture/schema.rs +++ b/crates/html_test_support/src/parser_fixture/schema.rs @@ -17,6 +17,29 @@ pub struct FixtureFileV1 { pub extensions: BTreeMap, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixtureFileV2 { + pub format: String, + pub id: String, + pub source: FixtureSourceDeclaration, + pub input: InputDeclaration, + pub execution: ExecutionDeclaration, + pub expectations: FixtureExpectationDeclarations, + pub disposition: FixtureDispositionDeclarationV2, + #[serde(default)] + pub metadata: FixtureMetadataDeclaration, + #[serde(default)] + pub extensions: BTreeMap, +} + +/// Minimal typed dispatch envelope. This selects a complete versioned schema; +/// it is not a permissive common fixture declaration. +#[derive(Clone, Debug, Deserialize)] +pub struct FixtureFormatEnvelope { + pub format: String, +} + #[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct FixtureMetadataDeclaration { @@ -159,6 +182,17 @@ pub struct FixtureDispositionDeclaration { pub reference: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixtureDispositionDeclarationV2 { + pub status: FixtureDispositionStatusDeclaration, + pub reason: Option, + pub capability: Option, + pub failure: Option, + pub classification: Option, + pub reference: Option, +} + #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum DispositionReferenceKindDeclaration { @@ -235,6 +269,41 @@ pub enum ExpectedFailureDeclaration { LiveTreeMismatchInvariant, } +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ExpectationSurfaceDeclaration { + Tokens, + ParseErrors, + ImplementationDiagnostics, + DocumentMode, + Tree, + Patches, + Transitions, + UnsupportedFeatures, + FinalInvariants, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ExpectedFailureKindDeclarationV2 { + SnapshotRead, + SnapshotFormat, + ParserObservation, + ValidatedRunnerInvariant, + ExpectationMismatch, + FinalInvariant, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ExpectedFailureDeclarationV2 { + pub kind: ExpectedFailureKindDeclarationV2, + pub surface: Option, + pub identity: Option, + pub code: Option, + pub site: Option, +} + #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum SkipClassificationKindDeclaration { diff --git a/crates/html_test_support/src/parser_fixture/tests.rs b/crates/html_test_support/src/parser_fixture/tests.rs index bd67b9a5..ea1fcd40 100644 --- a/crates/html_test_support/src/parser_fixture/tests.rs +++ b/crates/html_test_support/src/parser_fixture/tests.rs @@ -4,8 +4,10 @@ use super::runner::{FixtureFailureDetails, execute_fixture}; use super::*; use html::conformance::{ CanonicalParserResult, IncompleteObservationReason, InvariantFailureCode, ObservationState, + ParserObservationExecutionError, ParserObservationRequest, }; use ring::digest::{SHA256, digest}; +use std::collections::BTreeSet; use std::fmt::Write; use std::fs; use std::path::{Path, PathBuf}; @@ -17,6 +19,72 @@ struct TestRepository { fixture_root: PathBuf, } +#[derive(Default)] +struct RecordingFileAccess { + metadata_checks: Vec, + content_reads: Vec, + fail_content_reads: BTreeSet, +} + +#[derive(Default)] +struct CountingObservationExecutor { + calls: usize, +} + +impl super::runner::ParserObservationExecutor for CountingObservationExecutor { + fn execute( + &mut self, + _: ParserObservationRequest<'_>, + ) -> Result { + self.calls += 1; + Ok(canonical_result()) + } +} + +impl super::load::FixtureFileAccess for RecordingFileAccess { + fn validate_regular_file_metadata( + &mut self, + bundle: &FixtureBundle, + relative: &str, + ) -> Result<(), FixtureLoadError> { + self.metadata_checks.push(relative.to_string()); + super::load::validate_regular_file_metadata(bundle, relative) + } + + fn read_regular_file( + &mut self, + bundle: &FixtureBundle, + relative: &str, + ) -> Result, FixtureLoadError> { + self.content_reads.push(relative.to_string()); + if self.fail_content_reads.contains(relative) { + return Err(FixtureLoadError { + path: format!("{}/{}", bundle.repository_relative_path(), relative), + kind: FixtureLoadErrorKind::Io( + "injected complete-content read failure".to_string(), + ), + }); + } + super::load::read_regular_file(bundle, relative) + } +} + +impl RecordingFileAccess { + fn metadata_count(&self, relative: &str) -> usize { + self.metadata_checks + .iter() + .filter(|value| value.as_str() == relative) + .count() + } + + fn content_read_count(&self, relative: &str) -> usize { + self.content_reads + .iter() + .filter(|value| value.as_str() == relative) + .count() + } +} + impl TestRepository { fn new() -> Self { let temp = tempfile::tempdir().expect("temporary repository"); @@ -90,6 +158,26 @@ status = "active" ) } +fn add_fixture_v2(repository: &TestRepository, directory: &str, id: &str, input: &[u8]) -> PathBuf { + let bundle = repository.fixture_root.join(directory); + fs::create_dir_all(&bundle).expect("bundle"); + fs::write(bundle.join("input.html"), input).expect("input"); + fs::write( + bundle.join("tokens.txt"), + "# format: html5-token-v2\nTOKEN ordinal=1 kind=character data=\"hello\"\nTOKEN ordinal=2 kind=eof\n", + ) + .expect("tokens"); + fs::write(bundle.join("fixture.toml"), fixture_toml_v2(id, input)).expect("metadata"); + bundle +} + +fn fixture_toml_v2(id: &str, input: &[u8]) -> String { + fixture_toml(id, input).replace( + "borrowser-html-parser-fixture-v1", + "borrowser-html-parser-fixture-v2", + ) +} + fn rewrite(path: &Path, transform: impl FnOnce(String) -> String) { let original = fs::read_to_string(path).expect("read fixture metadata"); fs::write(path, transform(original)).expect("rewrite fixture metadata"); @@ -109,6 +197,1385 @@ fn load_single_native_fixture(repository: &TestRepository) -> ValidatedFixtureSp fixtures.remove(0) } +#[test] +fn fixture_loader_dispatches_exact_v1_and_v2_schemas_before_validation() { + let repository = TestRepository::new(); + add_fixture(&repository, "legacy", "legacy", b"hello"); + add_fixture_v2(&repository, "canonical", "canonical", b"hello"); + let fixtures = discover_and_load(&repository.native()).expect("both versions load"); + assert_eq!(fixtures.len(), 2); + assert!( + fixtures + .iter() + .any(|fixture| fixture.format() == FixtureFormatVersion::V1) + ); + assert!( + fixtures + .iter() + .any(|fixture| fixture.format() == FixtureFormatVersion::V2) + ); + + let unknown = TestRepository::new(); + let bundle = add_fixture_v2(&unknown, "unknown", "unknown", b"hello"); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace(FIXTURE_FORMAT_V2, "borrowser-html-parser-fixture-v99") + }); + assert!( + matches!(discover_and_load(&unknown.native()).unwrap_err().kind, FixtureLoadErrorKind::UnsupportedFixtureFormat(ref value) if value == "borrowser-html-parser-fixture-v99") + ); + + let strict = TestRepository::new(); + let bundle = add_fixture_v2(&strict, "strict", "strict", b"hello"); + rewrite(&bundle.join("fixture.toml"), |text| { + format!("{text}\nunknown = true\n") + }); + assert!(matches!( + discover_and_load(&strict.native()).unwrap_err().kind, + FixtureLoadErrorKind::InvalidFixtureToml(_) + )); +} + +#[test] +fn fixture_v1_active_discovery_reads_sidecar_and_execution_rereads_it() { + let repository = TestRepository::new(); + add_fixture(&repository, "legacy-active", "legacy-active", b"hello"); + let mut file_access = RecordingFileAccess::default(); + let fixture = + super::load::discover_and_load_with_access(&repository.native(), &mut file_access) + .expect("fixture-v1 discovery reads a valid sidecar") + .remove(0); + + assert_eq!(file_access.metadata_count("tokens.txt"), 0); + assert_eq!(file_access.content_read_count("tokens.txt"), 1); + + let mut executor = CountingObservationExecutor::default(); + let report = super::runner::run_fixture_with_executor_and_access( + &fixture, + &mut executor, + &mut file_access, + ) + .expect("legacy fixture completes after its execution-time reread"); + assert_eq!(file_access.content_read_count("tokens.txt"), 2); + assert_eq!( + executor.calls, 0, + "fixture-v1 keeps its legacy tokenizer path" + ); + assert_eq!(report.disposition(), DispositionEvaluation::Pass); + assert!(report.result().is_some()); +} + +#[test] +fn fixture_v1_sidecar_read_failure_remains_a_discovery_error() { + let repository = TestRepository::new(); + add_fixture( + &repository, + "legacy-unreadable", + "legacy-unreadable", + b"hello", + ); + let mut file_access = RecordingFileAccess { + fail_content_reads: BTreeSet::from(["tokens.txt".to_string()]), + ..RecordingFileAccess::default() + }; + + let error = super::load::discover_and_load_with_access(&repository.native(), &mut file_access) + .expect_err("fixture-v1 unreadable sidecar must fail discovery"); + assert!( + matches!(error.kind, FixtureLoadErrorKind::Io(ref message) if message == "injected complete-content read failure") + ); + assert_eq!(file_access.metadata_count("tokens.txt"), 0); + assert_eq!(file_access.content_read_count("tokens.txt"), 1); +} + +#[test] +fn fixture_v1_skipped_discovery_retains_legacy_sidecar_read() { + let repository = TestRepository::new(); + let bundle = add_fixture(&repository, "legacy-skipped", "legacy-skipped", b"hello"); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace( + "[source]\nkind = \"native\"", + "[source]\nkind = \"external\"\nprovenance = \"upstream/case\"", + ) + .replace( + "kind = \"standalone-tokenizer\"", + "kind = \"fragment\"\nfragment = { namespace = \"html\", local_name = \"div\" }", + ) + .replace( + "status = \"active\"", + "status = \"skipped\"\nreason = \"fragment unavailable\"\nclassification = { kind = \"unsupported-capability\", capability = { kind = \"fragment-parsing\" } }\nreference = { kind = \"tracking-issue\", value = \"#2\" }", + ) + }); + let mut file_access = RecordingFileAccess::default(); + let fixture = + super::load::discover_and_load_with_access(&repository.adapted(), &mut file_access) + .expect("fixture-v1 skipped discovery retains the legacy read") + .remove(0); + assert_eq!(file_access.metadata_count("tokens.txt"), 0); + assert_eq!(file_access.content_read_count("tokens.txt"), 1); + + let mut executor = CountingObservationExecutor::default(); + let report = super::runner::run_fixture_with_executor_and_access( + &fixture, + &mut executor, + &mut file_access, + ) + .expect("validated legacy skip remains not executed"); + assert_eq!(file_access.content_read_count("tokens.txt"), 1); + assert_eq!(executor.calls, 0); + assert_eq!(report.disposition(), DispositionEvaluation::Skip); + assert!(report.result().is_none()); + assert!(report.delivery_results().is_empty()); +} + +#[test] +fn fixture_v1_execution_reread_failure_keeps_legacy_snapshot_read_classification() { + let repository = TestRepository::new(); + add_fixture( + &repository, + "legacy-reread-failure", + "legacy-reread-failure", + b"hello", + ); + let mut file_access = RecordingFileAccess::default(); + let fixture = + super::load::discover_and_load_with_access(&repository.native(), &mut file_access) + .expect("fixture-v1 sidecar is readable during discovery") + .remove(0); + file_access + .fail_content_reads + .insert("tokens.txt".to_string()); + + let mut executor = CountingObservationExecutor::default(); + let error = super::runner::run_fixture_with_executor_and_access( + &fixture, + &mut executor, + &mut file_access, + ) + .expect_err("legacy execution-time reread failure remains classified"); + assert_eq!(file_access.content_read_count("tokens.txt"), 2); + assert_eq!(executor.calls, 0); + assert!(matches!( + error.policy, + DispositionEvaluationError::UnexpectedOutcome { + actual: FixtureOutcomeClassification::ExecutionFailedV1( + LegacyExecutionFailureClass::SnapshotRead(ExpectationSurface::Tokens) + ), + .. + } + )); + assert!(matches!( + error.details, + Some(FixtureFailureDetails::Message(ref message)) + if message.contains("injected complete-content read failure") + )); +} + +#[test] +fn every_fixture_v1_expected_failure_spelling_remains_accepted_unchanged() { + #[derive(serde::Deserialize)] + struct Holder { + failure: ExpectedFailureDeclaration, + } + let spellings = [ + "token-snapshot-read", + "token-snapshot-format", + "tokenizer-driver", + "validated-fixture-invariant", + "tokens-mismatch", + "parse-errors-mismatch", + "implementation-diagnostics-mismatch", + "document-mode-mismatch", + "tree-mismatch", + "patches-mismatch", + "transitions-mismatch", + "unsupported-features-mismatch", + "final-invariants-mismatch", + "decoder-carry-not-empty-invariant", + "preprocessing-not-flushed-invariant", + "eof-emission-invalid-invariant", + "pending-tokenizer-construct-invariant", + "tokenizer-output-unaccounted-invariant", + "pending-table-text-invariant", + "invalid-insertion-mode-invariant", + "open-elements-inconsistent-invariant", + "active-formatting-inconsistent-invariant", + "template-modes-inconsistent-invariant", + "form-pointer-invalid-invariant", + "parent-child-relationship-invalid-invariant", + "namespace-relationship-invalid-invariant", + "template-association-invalid-invariant", + "patch-materialization-incomplete-invariant", + "live-tree-mismatch-invariant", + ]; + for spelling in spellings { + let parsed: Holder = toml::from_str(&format!("failure = \"{spelling}\"")) + .unwrap_or_else(|error| panic!("fixture-v1 spelling {spelling} changed: {error}")); + let _identity = parsed.failure; + } +} + +#[test] +fn fixture_v2_uses_structured_failure_tables_and_rejects_v1_scalar_syntax() { + let structured = fixture_toml_v2("structured", b"hello").replace( + "[disposition]\nstatus = \"active\"", + "[disposition]\nstatus = \"expected-failure\"\nreason = \"known\"\nreference = { kind = \"tracking-issue\", value = \"#1\" }\n\n[disposition.failure]\nkind = \"parser-observation\"\nidentity = \"tokenizer-invariant\"\ncode = \"pending-text-range-invalid\"", + ); + let parsed: FixtureFileV2 = toml::from_str(&structured).expect("structured v2 failure"); + assert!(matches!( + parsed.disposition.failure, + Some(ExpectedFailureDeclarationV2 { + kind: ExpectedFailureKindDeclarationV2::ParserObservation, + .. + }) + )); + + let scalar = structured.replace( + "\n[disposition.failure]\nkind = \"parser-observation\"\nidentity = \"tokenizer-invariant\"\ncode = \"pending-text-range-invalid\"", + "\nfailure = \"tokenizer-driver\"", + ); + assert!(toml::from_str::(&scalar).is_err()); +} + +#[test] +fn fixture_v2_failure_spellings_round_trip_exhaustively_through_one_codec() { + use super::failure_spelling::*; + use html::conformance::{ParserFatalIdentity, ParserObservationExecutionIdentity as I}; + use std::collections::BTreeSet; + + let mut identities = vec![ + I::ParserFatal(ParserFatalIdentity::EngineInvariant), + I::ParserInvariant, + I::TokenCanonicalizationInvariant, + I::TreeTransitionTokenCanonicalizationInvariant, + I::ObservationRecorderMissing, + I::PatchHistoryCaptureMissing, + ]; + identities.extend( + all_parser_reservation_sites() + .iter() + .copied() + .map(|site| I::ParserFatal(ParserFatalIdentity::ResourceExhaustion(site))), + ); + identities.extend( + all_tokenizer_invariants() + .iter() + .copied() + .map(I::TokenizerInvariant), + ); + identities.extend( + all_unsupported_observation_invariants() + .iter() + .copied() + .map(I::UnsupportedFeatureObservationInvariant), + ); + identities.extend( + all_observation_invariants() + .iter() + .copied() + .map(I::ObservationInvariant), + ); + identities.extend( + all_observation_reservation_sites() + .iter() + .copied() + .map(I::ResourceExhaustion), + ); + + let mut names = BTreeSet::new(); + for identity in identities { + let spelling = parser_observation_failure_spelling(identity); + let reparsed = + parse_parser_observation_failure(spelling.identity, spelling.code, spelling.site) + .expect("canonical spelling parses"); + assert_eq!(reparsed, identity); + assert!(names.insert(parser_observation_failure_name(identity))); + } + + for code in all_runner_invariants() { + assert_eq!( + parse_runner_invariant(runner_invariant_name(*code)), + Ok(*code) + ); + } + assert!(parse_runner_invariant("not-a-runner-invariant").is_err()); + assert!( + parse_parser_observation_failure( + "tokenizer-invariant", + Some("not-a-tokenizer-invariant"), + None, + ) + .is_err() + ); + assert!( + parse_parser_observation_failure( + "parser-fatal-resource-exhaustion", + None, + Some("not-a-parser-site"), + ) + .is_err() + ); + assert!( + parse_parser_observation_failure("parser-invariant", Some("contradictory-code"), None,) + .is_err() + ); + assert!( + parse_parser_observation_failure( + "observation-resource-exhaustion", + Some("contradictory-code"), + Some("canonical-tree-projection"), + ) + .is_err() + ); + assert!( + parse_parser_observation_failure("not-a-parser-observation-identity", None, None).is_err() + ); +} + +#[test] +fn disposition_mismatch_names_exact_expected_and_actual_parser_identities() { + use html::conformance::{ + ParserObservationExecutionIdentity as I, ParserTokenizerInvariantError as T, + }; + + let disposition = FixtureDisposition::ExpectedFailureV2 { + reason: "known".to_string(), + failure: ExpectedFailureClassificationV2::Execution( + ExecutionFailureClass::ParserObservation(I::TokenizerInvariant( + T::PendingTextRangeInvalid, + )), + ), + reference: DispositionReference::TrackingIssue("#1".to_string()), + }; + let outcome = FixtureExecutionOutcome::ExecutionFailedV2 { + class: ExecutionFailureClass::ParserObservation(I::ParserInvariant), + message: "wording is not classification".to_string(), + }; + let diagnostic = evaluate_disposition(&disposition, &outcome) + .expect_err("different typed identities do not match") + .to_string(); + assert!( + diagnostic.contains("parser-observation:tokenizer-invariant:pending-text-range-invalid"), + "{diagnostic}" + ); + assert!( + diagnostic.contains("parser-observation:parser-invariant"), + "{diagnostic}" + ); + assert!(!diagnostic.contains("wording is not classification")); +} + +#[test] +fn required_unknown_extensions_are_selected_in_ascii_lexicographic_order() { + fn selected(extension_tables: &str) -> FixtureCapability { + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "extensions", "extensions", b"hello"); + rewrite(&bundle.join("fixture.toml"), |text| { + format!("{text}\n{extension_tables}") + }); + let fixture = load_single_native_fixture(&repository); + match execute_fixture(&fixture) { + FixtureExecutionOutcome::UnsupportedFixtureSemantics { capability } => capability, + other => panic!("unexpected outcome: {other:?}"), + } + } + let first = selected( + "[extensions.\"org.zeta.feature-v1\"]\nrequired = true\nvalue = {}\n[extensions.\"org.alpha.feature-v1\"]\nrequired = true\nvalue = {}\n", + ); + let second = selected( + "[extensions.\"org.alpha.feature-v1\"]\nrequired = true\nvalue = {}\n[extensions.\"org.zeta.feature-v1\"]\nrequired = true\nvalue = {}\n", + ); + assert_eq!( + first, + FixtureCapability::UnknownRequiredExtension("org.alpha.feature-v1".to_string()) + ); + assert_eq!(first, second); +} + +#[test] +fn fixture_v2_skipped_disposition_short_circuits_malformed_sidecars_and_unsupported_delivery() { + use html::conformance::{ParserObservationExecutionError, ParserObservationRequest}; + + struct CountingExecutor { + calls: usize, + } + + impl super::runner::ParserObservationExecutor for CountingExecutor { + fn execute( + &mut self, + _: ParserObservationRequest<'_>, + ) -> Result { + self.calls += 1; + Ok(canonical_result()) + } + } + + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "skipped", "skipped", b"hello"); + let mut malformed = "malformed\n".to_string(); + malformed.push_str(&"x".repeat(256 * 1024)); + fs::write(bundle.join("tokens.txt"), malformed).expect("large malformed sidecar"); + rewrite(&bundle.join("fixture.toml"), |text| { + format!("{}\n[extensions.\"org.example.required-v1\"]\nrequired = true\nvalue = {{}}\n", text.replace("[source]\nkind = \"native\"", "[source]\nkind = \"external\"\nprovenance = \"upstream/case\"") + .replace("strategy = \"whole\"", "strategy = \"boundaries\"\nboundaries = [1]") + .replace("status = \"active\"", "status = \"skipped\"\nreason = \"chunking deferred\"\nclassification = { kind = \"unsupported-capability\", capability = { kind = \"unicode-scalar-chunking\" } }\nreference = { kind = \"tracking-issue\", value = \"#1\" }")) + }); + let mut file_access = RecordingFileAccess { + fail_content_reads: BTreeSet::from(["tokens.txt".to_string()]), + ..RecordingFileAccess::default() + }; + let fixture = + super::load::discover_and_load_with_access(&repository.adapted(), &mut file_access) + .expect("metadata-only validation does not read the failing sidecar") + .remove(0); + assert_eq!(file_access.metadata_count("tokens.txt"), 1); + assert_eq!(file_access.content_read_count("tokens.txt"), 0); + let mut executor = CountingExecutor { calls: 0 }; + let report = super::runner::run_fixture_with_executor_and_access( + &fixture, + &mut executor, + &mut file_access, + ) + .expect("skip bypasses content reads and execution"); + assert_eq!(executor.calls, 0); + assert_eq!(file_access.content_read_count("tokens.txt"), 0); + assert_eq!(report.disposition(), DispositionEvaluation::Skip); + assert!(report.result().is_none()); + assert!(report.delivery_results().is_empty()); +} + +#[test] +fn skipped_fixture_precedes_unsupported_raw_input_without_reading_sidecars() { + use html::conformance::{ParserObservationExecutionError, ParserObservationRequest}; + + struct CountingExecutor(usize); + impl super::runner::ParserObservationExecutor for CountingExecutor { + fn execute( + &mut self, + _: ParserObservationRequest<'_>, + ) -> Result { + self.0 += 1; + Ok(canonical_result()) + } + } + + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "raw-skip", "raw-skip", b"hello"); + fs::rename(bundle.join("input.html"), bundle.join("input.bin")).expect("raw input rename"); + fs::write( + bundle.join("tokens.txt"), + "malformed and deliberately unreadable", + ) + .expect("malformed sidecar"); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace( + "[source]\nkind = \"native\"", + "[source]\nkind = \"external\"\nprovenance = \"upstream/raw-case\"", + ) + .replace("path = \"input.html\"", "path = \"input.bin\"") + .replace("kind = \"utf8-text\"", "kind = \"raw-bytes\"") + .replace("unit = \"unicode-scalars\"", "unit = \"bytes\"") + .replace( + "status = \"active\"", + "status = \"skipped\"\nreason = \"raw input deferred\"\nclassification = { kind = \"unsupported-capability\", capability = { kind = \"raw-byte-input\" } }\nreference = { kind = \"tracking-issue\", value = \"#2\" }", + ) + }); + + let mut file_access = RecordingFileAccess { + fail_content_reads: BTreeSet::from(["tokens.txt".to_string()]), + ..RecordingFileAccess::default() + }; + let fixture = + super::load::discover_and_load_with_access(&repository.adapted(), &mut file_access) + .expect("raw skipped fixture passes declaration validation") + .remove(0); + let mut executor = CountingExecutor(0); + let report = super::runner::run_fixture_with_executor_and_access( + &fixture, + &mut executor, + &mut file_access, + ) + .expect("skip precedes unsupported input and byte delivery checks"); + assert_eq!(file_access.metadata_count("tokens.txt"), 1); + assert_eq!(file_access.content_read_count("tokens.txt"), 0); + assert_eq!(executor.0, 0); + assert_eq!(report.disposition(), DispositionEvaluation::Skip); + assert!(report.result().is_none()); + assert!(report.delivery_results().is_empty()); +} + +#[test] +fn active_fixture_reads_each_expected_sidecar_once_after_metadata_validation() { + use html::conformance::{ParserObservationExecutionError, ParserObservationRequest}; + + struct CountingExecutor(usize); + impl super::runner::ParserObservationExecutor for CountingExecutor { + fn execute( + &mut self, + _: ParserObservationRequest<'_>, + ) -> Result { + self.0 += 1; + Ok(canonical_result()) + } + } + + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "active-read", "active-read", b"hello"); + fs::write(bundle.join("tokens.txt"), "malformed snapshot").expect("malformed active sidecar"); + let mut file_access = RecordingFileAccess::default(); + let fixture = + super::load::discover_and_load_with_access(&repository.native(), &mut file_access) + .expect("metadata validation does not parse snapshot content") + .remove(0); + assert_eq!(file_access.metadata_count("tokens.txt"), 1); + assert_eq!(file_access.content_read_count("tokens.txt"), 0); + + let mut executor = CountingExecutor(0); + let outcome = + super::runner::execute_fixture_v2_with_access(&fixture, &mut executor, &mut file_access); + assert!(matches!( + outcome, + FixtureExecutionOutcome::ExecutionFailedV2 { + class: ExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens), + .. + } + )); + assert_eq!(file_access.content_read_count("tokens.txt"), 1); + assert_eq!(executor.0, 0, "snapshot parsing precedes parser execution"); +} + +#[test] +fn unsupported_input_and_unknown_extension_precede_malformed_v2_sidecars() { + let raw_repository = TestRepository::new(); + let raw_bundle = add_fixture_v2(&raw_repository, "raw", "raw", b"hello"); + fs::rename(raw_bundle.join("input.html"), raw_bundle.join("input.bin")) + .expect("raw input rename"); + fs::write(raw_bundle.join("tokens.txt"), "malformed").expect("malformed sidecar"); + rewrite(&raw_bundle.join("fixture.toml"), |text| { + text.replace("path = \"input.html\"", "path = \"input.bin\"") + .replace("kind = \"utf8-text\"", "kind = \"raw-bytes\"") + .replace("unit = \"unicode-scalars\"", "unit = \"bytes\"") + .replace( + "[source]\nkind = \"native\"", + "[source]\nkind = \"external\"\nprovenance = \"upstream/raw\"", + ) + }); + let raw = discover_and_load(&raw_repository.adapted()) + .expect("valid raw fixture") + .remove(0); + assert!(matches!( + execute_fixture(&raw), + FixtureExecutionOutcome::UnsupportedFixtureSemantics { + capability: FixtureCapability::RawByteInput + } + )); + + let extension_repository = TestRepository::new(); + let extension_bundle = + add_fixture_v2(&extension_repository, "extension", "extension", b"hello"); + fs::write(extension_bundle.join("tokens.txt"), "malformed").expect("malformed sidecar"); + rewrite(&extension_bundle.join("fixture.toml"), |text| { + format!("{text}\n[extensions.\"org.example.required-v1\"]\nrequired = true\nvalue = {{}}\n") + }); + let extension = load_single_native_fixture(&extension_repository); + assert!(matches!( + execute_fixture(&extension), + FixtureExecutionOutcome::UnsupportedFixtureSemantics { + capability: FixtureCapability::UnknownRequiredExtension(ref value) + } if value == "org.example.required-v1" + )); +} + +#[test] +fn declared_delivery_capability_and_planned_execution_are_distinct() { + let supported = TestRepository::new(); + let bundle = add_fixture_v2(&supported, "supported", "supported", b"hello"); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace( + "[expectations]", + "[[execution.deliveries]]\nname = \"unused-whole\"\nunit = \"unicode-scalars\"\nstrategy = \"whole\"\n\n[expectations]", + ) + }); + let fixture = load_single_native_fixture(&supported); + let plan = super::execution::build_delivery_plan(&fixture).expect("plan"); + assert_eq!( + plan.iter() + .map(|delivery| delivery.name.as_str()) + .collect::>(), + ["whole"] + ); + + let unsupported = TestRepository::new(); + let bundle = add_fixture_v2(&unsupported, "unsupported", "unsupported", b"hello"); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace( + "[expectations]", + "[[execution.deliveries]]\nname = \"unused-chunked\"\nunit = \"unicode-scalars\"\nstrategy = \"boundaries\"\nboundaries = [1]\n\n[expectations]", + ) + }); + let fixture = load_single_native_fixture(&unsupported); + assert!(matches!( + execute_fixture(&fixture), + FixtureExecutionOutcome::UnsupportedFixtureSemantics { + capability: FixtureCapability::UnicodeScalarChunking + } + )); +} + +#[test] +fn fixture_v2_precedence_rejects_final_invariants_before_malformed_sidecars() { + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "precedence", "precedence", b"hello"); + fs::write(bundle.join("tokens.txt"), "malformed").expect("malformed tokens"); + fs::write(bundle.join("final-invariants.txt"), "malformed") + .expect("final invariant placeholder"); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace( + "tokens = \"tokens.txt\"", + "tokens = \"tokens.txt\"\nfinal_invariants = \"final-invariants.txt\"", + ) + }); + let fixture = load_single_native_fixture(&repository); + assert!(matches!( + execute_fixture(&fixture), + FixtureExecutionOutcome::UnsupportedExpectation { + surface: ExpectationSurface::FinalInvariants + } + )); +} + +#[test] +fn malformed_fixture_v2_sidecar_is_snapshot_format_for_its_exact_surface() { + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "malformed", "malformed", b"hello"); + fs::write( + bundle.join("tokens.txt"), + "# format: html5-token-v2\nTOKEN ordinal=1 kind=unknown\n", + ) + .expect("malformed tokens"); + let fixture = load_single_native_fixture(&repository); + assert!(matches!( + execute_fixture(&fixture), + FixtureExecutionOutcome::ExecutionFailedV2 { + class: ExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens), + .. + } + )); +} + +#[test] +fn fixture_v2_unions_requests_once_per_planned_delivery_and_separates_transition_only_delivery() { + use html::conformance::{ + ObservationRequest, ParserObservationExecutionError, ParserObservationRequest, + }; + struct CountingExecutor { + requests: Vec<(bool, bool, bool)>, + } + impl super::runner::ParserObservationExecutor for CountingExecutor { + fn execute( + &mut self, + request: ParserObservationRequest<'_>, + ) -> Result { + self.requests.push(( + matches!(request.tokens, ObservationRequest::Capture { .. }), + matches!(request.transitions, ObservationRequest::Capture { .. }), + matches!( + request.unsupported_features, + ObservationRequest::Capture { .. } + ), + )); + html::conformance::execute_parser_observation(request) + } + } + + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "union", "union", b"hello"); + fs::write( + bundle.join("transitions.txt"), + "# format: html5-tree-transitions-v1\n", + ) + .unwrap(); + fs::write( + bundle.join("unsupported-features.txt"), + "# format: html5-unsupported-features-v1\n", + ) + .unwrap(); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace("kind = \"standalone-tokenizer\"", "kind = \"document\"\nscripting = \"disabled\"") + .replace("[expectations]", "[[execution.deliveries]]\nname = \"trace-whole\"\nunit = \"unicode-scalars\"\nstrategy = \"whole\"\n\n[expectations]") + .replace("tokens = \"tokens.txt\"", "tokens = \"tokens.txt\"\nunsupported_features = \"unsupported-features.txt\"\n\n[[expectations.transitions]]\ndelivery = \"trace-whole\"\npath = \"transitions.txt\"") + }); + let fixture = load_single_native_fixture(&repository); + let mut executor = CountingExecutor { + requests: Vec::new(), + }; + let _ = super::runner::execute_fixture_v2_with(&fixture, &mut executor); + assert_eq!( + executor.requests, + [(true, false, true), (false, true, false)] + ); +} + +#[test] +fn parser_failure_and_incomplete_state_precede_snapshot_mismatch() { + use html::conformance::{ParserObservationExecutionError, ParserObservationRequest}; + struct Failing; + impl super::runner::ParserObservationExecutor for Failing { + fn execute( + &mut self, + _: ParserObservationRequest<'_>, + ) -> Result { + Err(ParserObservationExecutionError::ParserInvariant) + } + } + let repository = TestRepository::new(); + add_fixture_v2(&repository, "precedence", "precedence", b"different"); + let fixture = load_single_native_fixture(&repository); + assert!(matches!( + super::runner::execute_fixture_v2_with(&fixture, &mut Failing), + FixtureExecutionOutcome::ExecutionFailedV2 { + class: ExecutionFailureClass::ParserObservation( + html::conformance::ParserObservationExecutionIdentity::ParserInvariant + ), + .. + } + )); + + struct Incomplete; + impl super::runner::ParserObservationExecutor for Incomplete { + fn execute( + &mut self, + _: ParserObservationRequest<'_>, + ) -> Result { + let mut result = canonical_result(); + result.tokens = ObservationState::Incomplete { + partial: Vec::new(), + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 0, + dropped: 1, + }, + }; + Ok(result) + } + } + assert!(matches!( + super::runner::execute_fixture_v2_with(&fixture, &mut Incomplete), + FixtureExecutionOutcome::IncompleteObservationV2 { .. } + )); +} + +#[test] +fn multiple_fixture_v2_mismatches_select_the_fixed_first_surface() { + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "mismatch-order", "mismatch-order", b"hello"); + fs::write(bundle.join("tokens.txt"), "# format: html5-token-v2\nTOKEN ordinal=1 kind=character data=\"wrong\"\nTOKEN ordinal=2 kind=eof\n").unwrap(); + fs::write( + bundle.join("document-mode.txt"), + "# format: html5-document-mode-v1\nMODE value=quirks\n", + ) + .unwrap(); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace( + "kind = \"standalone-tokenizer\"", + "kind = \"document\"\nscripting = \"disabled\"", + ) + .replace( + "tokens = \"tokens.txt\"", + "tokens = \"tokens.txt\"\ndocument_mode = \"document-mode.txt\"", + ) + }); + let fixture = load_single_native_fixture(&repository); + match execute_fixture(&fixture) { + FixtureExecutionOutcome::ExpectationMismatchV2 { + surface: ExpectationSurface::Tokens, + diff, + .. + } => { + for required in [ + "fixture: mismatch-order", + "fixture path: fixtures/mismatch-order", + "expectation surface: tokens", + "expected snapshot: fixtures/mismatch-order/tokens.txt", + "snapshot format: html5-token-v2", + "first meaningful difference: record 1 (token 1)", + "expected:", + "actual:", + "nearby context:", + "expected record count:", + "actual record count:", + ] { + assert!(diff.contains(required), "missing '{required}' in:\n{diff}"); + } + } + other => panic!("unexpected outcome: {other:?}"), + } + let error = run_fixture(&fixture).expect_err("a mismatch cannot expose a completed report"); + assert!(error.to_string().contains("tokens expectation mismatch")); +} + +#[test] +fn transition_mismatch_selection_follows_fixture_declaration_order() { + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "transition-order", "transition-order", b"

x"); + fs::remove_file(bundle.join("tokens.txt")).expect("remove unused token sidecar"); + fs::write( + bundle.join("transitions.first.txt"), + "# format: html5-tree-transitions-v1\n", + ) + .expect("first transitions"); + fs::write( + bundle.join("transitions.second.txt"), + "# format: html5-tree-transitions-v1\n", + ) + .expect("second transitions"); + rewrite(&bundle.join("fixture.toml"), |text| { + text.replace( + "kind = \"standalone-tokenizer\"", + "kind = \"document\"\nscripting = \"disabled\"", + ) + .replace( + "[expectations]\ntokens = \"tokens.txt\"", + "[[execution.deliveries]]\nname = \"first-trace\"\nunit = \"unicode-scalars\"\nstrategy = \"whole\"\n\n[[execution.deliveries]]\nname = \"second-trace\"\nunit = \"unicode-scalars\"\nstrategy = \"whole\"\n\n[expectations]\n\n[[expectations.transitions]]\ndelivery = \"first-trace\"\npath = \"transitions.first.txt\"\n\n[[expectations.transitions]]\ndelivery = \"second-trace\"\npath = \"transitions.second.txt\"", + ) + }); + let fixture = load_single_native_fixture(&repository); + match execute_fixture(&fixture) { + FixtureExecutionOutcome::ExpectationMismatchV2 { + ref delivery, + surface: ExpectationSurface::Transitions, + diff, + } => { + assert_eq!(delivery.as_str(), "first-trace"); + assert!(diff.contains("transition delivery: first-trace"), "{diff}"); + } + other => panic!("unexpected outcome: {other:?}"), + } +} + +#[test] +fn canonical_fixture_guardrails_are_fixed_and_expectation_independent() { + use html::conformance::{ + ObservationRequest, ParserObservationTarget, ScalarObservationRequest, + }; + let guardrails = super::execution::FixtureObservationGuardrails::PRODUCTION; + let request = super::execution::observation_request( + ParserObservationTarget::DocumentParser, + "input", + super::execution::RequestedSurfaces { + tokens: true, + parse_errors: true, + implementation_diagnostics: true, + document_mode: true, + tree: true, + patches: true, + transitions: true, + unsupported_features: true, + }, + guardrails, + ); + assert_eq!( + request.tokens, + ObservationRequest::Capture { + capacity: guardrails.tokens + } + ); + assert_eq!( + request.parse_errors, + ObservationRequest::Capture { + capacity: guardrails.parse_errors + } + ); + assert_eq!( + request.implementation_diagnostics, + ObservationRequest::Capture { + capacity: guardrails.implementation_diagnostics + } + ); + assert_eq!(request.document_mode, ScalarObservationRequest::Capture); + assert_eq!( + request.tree, + ObservationRequest::Capture { + capacity: guardrails.canonical_tree_units + } + ); + assert_eq!( + request.patches, + ObservationRequest::Capture { + capacity: guardrails.patch_operations + } + ); + assert_eq!( + request.transitions, + ObservationRequest::Capture { + capacity: guardrails.transitions + } + ); + assert_eq!( + request.unsupported_features, + ObservationRequest::Capture { + capacity: guardrails.unsupported_features + } + ); +} + +#[test] +fn expected_sidecar_record_count_cannot_change_injected_guardrails() { + use super::execution::FixtureObservationGuardrails; + use html::conformance::{ObservationRequest, ObservedToken, ParserObservationRequest}; + + struct CaptureTokenCapacity { + observed: Vec, + } + + impl super::runner::ParserObservationExecutor for CaptureTokenCapacity { + fn execute( + &mut self, + request: ParserObservationRequest<'_>, + ) -> Result + { + self.observed.push(request.tokens); + let mut result = canonical_result(); + result.tokens = ObservationState::Captured(vec![ObservedToken::Eof]); + Ok(result) + } + } + + fn run_with_snapshot(snapshot: &str) -> Vec { + let repository = TestRepository::new(); + let bundle = add_fixture_v2(&repository, "guardrail", "guardrail", b"hello"); + fs::write(bundle.join("tokens.txt"), snapshot).expect("replacement snapshot"); + let fixture = load_single_native_fixture(&repository); + let mut executor = CaptureTokenCapacity { + observed: Vec::new(), + }; + let _ = super::runner::execute_fixture_v2_with_guardrails( + &fixture, + &mut executor, + FixtureObservationGuardrails { + tokens: 7, + ..FixtureObservationGuardrails::PRODUCTION + }, + ); + executor.observed + } + + let one_record = run_with_snapshot("# format: html5-token-v2\nTOKEN ordinal=1 kind=eof\n"); + let two_records = run_with_snapshot( + "# format: html5-token-v2\nTOKEN ordinal=1 kind=character data=\"hello\"\nTOKEN ordinal=2 kind=eof\n", + ); + assert_eq!( + one_record, + vec![ObservationRequest::Capture { capacity: 7 }] + ); + assert_eq!(two_records, one_record); +} + +#[test] +fn every_incomplete_requested_surface_is_rejected_before_comparison() { + use super::execution::RequestedSurfaces; + use super::runner::{StateIssue, first_state_issue}; + use html::DocumentMode; + use html::conformance::{ObservedPatchStream, ObservedTree}; + + fn reason() -> IncompleteObservationReason { + IncompleteObservationReason::StorageLimitExceeded { + retained: 1, + dropped: 1, + } + } + + macro_rules! assert_incomplete { + ($surface:expr, $field:ident, $partial:expr) => {{ + let mut result = canonical_result(); + result.$field = ObservationState::Incomplete { + partial: $partial, + reason: reason(), + }; + let requested = RequestedSurfaces { + $field: true, + ..RequestedSurfaces::default() + }; + assert!(matches!( + first_state_issue(&result, requested), + Some(StateIssue::Incomplete { + surface, + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 1, + dropped: 1 + }, + retained: 1, + dropped: 1, + }) if surface == $surface + )); + let unrequested = RequestedSurfaces::default(); + assert_eq!( + first_state_issue(&result, unrequested), + Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyIncomplete + )) + ); + }}; + } + + assert_incomplete!(ExpectationSurface::Tokens, tokens, Vec::new()); + assert_incomplete!(ExpectationSurface::ParseErrors, parse_errors, Vec::new()); + assert_incomplete!( + ExpectationSurface::ImplementationDiagnostics, + implementation_diagnostics, + Vec::new() + ); + assert_incomplete!( + ExpectationSurface::DocumentMode, + document_mode, + DocumentMode::NoQuirks + ); + assert_incomplete!(ExpectationSurface::Tree, tree, ObservedTree::default()); + assert_incomplete!( + ExpectationSurface::Patches, + patches, + ObservedPatchStream::default() + ); + assert_incomplete!(ExpectationSurface::Transitions, transitions, Vec::new()); + assert_incomplete!( + ExpectationSurface::UnsupportedFeatures, + unsupported_features, + Vec::new() + ); +} + +#[test] +fn incomplete_diagnostics_retain_exact_identity_for_every_surface() { + let repository = TestRepository::new(); + add_fixture_v2(&repository, "incomplete", "incomplete", b"hello"); + let fixture = load_single_native_fixture(&repository); + let delivery = DeliveryName::validated("whole".to_string()); + + for surface in [ + ExpectationSurface::Tokens, + ExpectationSurface::ParseErrors, + ExpectationSurface::ImplementationDiagnostics, + ExpectationSurface::DocumentMode, + ExpectationSurface::Tree, + ExpectationSurface::Patches, + ExpectationSurface::Transitions, + ExpectationSurface::UnsupportedFeatures, + ] { + let outcome = FixtureExecutionOutcome::IncompleteObservationV2 { + delivery: delivery.clone(), + surface, + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 11, + dropped: 3, + }, + retained: 11, + dropped: 3, + }; + let Some(FixtureFailureDetails::Message(message)) = + super::runner::failure_details(&fixture, &outcome) + else { + panic!("missing incomplete details for {}", surface.name()); + }; + for expected in [ + "delivery: whole".to_string(), + format!("surface: {}", surface.name()), + "reason: storage-limit-exceeded".to_string(), + "retained count: 11".to_string(), + "dropped count: 3".to_string(), + ] { + assert!(message.contains(&expected), "missing {expected}: {message}"); + } + } +} + +#[test] +fn every_surface_enforces_requested_and_unrequested_state_contracts() { + use super::execution::RequestedSurfaces; + use super::runner::{StateIssue, first_state_issue}; + use html::DocumentMode; + use html::conformance::{NotApplicableReason, ObservedPatchStream, ObservedTree}; + + macro_rules! assert_states { + ($field:ident, $captured:expr) => {{ + let requested = RequestedSurfaces { + $field: true, + ..RequestedSurfaces::default() + }; + + let mut captured = canonical_result(); + captured.$field = ObservationState::Captured($captured); + assert_eq!(first_state_issue(&captured, requested), None); + assert_eq!( + first_state_issue(&captured, RequestedSurfaces::default()), + Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::UnrequestedSurfaceUnexpectedlyCaptured + )) + ); + + let not_requested = canonical_result(); + assert_eq!( + first_state_issue(¬_requested, requested), + Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::RequestedSurfaceUnexpectedlyNotRequested + )) + ); + + let mut not_applicable = canonical_result(); + not_applicable.$field = ObservationState::NotApplicable { + reason: NotApplicableReason::DocumentParserRun, + }; + assert_eq!( + first_state_issue(¬_applicable, requested), + Some(StateIssue::Invariant( + ValidatedFixtureInvariantCode::RequestedSurfaceUnexpectedlyNotApplicable + )) + ); + }}; + } + + assert_states!(tokens, Vec::new()); + assert_states!(parse_errors, Vec::new()); + assert_states!(implementation_diagnostics, Vec::new()); + assert_states!(document_mode, DocumentMode::NoQuirks); + assert_states!(tree, ObservedTree::default()); + assert_states!(patches, ObservedPatchStream::default()); + assert_states!(transitions, Vec::new()); + assert_states!(unsupported_features, Vec::new()); +} + +#[test] +fn injected_guardrails_cover_exact_and_capacity_plus_one_for_every_retained_surface() { + use super::execution::{FixtureObservationGuardrails, RequestedSurfaces, observation_request}; + use html::conformance::{ + ObservationRequest, ParserObservationTarget, execute_parser_observation, + }; + + const INPUT: &str = "

\n"; + let surfaces = RequestedSurfaces { + tokens: true, + parse_errors: true, + implementation_diagnostics: true, + document_mode: true, + tree: true, + patches: true, + transitions: true, + unsupported_features: true, + }; + let high = FixtureObservationGuardrails { + tokens: 1_024, + parse_errors: 1_024, + implementation_diagnostics: 1_024, + unsupported_features: 1_024, + canonical_tree_units: 1_024, + transitions: 1_024, + patch_operations: 1_024, + }; + let baseline = execute_parser_observation(observation_request( + ParserObservationTarget::DocumentParser, + INPUT, + surfaces, + high, + )) + .expect("baseline observation"); + macro_rules! captured_len { + ($state:expr) => { + match $state { + ObservationState::Captured(values) => values.len(), + _ => panic!("baseline collection was not captured"), + } + }; + } + let token_count = captured_len!(&baseline.tokens); + let parse_error_count = captured_len!(&baseline.parse_errors); + let diagnostic_count = captured_len!(&baseline.implementation_diagnostics); + let transition_count = captured_len!(&baseline.transitions); + let unsupported_count = captured_len!(&baseline.unsupported_features); + let patch_count = match &baseline.patches { + ObservationState::Captured(stream) => stream.operations.len(), + _ => panic!("baseline patches were not captured"), + }; + let mut zero_tree = high; + zero_tree.canonical_tree_units = 0; + let tree_probe = execute_parser_observation(observation_request( + ParserObservationTarget::DocumentParser, + INPUT, + surfaces, + zero_tree, + )) + .expect("tree capacity probe"); + let tree_count = match tree_probe.tree { + ObservationState::Incomplete { + reason: IncompleteObservationReason::StorageLimitExceeded { dropped, .. }, + .. + } => usize::try_from(dropped).expect("fixture-sized tree unit count"), + _ => panic!("zero tree capacity must be incomplete"), + }; + + macro_rules! assert_boundary { + ($policy_field:ident, $result_field:ident, $required:expr) => {{ + let required = $required; + assert!(required > 0); + let mut exact = high; + exact.$policy_field = required; + let exact_request = observation_request( + ParserObservationTarget::DocumentParser, + INPUT, + surfaces, + exact, + ); + assert_eq!( + exact_request.$result_field, + ObservationRequest::Capture { capacity: required } + ); + let exact_result = execute_parser_observation(exact_request).expect("exact capacity"); + assert!(matches!( + exact_result.$result_field, + ObservationState::Captured(_) + )); + + let capacity = required.checked_sub(1).expect("positive requirement"); + let mut below = high; + below.$policy_field = capacity; + let below_request = observation_request( + ParserObservationTarget::DocumentParser, + INPUT, + surfaces, + below, + ); + assert_eq!( + below_request.$result_field, + ObservationRequest::Capture { capacity } + ); + let below_result = + execute_parser_observation(below_request).expect("capacity plus one observation"); + assert!(matches!( + below_result.$result_field, + ObservationState::Incomplete { .. } + )); + }}; + } + + assert_boundary!(tokens, tokens, token_count); + assert_boundary!(parse_errors, parse_errors, parse_error_count); + assert_boundary!( + implementation_diagnostics, + implementation_diagnostics, + diagnostic_count + ); + assert_boundary!(canonical_tree_units, tree, tree_count); + assert_boundary!(patch_operations, patches, patch_count); + assert_boundary!(transitions, transitions, transition_count); + assert_boundary!( + unsupported_features, + unsupported_features, + unsupported_count + ); +} + +#[test] +fn incomplete_prefix_equal_to_expected_snapshot_cannot_reach_serialization_or_comparison() { + use super::execution::FixtureObservationGuardrails; + use html::conformance::{ + ObservedToken, ParserObservationExecutionError, ParserObservationRequest, + }; + + struct IncompleteMatchingPrefix; + impl super::runner::ParserObservationExecutor for IncompleteMatchingPrefix { + fn execute( + &mut self, + _: ParserObservationRequest<'_>, + ) -> Result { + let mut result = canonical_result(); + result.tokens = ObservationState::Incomplete { + partial: vec![ + ObservedToken::Character { + data: "hello".to_string(), + }, + ObservedToken::Eof, + ], + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 2, + dropped: 1, + }, + }; + Ok(result) + } + } + + let repository = TestRepository::new(); + add_fixture_v2( + &repository, + "incomplete-prefix", + "incomplete-prefix", + b"hello", + ); + let fixture = load_single_native_fixture(&repository); + let outcome = super::runner::execute_fixture_v2_with_guardrails( + &fixture, + &mut IncompleteMatchingPrefix, + FixtureObservationGuardrails { + tokens: 2, + ..FixtureObservationGuardrails::PRODUCTION + }, + ); + assert!(matches!( + outcome, + FixtureExecutionOutcome::IncompleteObservationV2 { + ref delivery, + surface: ExpectationSurface::Tokens, + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 2, + dropped: 1 + }, + retained: 2, + dropped: 1, + } if delivery.as_str() == "whole" + )); + + let error = super::runner::run_fixture_with_executor(&fixture, &mut IncompleteMatchingPrefix) + .expect_err("incomplete capture cannot produce a completed report"); + let diagnostic = error.to_string(); + for expected in [ + "delivery: whole", + "surface: tokens", + "reason: storage-limit-exceeded", + "retained count: 2", + "dropped count: 1", + ] { + assert!( + diagnostic.contains(expected), + "missing {expected}: {diagnostic}" + ); + } +} + +#[test] +fn successful_v2_report_borrows_reference_result_from_its_single_delivery_owner() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let repository_root = manifest + .join("../..") + .canonicalize() + .expect("repository root"); + let fixture_root = manifest + .join("../html/tests/fixtures/html5/conformance") + .canonicalize() + .expect("fixture root"); + let fixtures = discover_and_load(&FixtureRepository::native(repository_root, fixture_root)) + .expect("canonical fixture-v2 corpus"); + let fixture = fixtures + .iter() + .find(|fixture| fixture.id().as_str() == "document-structured-observations") + .expect("multi-delivery canonical fixture"); + let report = run_fixture(fixture).expect("multi-delivery fixture passes"); + assert_eq!(report.delivery_results().len(), 2); + let reference = report.result().expect("ordinary reference result"); + let owned = report + .delivery_results() + .iter() + .find(|delivery| delivery.delivery().as_str() == "whole") + .expect("reference delivery") + .result(); + assert!(std::ptr::eq(reference, owned)); +} + #[test] fn discovery_is_sorted_by_normalized_repository_relative_path() { let repository = TestRepository::new(); @@ -178,8 +1645,8 @@ fn canonical_corpus_runner_aggregates_all_fixture_failures_with_identity() { assert!(error.failures().iter().all(|failure| matches!( failure.error().policy, DispositionEvaluationError::UnexpectedOutcome { - actual: FixtureOutcomeClassification::ExecutionFailed( - ExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens) + actual: FixtureOutcomeClassification::ExecutionFailedV1( + LegacyExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens) ), .. } @@ -652,8 +2119,8 @@ fn malformed_token_snapshot_is_a_typed_snapshot_failure_not_fixture_toml() { assert!(matches!( error.policy, DispositionEvaluationError::UnexpectedOutcome { - actual: FixtureOutcomeClassification::ExecutionFailed( - ExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens) + actual: FixtureOutcomeClassification::ExecutionFailedV1( + LegacyExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tokens) ), .. } @@ -1293,9 +2760,9 @@ fn disposition_policy_table_covers_exact_outcomes_and_xpass() { }; let expected_execution_failure = FixtureDisposition::ExpectedFailure { reason: "known failure".to_string(), - failure: ExpectedFailureClassification::Execution(ExecutionFailureClass::SnapshotFormat( - ExpectationSurface::Tree, - )), + failure: ExpectedFailureClassification::Execution( + LegacyExecutionFailureClass::SnapshotFormat(ExpectationSurface::Tree), + ), reference: DispositionReference::TrackingIssue("#3".to_string()), }; let expected_mismatch = FixtureDisposition::ExpectedFailure { @@ -1340,7 +2807,7 @@ fn disposition_policy_table_covers_exact_outcomes_and_xpass() { ( "active execution failure", FixtureDisposition::Active, - execution_failure(ExecutionFailureClass::TokenizerDriver), + execution_failure(LegacyExecutionFailureClass::TokenizerDriver), ExpectedEvaluation::Unexpected, ), ( @@ -1388,7 +2855,7 @@ fn disposition_policy_table_covers_exact_outcomes_and_xpass() { ( "exact execution failure", expected_execution_failure.clone(), - execution_failure(ExecutionFailureClass::SnapshotFormat( + execution_failure(LegacyExecutionFailureClass::SnapshotFormat( ExpectationSurface::Tree, )), ExpectedEvaluation::Pass, @@ -1396,7 +2863,7 @@ fn disposition_policy_table_covers_exact_outcomes_and_xpass() { ( "wrong execution failure", expected_execution_failure.clone(), - execution_failure(ExecutionFailureClass::SnapshotRead( + execution_failure(LegacyExecutionFailureClass::SnapshotRead( ExpectationSurface::Tree, )), ExpectedEvaluation::Unexpected, @@ -1443,7 +2910,11 @@ fn disposition_policy_table_covers_exact_outcomes_and_xpass() { ( "skipped is not executed", skipped.clone(), - FixtureExecutionOutcome::NotExecuted, + FixtureExecutionOutcome::NotExecuted { + classification: SkipClassification::UnsupportedCapability( + FixtureCapability::FragmentParsing, + ), + }, ExpectedEvaluation::Skip, ), ( @@ -1507,7 +2978,7 @@ fn unsupported_expectation(surface: ExpectationSurface) -> FixtureExecutionOutco FixtureExecutionOutcome::UnsupportedExpectation { surface } } -fn execution_failure(class: ExecutionFailureClass) -> FixtureExecutionOutcome { +fn execution_failure(class: LegacyExecutionFailureClass) -> FixtureExecutionOutcome { FixtureExecutionOutcome::ExecutionFailed { class, message: "failure".to_string(), diff --git a/crates/html_test_support/src/parser_fixture/validate.rs b/crates/html_test_support/src/parser_fixture/validate.rs index 6ad054e4..8c2df807 100644 --- a/crates/html_test_support/src/parser_fixture/validate.rs +++ b/crates/html_test_support/src/parser_fixture/validate.rs @@ -1,6 +1,7 @@ +use super::failure_spelling::{parse_parser_observation_failure, parse_runner_invariant}; use super::load::{ - FixtureLoadError, FixtureLoadErrorKind, FixtureRepositoryPolicy, normalize_relative_path, - read_regular_file, validate_relative_path, + FixtureFileAccess, FixtureLoadError, FixtureLoadErrorKind, FixtureRepositoryPolicy, + normalize_relative_path, validate_relative_path, }; use super::model::*; use super::schema::*; @@ -12,13 +13,14 @@ use std::fmt::Write; use std::fs; use std::path::Path; -/// A fixture whose complete serialized declaration passed the canonical v1 -/// validation boundary. +/// A fixture whose complete serialized declaration passed its selected strict +/// versioned validation boundary. /// /// Fields and construction stay in this module so callers cannot assemble a /// partially validated value from otherwise plausible component values. #[derive(Clone, Debug)] pub struct ValidatedFixtureSpec { + format: FixtureFormatVersion, id: FixtureId, bundle: FixtureBundle, source: FixtureSource, @@ -142,6 +144,10 @@ impl ValidatedFixtureSpec { &self.bundle } + pub(super) fn format(&self) -> FixtureFormatVersion { + self.format + } + pub(super) fn input(&self) -> &ExactInput { &self.input } @@ -163,10 +169,11 @@ impl ValidatedFixtureSpec { } } -pub(super) fn validate_fixture( +pub(super) fn validate_fixture_v1( declaration: FixtureFileV1, bundle: FixtureBundle, repository_policy: FixtureRepositoryPolicy, + file_access: &mut impl FixtureFileAccess, ) -> Result { if declaration.format != FIXTURE_FORMAT_V1 { return Err(bundle_error( @@ -179,12 +186,18 @@ pub(super) fn validate_fixture( let input_path = declaration.input.path.clone(); validate_relative_path(&input_path).map_err(|kind| bundle_error(&bundle, kind))?; - let input_bytes = read_regular_file(&bundle, &input_path)?; + let input_bytes = file_access.read_regular_file(&bundle, &input_path)?; validate_sha256(&bundle, &declaration.input.sha256, &input_bytes)?; let input = validate_input(&bundle, declaration.input, input_bytes)?; let execution = validate_execution(&bundle, &input, declaration.execution)?; - let expectations = validate_expectations(&bundle, &execution, declaration.expectations)?; + let expectations = validate_expectations( + &bundle, + &execution, + declaration.expectations, + SidecarValidationPolicy::LegacyV1ContentReadable, + file_access, + )?; if !has_any_expectation(&expectations) { return invalid_combination(&bundle, "fixture must declare at least one expectation"); } @@ -202,6 +215,88 @@ pub(super) fn validate_fixture( validate_source_disposition_policy(&bundle, repository_policy, &source, &disposition)?; Ok(ValidatedFixtureSpec { + format: FixtureFormatVersion::V1, + id, + bundle, + source, + input, + execution, + expectations, + disposition, + description: declaration.metadata.description, + comments: declaration.metadata.comments, + optional_extensions, + required_unknown_extensions, + }) +} + +fn validate_v2_expectation_identities( + bundle: &FixtureBundle, + expectations: &EnabledExpectations, +) -> Result<(), FixtureLoadError> { + let ExpectedSurface::Compare(transitions) = expectations.transitions() else { + return Ok(()); + }; + let mut deliveries = BTreeSet::new(); + for transition in transitions { + if !deliveries.insert(transition.delivery().clone()) { + return invalid_combination( + bundle, + "fixture-v2 transition expectations must have unique delivery identities", + ); + } + } + Ok(()) +} + +pub(super) fn validate_fixture_v2( + declaration: FixtureFileV2, + bundle: FixtureBundle, + repository_policy: FixtureRepositoryPolicy, + file_access: &mut impl FixtureFileAccess, +) -> Result { + if declaration.format != FIXTURE_FORMAT_V2 { + return Err(bundle_error( + &bundle, + FixtureLoadErrorKind::UnsupportedFixtureFormat(declaration.format), + )); + } + let id = validate_fixture_id(&bundle, declaration.id)?; + let source = validate_source(&bundle, declaration.source)?; + + let input_path = declaration.input.path.clone(); + validate_relative_path(&input_path).map_err(|kind| bundle_error(&bundle, kind))?; + let input_bytes = file_access.read_regular_file(&bundle, &input_path)?; + validate_sha256(&bundle, &declaration.input.sha256, &input_bytes)?; + let input = validate_input(&bundle, declaration.input, input_bytes)?; + + let execution = validate_execution(&bundle, &input, declaration.execution)?; + let expectations = validate_expectations( + &bundle, + &execution, + declaration.expectations, + SidecarValidationPolicy::MetadataOnlyV2, + file_access, + )?; + validate_v2_expectation_identities(&bundle, &expectations)?; + if !has_any_expectation(&expectations) { + return invalid_combination(&bundle, "fixture must declare at least one expectation"); + } + validate_orphan_sidecars(&bundle, &input_path, &expectations)?; + let (optional_extensions, required_unknown_extensions) = + validate_extensions(&bundle, declaration.extensions)?; + let disposition = validate_disposition_v2( + &bundle, + declaration.disposition, + &input, + &execution, + &expectations, + &required_unknown_extensions, + )?; + validate_source_disposition_policy(&bundle, repository_policy, &source, &disposition)?; + + Ok(ValidatedFixtureSpec { + format: FixtureFormatVersion::V2, id, bundle, source, @@ -348,6 +443,188 @@ fn validate_disposition( } } +fn validate_disposition_v2( + bundle: &FixtureBundle, + disposition: FixtureDispositionDeclarationV2, + input: &ExactInput, + execution: &ValidatedExecution, + expectations: &EnabledExpectations, + required_unknown_extensions: &[String], +) -> Result { + match ( + disposition.status, + disposition.reason, + disposition.capability, + disposition.failure, + disposition.classification, + disposition.reference, + ) { + (FixtureDispositionStatusDeclaration::Active, None, None, None, None, None) => { + Ok(FixtureDisposition::Active) + } + ( + FixtureDispositionStatusDeclaration::ExpectedUnsupported, + Some(reason), + Some(capability), + None, + None, + Some(reference), + ) => { + require_non_empty(bundle, "expected-unsupported reason", &reason)?; + let capability = map_capability(bundle, capability)?; + require_non_active_capability(bundle, &capability, "expected unsupported")?; + Ok(FixtureDisposition::ExpectedUnsupported { + reason, + capability, + reference: validate_reference(bundle, reference)?, + }) + } + ( + FixtureDispositionStatusDeclaration::ExpectedFailure, + Some(reason), + None, + Some(failure), + None, + Some(reference), + ) => { + require_non_empty(bundle, "expected-failure reason", &reason)?; + Ok(FixtureDisposition::ExpectedFailureV2 { + reason, + failure: map_expected_failure_v2(bundle, failure)?, + reference: validate_reference(bundle, reference)?, + }) + } + ( + FixtureDispositionStatusDeclaration::Skipped, + Some(reason), + None, + None, + Some(classification), + Some(reference), + ) => { + require_non_empty(bundle, "skipped reason", &reason)?; + let classification = validate_skip_classification(bundle, classification)?; + let SkipClassification::UnsupportedCapability(capability) = &classification; + if !capability_is_relevant( + capability, + input, + execution, + expectations, + required_unknown_extensions, + ) { + return Err(bundle_error( + bundle, + FixtureLoadErrorKind::InvalidDisposition(format!( + "skipped unsupported capability '{}' is not relevant to the fixture's declared semantics", + capability_name(capability) + )), + )); + } + Ok(FixtureDisposition::Skipped { + reason, + classification, + reference: validate_reference(bundle, reference)?, + }) + } + _ => invalid_combination( + bundle, + "fixture-v2 disposition fields do not match the declared status", + ), + } +} + +fn map_expected_failure_v2( + bundle: &FixtureBundle, + declaration: ExpectedFailureDeclarationV2, +) -> Result { + let invalid = || { + bundle_error( + bundle, + FixtureLoadErrorKind::InvalidDisposition( + "fixture-v2 failure fields do not match the selected failure kind".to_string(), + ), + ) + }; + match ( + declaration.kind, + declaration.surface, + declaration.identity, + declaration.code, + declaration.site, + ) { + (ExpectedFailureKindDeclarationV2::SnapshotRead, Some(surface), None, None, None) => { + Ok(ExpectedFailureClassificationV2::Execution( + ExecutionFailureClass::SnapshotRead(map_surface(surface)), + )) + } + (ExpectedFailureKindDeclarationV2::SnapshotFormat, Some(surface), None, None, None) => { + Ok(ExpectedFailureClassificationV2::Execution( + ExecutionFailureClass::SnapshotFormat(map_surface(surface)), + )) + } + (ExpectedFailureKindDeclarationV2::ParserObservation, None, Some(identity), code, site) => { + Ok(ExpectedFailureClassificationV2::Execution( + ExecutionFailureClass::ParserObservation( + parse_parser_observation_failure(&identity, code.as_deref(), site.as_deref()) + .map_err(|error| { + bundle_error( + bundle, + FixtureLoadErrorKind::InvalidDisposition(error.to_string()), + ) + })?, + ), + )) + } + ( + ExpectedFailureKindDeclarationV2::ValidatedRunnerInvariant, + None, + None, + Some(code), + None, + ) => Ok(ExpectedFailureClassificationV2::Execution( + ExecutionFailureClass::ValidatedFixtureInvariant( + parse_runner_invariant(&code).map_err(|error| { + bundle_error( + bundle, + FixtureLoadErrorKind::InvalidDisposition(error.to_string()), + ) + })?, + ), + )), + ( + ExpectedFailureKindDeclarationV2::ExpectationMismatch, + Some(surface), + None, + None, + None, + ) => Ok(ExpectedFailureClassificationV2::ExpectationMismatch( + map_surface(surface), + )), + (ExpectedFailureKindDeclarationV2::FinalInvariant, None, None, Some(code), None) => Ok( + ExpectedFailureClassificationV2::FinalInvariant(map_final_invariant(bundle, &code)?), + ), + _ => Err(invalid()), + } +} + +fn map_surface(surface: ExpectationSurfaceDeclaration) -> ExpectationSurface { + match surface { + ExpectationSurfaceDeclaration::Tokens => ExpectationSurface::Tokens, + ExpectationSurfaceDeclaration::ParseErrors => ExpectationSurface::ParseErrors, + ExpectationSurfaceDeclaration::ImplementationDiagnostics => { + ExpectationSurface::ImplementationDiagnostics + } + ExpectationSurfaceDeclaration::DocumentMode => ExpectationSurface::DocumentMode, + ExpectationSurfaceDeclaration::Tree => ExpectationSurface::Tree, + ExpectationSurfaceDeclaration::Patches => ExpectationSurface::Patches, + ExpectationSurfaceDeclaration::Transitions => ExpectationSurface::Transitions, + ExpectationSurfaceDeclaration::UnsupportedFeatures => { + ExpectationSurface::UnsupportedFeatures + } + ExpectationSurfaceDeclaration::FinalInvariants => ExpectationSurface::FinalInvariants, + } +} + fn validate_reference( bundle: &FixtureBundle, reference: DispositionReferenceDeclaration, @@ -583,10 +860,37 @@ fn validate_execution( )) } +#[derive(Clone, Copy)] +enum SidecarValidationPolicy { + LegacyV1ContentReadable, + MetadataOnlyV2, +} + +impl SidecarValidationPolicy { + fn validate_declared_sidecar( + self, + bundle: &FixtureBundle, + path: &str, + file_access: &mut impl FixtureFileAccess, + ) -> Result<(), FixtureLoadError> { + match self { + Self::LegacyV1ContentReadable => { + let _ = file_access.read_regular_file(bundle, path)?; + } + Self::MetadataOnlyV2 => { + file_access.validate_regular_file_metadata(bundle, path)?; + } + } + Ok(()) + } +} + fn validate_expectations( bundle: &FixtureBundle, execution: &ValidatedExecution, declaration: FixtureExpectationDeclarations, + sidecar_policy: SidecarValidationPolicy, + file_access: &mut impl FixtureFileAccess, ) -> Result { let delivery_names = execution .deliveries() @@ -605,7 +909,7 @@ fn validate_expectations( "transition expectation references an undeclared delivery", ); } - read_regular_file(bundle, &transition.path)?; + sidecar_policy.validate_declared_sidecar(bundle, &transition.path, file_access)?; Ok(TransitionSnapshotExpectation::validated( delivery, SnapshotPath::validated(transition.path), @@ -614,31 +918,58 @@ fn validate_expectations( .collect::, FixtureLoadError>>()?; Ok(EnabledExpectations::validated( - snapshot_surface(bundle, declaration.tokens)?, - snapshot_surface(bundle, declaration.parse_errors)?, - snapshot_surface(bundle, declaration.implementation_diagnostics)?, - snapshot_surface(bundle, declaration.document_mode)?, - snapshot_surface(bundle, declaration.tree)?, - snapshot_surface(bundle, declaration.patches)?, + snapshot_surface(bundle, declaration.tokens, sidecar_policy, file_access)?, + snapshot_surface( + bundle, + declaration.parse_errors, + sidecar_policy, + file_access, + )?, + snapshot_surface( + bundle, + declaration.implementation_diagnostics, + sidecar_policy, + file_access, + )?, + snapshot_surface( + bundle, + declaration.document_mode, + sidecar_policy, + file_access, + )?, + snapshot_surface(bundle, declaration.tree, sidecar_policy, file_access)?, + snapshot_surface(bundle, declaration.patches, sidecar_policy, file_access)?, if transitions.is_empty() { ExpectedSurface::NotDeclared } else { ExpectedSurface::Compare(transitions) }, - snapshot_surface(bundle, declaration.unsupported_features)?, - snapshot_surface(bundle, declaration.final_invariants)?, + snapshot_surface( + bundle, + declaration.unsupported_features, + sidecar_policy, + file_access, + )?, + snapshot_surface( + bundle, + declaration.final_invariants, + sidecar_policy, + file_access, + )?, )) } fn snapshot_surface( bundle: &FixtureBundle, path: Option, + sidecar_policy: SidecarValidationPolicy, + file_access: &mut impl FixtureFileAccess, ) -> Result, FixtureLoadError> { let Some(path) = path else { return Ok(ExpectedSurface::NotDeclared); }; validate_relative_path(&path).map_err(|kind| bundle_error(bundle, kind))?; - read_regular_file(bundle, &path)?; + sidecar_policy.validate_declared_sidecar(bundle, &path, file_access)?; Ok(ExpectedSurface::Compare(SnapshotPath::validated(path))) } @@ -912,19 +1243,19 @@ fn map_capability( fn map_expected_failure(value: ExpectedFailureDeclaration) -> ExpectedFailureClassification { match value { ExpectedFailureDeclaration::TokenSnapshotRead => ExpectedFailureClassification::Execution( - ExecutionFailureClass::SnapshotRead(ExpectationSurface::Tokens), + LegacyExecutionFailureClass::SnapshotRead(ExpectationSurface::Tokens), ), ExpectedFailureDeclaration::TokenSnapshotFormat => { - ExpectedFailureClassification::Execution(ExecutionFailureClass::SnapshotFormat( + ExpectedFailureClassification::Execution(LegacyExecutionFailureClass::SnapshotFormat( ExpectationSurface::Tokens, )) } ExpectedFailureDeclaration::TokenizerDriver => { - ExpectedFailureClassification::Execution(ExecutionFailureClass::TokenizerDriver) + ExpectedFailureClassification::Execution(LegacyExecutionFailureClass::TokenizerDriver) } ExpectedFailureDeclaration::ValidatedFixtureInvariant => { ExpectedFailureClassification::Execution( - ExecutionFailureClass::ValidatedFixtureInvariant, + LegacyExecutionFailureClass::ValidatedFixtureInvariant, ) } ExpectedFailureDeclaration::TokensMismatch => { @@ -1037,6 +1368,39 @@ fn map_expected_failure(value: ExpectedFailureDeclaration) -> ExpectedFailureCla } } +fn map_final_invariant( + bundle: &FixtureBundle, + code: &str, +) -> Result { + let value = match code { + "decoder-carry-not-empty" => InvariantFailureCode::DecoderCarryNotEmpty, + "preprocessing-not-flushed" => InvariantFailureCode::PreprocessingNotFlushed, + "eof-emission-invalid" => InvariantFailureCode::EofEmissionInvalid, + "pending-tokenizer-construct" => InvariantFailureCode::PendingTokenizerConstruct, + "tokenizer-output-unaccounted" => InvariantFailureCode::TokenizerOutputUnaccounted, + "pending-table-text" => InvariantFailureCode::PendingTableText, + "invalid-insertion-mode" => InvariantFailureCode::InvalidInsertionMode, + "open-elements-inconsistent" => InvariantFailureCode::OpenElementsInconsistent, + "active-formatting-inconsistent" => InvariantFailureCode::ActiveFormattingInconsistent, + "template-modes-inconsistent" => InvariantFailureCode::TemplateModesInconsistent, + "form-pointer-invalid" => InvariantFailureCode::FormPointerInvalid, + "parent-child-relationship-invalid" => InvariantFailureCode::ParentChildRelationshipInvalid, + "namespace-relationship-invalid" => InvariantFailureCode::NamespaceRelationshipInvalid, + "template-association-invalid" => InvariantFailureCode::TemplateAssociationInvalid, + "patch-materialization-incomplete" => InvariantFailureCode::PatchMaterializationIncomplete, + "live-tree-mismatch" => InvariantFailureCode::LiveTreeMismatch, + _ => { + return Err(bundle_error( + bundle, + FixtureLoadErrorKind::InvalidDisposition( + "unknown final-invariant code".to_string(), + ), + )); + } + }; + Ok(value) +} + fn validate_skip_classification( bundle: &FixtureBundle, declaration: SkipClassificationDeclaration, @@ -1178,7 +1542,8 @@ fn require_non_active_capability( return Err(bundle_error( bundle, FixtureLoadErrorKind::InvalidDisposition(format!( - "completed Milestone AE capability {capability:?} cannot use {disposition}" + "completed Milestone AE capability {} cannot use {disposition}", + capability_name(capability) )), )); } @@ -1190,21 +1555,23 @@ fn require_non_active_failure( failure: &ExpectedFailureClassification, ) -> Result<(), FixtureLoadError> { let capability = match failure { - ExpectedFailureClassification::Execution(ExecutionFailureClass::SnapshotRead(surface)) - | ExpectedFailureClassification::Execution(ExecutionFailureClass::SnapshotFormat( + ExpectedFailureClassification::Execution(LegacyExecutionFailureClass::SnapshotRead( + surface, + )) + | ExpectedFailureClassification::Execution(LegacyExecutionFailureClass::SnapshotFormat( surface, )) | ExpectedFailureClassification::ExpectationMismatch(surface) => { Some(FixtureCapability::Expectation(*surface)) } - ExpectedFailureClassification::Execution(ExecutionFailureClass::TokenizerDriver) => { + ExpectedFailureClassification::Execution(LegacyExecutionFailureClass::TokenizerDriver) => { Some(FixtureCapability::Expectation(ExpectationSurface::Tokens)) } ExpectedFailureClassification::InvariantFailure(_) => Some(FixtureCapability::Expectation( ExpectationSurface::FinalInvariants, )), ExpectedFailureClassification::Execution( - ExecutionFailureClass::ValidatedFixtureInvariant, + LegacyExecutionFailureClass::ValidatedFixtureInvariant, ) => None, }; let Some(capability) = capability else { diff --git a/crates/html_test_support/src/parser_snapshot/document_mode.rs b/crates/html_test_support/src/parser_snapshot/document_mode.rs new file mode 100644 index 00000000..d63f3687 --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/document_mode.rs @@ -0,0 +1,61 @@ +use super::SnapshotData; +use super::lexical::{SnapshotReadError, SnapshotRecord, fixed_fields, strict_record_lines}; +use html::DocumentMode; +use html::conformance::ObservationState; + +const HEADER: &str = "# format: html5-document-mode-v1"; + +define_snapshot_types!(ParsedDocumentModeSnapshot, CanonicalDocumentModeSnapshot); + +pub(super) fn write( + state: &ObservationState, +) -> Result { + let ObservationState::Captured(mode) = state else { + return Err(()); + }; + let line = match mode { + DocumentMode::NoQuirks => "MODE value=no-quirks", + DocumentMode::LimitedQuirks => "MODE value=limited-quirks", + DocumentMode::Quirks => "MODE value=quirks", + } + .to_string(); + Ok(CanonicalDocumentModeSnapshot::new(SnapshotData::new( + format!("{HEADER}\n{line}\n"), + vec![SnapshotRecord { + location: "document mode".to_string(), + line, + }], + ))) +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let lines = strict_record_lines(bytes, HEADER, false)?; + if lines.len() != 1 { + return Err(SnapshotReadError::TrailingContent { + line: lines.get(1).map_or(2, |v| v.0), + }); + } + let (line_number, line) = lines[0]; + let Some(fields) = fixed_fields(line, "MODE", &["value"]) else { + return malformed(line_number); + }; + if !matches!(fields[0], "no-quirks" | "limited-quirks" | "quirks") { + return malformed(line_number); + } + Ok(ParsedDocumentModeSnapshot::new(SnapshotData::new( + std::str::from_utf8(bytes) + .map_err(|_| SnapshotReadError::InvalidUtf8)? + .to_string(), + vec![SnapshotRecord { + location: "document mode".to_string(), + line: line.to_string(), + }], + ))) +} + +fn malformed(line: usize) -> Result { + Err(SnapshotReadError::MalformedRecord { + line, + reason: "invalid document-mode record", + }) +} diff --git a/crates/html_test_support/src/parser_snapshot/implementation_diagnostics.rs b/crates/html_test_support/src/parser_snapshot/implementation_diagnostics.rs new file mode 100644 index 00000000..28699538 --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/implementation_diagnostics.rs @@ -0,0 +1,372 @@ +use super::SnapshotData; +use super::lexical::{ + SnapshotReadError, SnapshotRecord, fixed_fields, strict_record_lines, validate_u64, +}; +use crate::parser_snapshot::parse_errors::insertion_mode_name; +use html::conformance::*; +use std::fmt::Write; + +const HEADER: &str = "# format: html5-implementation-diagnostics-v1"; + +define_snapshot_types!( + ParsedImplementationDiagnosticsSnapshot, + CanonicalImplementationDiagnosticsSnapshot +); + +pub(super) fn write( + state: &ObservationState>, +) -> Result { + let ObservationState::Captured(events) = state else { + return Err(()); + }; + let mut bytes = format!("{HEADER}\n"); + let mut records = Vec::new(); + for event in events { + let metadata = event.metadata(); + let (code, payload) = match event { + ImplementationDiagnosticEvent::InvalidUtf8Replaced { + reason, payload, .. + } => ( + match reason { + Utf8ReplacementReason::InvalidSequence => { + "invalid-utf8-replaced:invalid-sequence" + } + Utf8ReplacementReason::IncompleteSequenceAtEof => { + "invalid-utf8-replaced:incomplete-sequence-at-eof" + } + }, + format!("affected-byte-count:{}", payload.affected_byte_count.get()), + ), + ImplementationDiagnosticEvent::ParserResourceLimitActivated { + limit, payload, .. + } => ( + resource_limit_name(*limit), + format!("configured-limit:{}", payload.configured_limit), + ), + ImplementationDiagnosticEvent::ParserGuardrailActivated { + guardrail, payload, .. + } => ( + match guardrail { + ParserGuardrail::TokenizerStallRecovery => { + "parser-guardrail:tokenizer-stall-recovery" + } + }, + format!( + "consecutive-stall-steps:{}", + payload.consecutive_stall_steps.get() + ), + ), + ImplementationDiagnosticEvent::TreeConstruction { code, .. } => { + (tree_code_name(*code), "none".to_string()) + } + }; + let (context, token, mode, namespace) = context_fields(metadata.context.as_ref()); + let line = format!( + "IMPLEMENTATION_DIAGNOSTIC occurrence={} stage={} code={} payload={} position={} context={} context-token={} context-mode={} context-namespace={}", + metadata.occurrence, + stage_name(metadata.stage), + code, + payload, + position_name(&metadata.position), + context, + token, + mode, + namespace + ); + let _ = writeln!(&mut bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("occurrence {}", metadata.occurrence), + line, + }); + } + Ok(CanonicalImplementationDiagnosticsSnapshot::new( + SnapshotData::new(bytes, records), + )) +} + +pub(super) fn read( + bytes: &[u8], +) -> Result { + let lines = strict_record_lines(bytes, HEADER, true)?; + let mut expected = 1u64; + let mut records = Vec::new(); + for (line_number, line) in lines { + let Some(fields) = fixed_fields( + line, + "IMPLEMENTATION_DIAGNOSTIC", + &[ + "occurrence", + "stage", + "code", + "payload", + "position", + "context", + "context-token", + "context-mode", + "context-namespace", + ], + ) else { + return malformed( + line_number, + "invalid implementation-diagnostic record shape", + ); + }; + if !validate_u64(fields[0]) || fields[0].parse::().ok() != Some(expected) { + return Err(SnapshotReadError::NonContiguousOrdinal { line: line_number }); + } + if !valid_stage(fields[1]) + || !valid_code_payload(fields[2], fields[3]) + || !valid_position(fields[4]) + || !valid_context(fields[5], fields[6], fields[7], fields[8]) + { + return malformed( + line_number, + "unknown spelling or malformed implementation-diagnostic field", + ); + } + records.push(SnapshotRecord { + location: format!("occurrence {expected}"), + line: line.to_string(), + }); + expected = expected + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?; + } + Ok(ParsedImplementationDiagnosticsSnapshot::new( + SnapshotData::new( + std::str::from_utf8(bytes) + .map_err(|_| SnapshotReadError::InvalidUtf8)? + .to_string(), + records, + ), + )) +} + +fn resource_limit_name(value: ParserResourceLimit) -> &'static str { + match value { + ParserResourceLimit::TokenBatchCapacity => "parser-resource-limit:token-batch-capacity", + ParserResourceLimit::TagNameBytes => "parser-resource-limit:tag-name-bytes", + ParserResourceLimit::AttributeNameBytes => "parser-resource-limit:attribute-name-bytes", + ParserResourceLimit::AttributeValueBytes => "parser-resource-limit:attribute-value-bytes", + ParserResourceLimit::AttributesPerTag => "parser-resource-limit:attributes-per-tag", + ParserResourceLimit::CommentBytes => "parser-resource-limit:comment-bytes", + ParserResourceLimit::ProcessingInstructionTargetBytes => { + "parser-resource-limit:processing-instruction-target-bytes" + } + ParserResourceLimit::ProcessingInstructionDataBytes => { + "parser-resource-limit:processing-instruction-data-bytes" + } + ParserResourceLimit::DoctypeBytes => "parser-resource-limit:doctype-bytes", + ParserResourceLimit::EndTagMatchScanBytes => { + "parser-resource-limit:end-tag-match-scan-bytes" + } + ParserResourceLimit::NumericCharacterReferenceDigits => { + "parser-resource-limit:numeric-character-reference-digits" + } + ParserResourceLimit::TreeOpenElementsDepth => { + "parser-resource-limit:tree-open-elements-depth" + } + ParserResourceLimit::TreeNodeCount => "parser-resource-limit:tree-node-count", + ParserResourceLimit::TreeChildrenPerNode => "parser-resource-limit:tree-children-per-node", + ParserResourceLimit::TreeTemplateModeDepth => { + "parser-resource-limit:tree-template-mode-depth" + } + } +} +fn tree_code_name(value: TreeConstructionImplementationDiagnosticCode) -> &'static str { + match value { + TreeConstructionImplementationDiagnosticCode::UnsupportedTableInsertionModeFallback => "tree-construction:unsupported-table-insertion-mode-fallback", + TreeConstructionImplementationDiagnosticCode::UnexpectedStartTagTokenInTextMode => "tree-construction:unexpected-start-tag-token-in-text-mode", + TreeConstructionImplementationDiagnosticCode::TextModeStartTagAttributeValuesDiscarded => "tree-construction:text-mode-start-tag-attribute-values-discarded", + TreeConstructionImplementationDiagnosticCode::TextModeStartTagAttributeNamesCanonicalized => "tree-construction:text-mode-start-tag-attribute-names-canonicalized", + TreeConstructionImplementationDiagnosticCode::UnexpectedDoctypeTokenInTextMode => "tree-construction:unexpected-doctype-token-in-text-mode", + TreeConstructionImplementationDiagnosticCode::UnexpectedEndTagTokenInTextMode => "tree-construction:unexpected-end-tag-token-in-text-mode", + TreeConstructionImplementationDiagnosticCode::NonVoidHtmlSelfClosingFlagAlteredStackDisposition => "tree-construction:non-void-html-self-closing-flag-altered-stack-disposition", +} +} +fn stage_name(value: ParserStage) -> &'static str { + match value { + ParserStage::InputPreprocessing(InputPreprocessingStage::Utf8Decoding) => { + "input-preprocessing:utf8-decoding" + } + ParserStage::InputPreprocessing(InputPreprocessingStage::NewlineNormalization) => { + "input-preprocessing:newline-normalization" + } + ParserStage::Tokenizer => "tokenizer", + ParserStage::TreeConstruction => "tree-construction", + ParserStage::Finalization => "finalization", + } +} +fn position_name(value: &EventPosition) -> String { + match value { + EventPosition::Unavailable(PositionUnavailableReason::ParserDidNotProvidePosition) => { + "unavailable:parser-did-not-provide-position".to_string() + } + EventPosition::Known(position) => { + let source = match position.source_bytes { + SourceBytePosition::Exact(value) => format!("exact:{value}"), + SourceBytePosition::Unavailable( + SourcePositionUnavailableReason::NoInputProvenanceMap, + ) => "unavailable:no-input-provenance-map".to_string(), + }; + match position.normalized.space { + InputCoordinateSpace::NormalizedUtf8 => format!( + "normalized-utf8:{}:{}:{}:source-{source}", + position.normalized.utf8_byte_offset, + position.normalized.line.get(), + position.normalized.column.get() + ), + } + } + } +} +fn context_fields( + value: Option<&ParserContextSummary>, +) -> (&'static str, &'static str, &'static str, &'static str) { + let Some(value) = value else { + return ("absent", "null", "null", "null"); + }; + ( + "present", + value.token_kind.map_or("null", token_kind_name), + value.insertion_mode.map_or("null", insertion_mode_name), + value + .adjusted_current_node_namespace + .map_or("null", |v| v.snapshot_name()), + ) +} +fn token_kind_name(value: ParserTokenKind) -> &'static str { + match value { + ParserTokenKind::Doctype => "doctype", + ParserTokenKind::StartTag => "start-tag", + ParserTokenKind::EndTag => "end-tag", + ParserTokenKind::Character => "character", + ParserTokenKind::Comment => "comment", + ParserTokenKind::ProcessingInstruction => "processing-instruction", + ParserTokenKind::Eof => "eof", + } +} +fn valid_stage(v: &str) -> bool { + matches!( + v, + "input-preprocessing:utf8-decoding" + | "input-preprocessing:newline-normalization" + | "tokenizer" + | "tree-construction" + | "finalization" + ) +} +fn valid_code_payload(code: &str, payload: &str) -> bool { + if matches!( + code, + "invalid-utf8-replaced:invalid-sequence" + | "invalid-utf8-replaced:incomplete-sequence-at-eof" + ) { + return payload + .strip_prefix("affected-byte-count:") + .is_some_and(|v| validate_u64(v) && v != "0"); + } + if let Some(limit) = code.strip_prefix("parser-resource-limit:") { + return matches!( + limit, + "token-batch-capacity" + | "tag-name-bytes" + | "attribute-name-bytes" + | "attribute-value-bytes" + | "attributes-per-tag" + | "comment-bytes" + | "processing-instruction-target-bytes" + | "processing-instruction-data-bytes" + | "doctype-bytes" + | "end-tag-match-scan-bytes" + | "numeric-character-reference-digits" + | "tree-open-elements-depth" + | "tree-node-count" + | "tree-children-per-node" + | "tree-template-mode-depth" + ) && payload + .strip_prefix("configured-limit:") + .is_some_and(validate_u64); + } + if code == "parser-guardrail:tokenizer-stall-recovery" { + return payload + .strip_prefix("consecutive-stall-steps:") + .is_some_and(|v| validate_u64(v) && v != "0"); + } + matches!( + code, + "tree-construction:unsupported-table-insertion-mode-fallback" + | "tree-construction:unexpected-start-tag-token-in-text-mode" + | "tree-construction:text-mode-start-tag-attribute-values-discarded" + | "tree-construction:text-mode-start-tag-attribute-names-canonicalized" + | "tree-construction:unexpected-doctype-token-in-text-mode" + | "tree-construction:unexpected-end-tag-token-in-text-mode" + | "tree-construction:non-void-html-self-closing-flag-altered-stack-disposition" + ) && payload == "none" +} +fn valid_position(value: &str) -> bool { + if value == "unavailable:parser-did-not-provide-position" { + return true; + } + let Some(rest) = value.strip_prefix("normalized-utf8:") else { + return false; + }; + let Some((offset, rest)) = rest.split_once(':') else { + return false; + }; + let Some((line, rest)) = rest.split_once(':') else { + return false; + }; + let Some((column, source)) = rest.split_once(":source-") else { + return false; + }; + validate_u64(offset) + && validate_u64(line) + && line != "0" + && validate_u64(column) + && column != "0" + && (source == "unavailable:no-input-provenance-map" + || source.strip_prefix("exact:").is_some_and(validate_u64)) +} +fn valid_context(context: &str, token: &str, mode: &str, namespace: &str) -> bool { + match context { + "absent" => token == "null" && mode == "null" && namespace == "null", + "present" => { + matches!( + token, + "null" + | "doctype" + | "start-tag" + | "end-tag" + | "character" + | "comment" + | "processing-instruction" + | "eof" + ) && matches!( + mode, + "null" + | "initial" + | "before-html" + | "before-head" + | "in-head" + | "after-head" + | "in-body" + | "after-body" + | "after-after-body" + | "in-table" + | "in-table-text" + | "in-caption" + | "in-column-group" + | "in-table-body" + | "in-row" + | "in-cell" + | "in-template" + | "text" + ) && matches!(namespace, "null" | "html" | "svg" | "mathml") + } + _ => false, + } +} +fn malformed(line: usize, reason: &'static str) -> Result { + Err(SnapshotReadError::MalformedRecord { line, reason }) +} diff --git a/crates/html_test_support/src/parser_snapshot/lexical.rs b/crates/html_test_support/src/parser_snapshot/lexical.rs new file mode 100644 index 00000000..daa7a13f --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/lexical.rs @@ -0,0 +1,231 @@ +use std::fmt::Write; +use std::ops::Range; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SnapshotRecord { + pub(crate) location: String, + pub(crate) line: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StoredSnapshotRecord { + pub(crate) location: String, + pub(crate) line: Range, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SnapshotReadError { + InvalidUtf8, + BomNotAllowed, + CarriageReturnNotAllowed, + MissingTerminalLf, + InvalidHeader, + HeaderOnlyNotAllowed, + BlankLine { line: usize }, + CommentNotAllowed { line: usize }, + MalformedRecord { line: usize, reason: &'static str }, + DuplicateLocation { line: usize }, + NonContiguousOrdinal { line: usize }, + TrailingContent { line: usize }, +} + +impl std::fmt::Display for SnapshotReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUtf8 => f.write_str("snapshot must be valid UTF-8"), + Self::BomNotAllowed => f.write_str("snapshot must not begin with a UTF-8 BOM"), + Self::CarriageReturnNotAllowed => { + f.write_str("snapshot must use LF line endings; carriage return is forbidden") + } + Self::MissingTerminalLf => f.write_str("snapshot must end with exactly one LF"), + Self::InvalidHeader => f.write_str("snapshot format header is missing or incorrect"), + Self::HeaderOnlyNotAllowed => { + f.write_str("this snapshot surface requires at least one record") + } + Self::BlankLine { line } => write!(f, "blank physical line at line {line}"), + Self::CommentNotAllowed { line } => write!(f, "comment is forbidden at line {line}"), + Self::MalformedRecord { line, reason } => { + write!(f, "malformed snapshot record at line {line}: {reason}") + } + Self::DuplicateLocation { line } => { + write!(f, "duplicate snapshot record location at line {line}") + } + Self::NonContiguousOrdinal { line } => { + write!(f, "non-contiguous snapshot ordinal at line {line}") + } + Self::TrailingContent { line } => { + write!(f, "snapshot contains trailing content at line {line}") + } + } + } +} + +impl std::error::Error for SnapshotReadError {} + +pub(crate) fn strict_record_lines<'a>( + bytes: &'a [u8], + header: &str, + header_only_allowed: bool, +) -> Result, SnapshotReadError> { + let text = std::str::from_utf8(bytes).map_err(|_| SnapshotReadError::InvalidUtf8)?; + if text.starts_with('\u{feff}') { + return Err(SnapshotReadError::BomNotAllowed); + } + if text.contains('\r') { + return Err(SnapshotReadError::CarriageReturnNotAllowed); + } + if !text.ends_with('\n') || text.ends_with("\n\n") { + return Err(SnapshotReadError::MissingTerminalLf); + } + let mut lines = text + .strip_suffix('\n') + .expect("terminal LF checked") + .split('\n'); + if lines.next() != Some(header) { + return Err(SnapshotReadError::InvalidHeader); + } + let records = lines + .enumerate() + .map(|(index, line)| (index + 2, line)) + .collect::>(); + if records.is_empty() && !header_only_allowed { + return Err(SnapshotReadError::HeaderOnlyNotAllowed); + } + for (line_number, line) in &records { + if line.is_empty() { + return Err(SnapshotReadError::BlankLine { line: *line_number }); + } + if line.starts_with('#') { + return Err(SnapshotReadError::CommentNotAllowed { line: *line_number }); + } + if line.starts_with(' ') || line.ends_with(' ') || line.contains(" ") { + return Err(SnapshotReadError::MalformedRecord { + line: *line_number, + reason: "records use one ASCII space and no surrounding whitespace", + }); + } + } + Ok(records) +} + +pub(crate) fn escape_quoted(value: &str) -> String { + let mut result = String::with_capacity(value.len() + 2); + result.push('"'); + for ch in value.chars() { + match ch { + '"' => result.push_str("\\\""), + '\\' => result.push_str("\\\\"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\0'..='\u{0008}' | '\u{000b}' | '\u{000c}' | '\u{000e}'..='\u{001f}' | '\u{007f}' => { + let _ = write!(&mut result, "\\u{:04X}", u32::from(ch)); + } + _ => result.push(ch), + } + } + result.push('"'); + result +} + +pub(crate) fn optional_quoted(value: Option<&str>) -> String { + value.map_or_else(|| "null".to_string(), escape_quoted) +} + +pub(crate) fn validate_quoted(value: &str) -> bool { + let Some(mut rest) = value.strip_prefix('"') else { + return false; + }; + loop { + let Some(ch) = rest.chars().next() else { + return false; + }; + rest = &rest[ch.len_utf8()..]; + match ch { + '"' => return rest.is_empty(), + '\\' => { + let Some(escape) = rest.chars().next() else { + return false; + }; + rest = &rest[escape.len_utf8()..]; + match escape { + '"' | '\\' | 'n' | 'r' | 't' => {} + 'u' => { + if rest.len() < 4 { + return false; + } + let digits = &rest[..4]; + if !digits + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'A'..=b'F').contains(&byte)) + { + return false; + } + let Ok(scalar) = u32::from_str_radix(digits, 16) else { + return false; + }; + let Some(scalar) = char::from_u32(scalar) else { + return false; + }; + if !matches!(scalar, '\0'..='\u{0008}' | '\u{000b}' | '\u{000c}' | '\u{000e}'..='\u{001f}' | '\u{007f}') + { + return false; + } + rest = &rest[4..]; + } + _ => return false, + } + } + '\0'..='\u{001f}' | '\u{007f}' => return false, + _ => {} + } + } +} + +pub(crate) fn validate_nullable_quoted(value: &str) -> bool { + value == "null" || validate_quoted(value) +} + +pub(crate) fn validate_u64(value: &str) -> bool { + value == "0" + || (!value.starts_with('0') + && !value.is_empty() + && value.bytes().all(|b| b.is_ascii_digit())) +} + +pub(crate) fn validate_bool(value: &str) -> bool { + matches!(value, "true" | "false") +} + +/// Split a fixed-order record without interpreting its semantic payload. +pub(crate) fn fixed_fields<'a>(line: &'a str, record: &str, keys: &[&str]) -> Option> { + let mut rest = line.strip_prefix(record)?; + let mut values = Vec::with_capacity(keys.len()); + for key in keys { + rest = rest.strip_prefix(' ')?; + rest = rest.strip_prefix(key)?.strip_prefix('=')?; + let (value, tail) = consume_field(rest)?; + values.push(value); + rest = tail; + } + rest.is_empty().then_some(values) +} + +fn consume_field(value: &str) -> Option<(&str, &str)> { + if value.starts_with('"') { + let mut escaped = false; + for (index, ch) in value.char_indices().skip(1) { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + return Some((&value[..=index], &value[index + 1..])); + } + } + None + } else { + let end = value.find(' ').unwrap_or(value.len()); + Some((&value[..end], &value[end..])) + } +} diff --git a/crates/html_test_support/src/parser_snapshot/mod.rs b/crates/html_test_support/src/parser_snapshot/mod.rs new file mode 100644 index 00000000..ec1c07c9 --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/mod.rs @@ -0,0 +1,269 @@ +macro_rules! define_snapshot_types { + ($parsed:ident, $canonical:ident) => { + #[derive(Clone, Debug, PartialEq, Eq)] + pub(crate) struct $parsed(super::SnapshotData); + + impl $parsed { + fn new(data: super::SnapshotData) -> Self { + Self(data) + } + + pub(super) fn data(&self) -> &super::SnapshotData { + &self.0 + } + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub(crate) struct $canonical(super::SnapshotData); + + impl $canonical { + fn new(data: super::SnapshotData) -> Self { + Self(data) + } + + pub(super) fn data(&self) -> &super::SnapshotData { + &self.0 + } + } + }; +} + +mod document_mode; +mod implementation_diagnostics; +mod lexical; +mod parse_errors; +mod patches; +mod token_v2; +mod transitions; +mod tree; +mod unsupported_features; + +use crate::parser_fixture::ExpectationSurface; +use html::conformance::CanonicalParserResult; +pub use lexical::SnapshotReadError; +use lexical::{SnapshotRecord, StoredSnapshotRecord}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SnapshotFormat { + TokenV2, + ParseErrorsV1, + ImplementationDiagnosticsV1, + DocumentModeV1, + DomV3, + DomPatchV3, + TreeTransitionsV1, + UnsupportedFeaturesV1, +} + +impl SnapshotFormat { + pub(crate) const fn name(self) -> &'static str { + match self { + Self::TokenV2 => "html5-token-v2", + Self::ParseErrorsV1 => "html5-parse-errors-v1", + Self::ImplementationDiagnosticsV1 => "html5-implementation-diagnostics-v1", + Self::DocumentModeV1 => "html5-document-mode-v1", + Self::DomV3 => "html5-dom-v3", + Self::DomPatchV3 => "html5-dompatch-v3", + Self::TreeTransitionsV1 => "html5-tree-transitions-v1", + Self::UnsupportedFeaturesV1 => "html5-unsupported-features-v1", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SnapshotData { + bytes: String, + records: Vec, +} + +impl SnapshotData { + fn new(bytes: String, records: Vec) -> Self { + let mut cursor = bytes.find('\n').map_or(bytes.len(), |index| index + 1); + let mut stored = Vec::with_capacity(records.len()); + for record in records { + let end = cursor + .checked_add(record.line.len()) + .expect("validated snapshot record range"); + debug_assert_eq!(bytes.get(cursor..end), Some(record.line.as_str())); + stored.push(StoredSnapshotRecord { + location: record.location, + line: cursor..end, + }); + cursor = end + .checked_add(1) + .expect("validated snapshot terminal LF range"); + } + Self { + bytes, + records: stored, + } + } + + #[cfg(test)] + pub(crate) fn bytes(&self) -> &str { + &self.bytes + } + + pub(crate) fn record_count(&self) -> usize { + self.records.len() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.records.is_empty() + } + + pub(crate) fn record(&self, index: usize) -> Option> { + self.records.get(index).map(|record| SnapshotRecordRef { + location: &record.location, + line: &self.bytes[record.line.clone()], + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SnapshotRecordRef<'a> { + pub(crate) location: &'a str, + pub(crate) line: &'a str, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ParsedSnapshot { + Tokens(token_v2::ParsedTokenSnapshot), + ParseErrors(parse_errors::ParsedParseErrorsSnapshot), + ImplementationDiagnostics(implementation_diagnostics::ParsedImplementationDiagnosticsSnapshot), + DocumentMode(document_mode::ParsedDocumentModeSnapshot), + Tree(tree::ParsedTreeSnapshot), + Patches(patches::ParsedPatchesSnapshot), + Transitions(transitions::ParsedTransitionsSnapshot), + UnsupportedFeatures(unsupported_features::ParsedUnsupportedFeaturesSnapshot), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CanonicalSnapshot { + Tokens(token_v2::CanonicalTokenSnapshot), + ParseErrors(parse_errors::CanonicalParseErrorsSnapshot), + ImplementationDiagnostics( + implementation_diagnostics::CanonicalImplementationDiagnosticsSnapshot, + ), + DocumentMode(document_mode::CanonicalDocumentModeSnapshot), + Tree(tree::CanonicalTreeSnapshot), + Patches(patches::CanonicalPatchesSnapshot), + Transitions(transitions::CanonicalTransitionsSnapshot), + UnsupportedFeatures(unsupported_features::CanonicalUnsupportedFeaturesSnapshot), +} + +macro_rules! snapshot_accessors { + ($ty:ty) => { + impl $ty { + pub(crate) fn surface(&self) -> ExpectationSurface { + match self { + Self::Tokens(_) => ExpectationSurface::Tokens, + Self::ParseErrors(_) => ExpectationSurface::ParseErrors, + Self::ImplementationDiagnostics(_) => { + ExpectationSurface::ImplementationDiagnostics + } + Self::DocumentMode(_) => ExpectationSurface::DocumentMode, + Self::Tree(_) => ExpectationSurface::Tree, + Self::Patches(_) => ExpectationSurface::Patches, + Self::Transitions(_) => ExpectationSurface::Transitions, + Self::UnsupportedFeatures(_) => ExpectationSurface::UnsupportedFeatures, + } + } + + pub(crate) fn format(&self) -> SnapshotFormat { + match self { + Self::Tokens(_) => SnapshotFormat::TokenV2, + Self::ParseErrors(_) => SnapshotFormat::ParseErrorsV1, + Self::ImplementationDiagnostics(_) => { + SnapshotFormat::ImplementationDiagnosticsV1 + } + Self::DocumentMode(_) => SnapshotFormat::DocumentModeV1, + Self::Tree(_) => SnapshotFormat::DomV3, + Self::Patches(_) => SnapshotFormat::DomPatchV3, + Self::Transitions(_) => SnapshotFormat::TreeTransitionsV1, + Self::UnsupportedFeatures(_) => SnapshotFormat::UnsupportedFeaturesV1, + } + } + + pub(crate) fn snapshot(&self) -> &SnapshotData { + match self { + Self::Tokens(value) => value.data(), + Self::ParseErrors(value) => value.data(), + Self::ImplementationDiagnostics(value) => value.data(), + Self::DocumentMode(value) => value.data(), + Self::Tree(value) => value.data(), + Self::Patches(value) => value.data(), + Self::Transitions(value) => value.data(), + Self::UnsupportedFeatures(value) => value.data(), + } + } + } + }; +} + +snapshot_accessors!(ParsedSnapshot); +snapshot_accessors!(CanonicalSnapshot); + +pub(crate) fn read_snapshot( + surface: ExpectationSurface, + bytes: &[u8], +) -> Result { + match surface { + ExpectationSurface::Tokens => token_v2::read(bytes).map(ParsedSnapshot::Tokens), + ExpectationSurface::ParseErrors => { + parse_errors::read(bytes).map(ParsedSnapshot::ParseErrors) + } + ExpectationSurface::ImplementationDiagnostics => { + implementation_diagnostics::read(bytes).map(ParsedSnapshot::ImplementationDiagnostics) + } + ExpectationSurface::DocumentMode => { + document_mode::read(bytes).map(ParsedSnapshot::DocumentMode) + } + ExpectationSurface::Tree => tree::read(bytes).map(ParsedSnapshot::Tree), + ExpectationSurface::Patches => patches::read(bytes).map(ParsedSnapshot::Patches), + ExpectationSurface::Transitions => { + transitions::read(bytes).map(ParsedSnapshot::Transitions) + } + ExpectationSurface::UnsupportedFeatures => { + unsupported_features::read(bytes).map(ParsedSnapshot::UnsupportedFeatures) + } + ExpectationSurface::FinalInvariants => Err(SnapshotReadError::InvalidHeader), + } +} + +pub(crate) fn serialize_snapshot( + surface: ExpectationSurface, + result: &CanonicalParserResult, +) -> Result { + match surface { + ExpectationSurface::Tokens => { + token_v2::write(&result.tokens).map(CanonicalSnapshot::Tokens) + } + ExpectationSurface::ParseErrors => { + parse_errors::write(&result.parse_errors).map(CanonicalSnapshot::ParseErrors) + } + ExpectationSurface::ImplementationDiagnostics => { + implementation_diagnostics::write(&result.implementation_diagnostics) + .map(CanonicalSnapshot::ImplementationDiagnostics) + } + ExpectationSurface::DocumentMode => { + document_mode::write(&result.document_mode).map(CanonicalSnapshot::DocumentMode) + } + ExpectationSurface::Tree => tree::write(&result.tree).map(CanonicalSnapshot::Tree), + ExpectationSurface::Patches => { + patches::write(&result.patches).map(CanonicalSnapshot::Patches) + } + ExpectationSurface::Transitions => { + transitions::write(&result.transitions).map(CanonicalSnapshot::Transitions) + } + ExpectationSurface::UnsupportedFeatures => { + unsupported_features::write(&result.unsupported_features) + .map(CanonicalSnapshot::UnsupportedFeatures) + } + ExpectationSurface::FinalInvariants => Err(()), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/html_test_support/src/parser_snapshot/parse_errors.rs b/crates/html_test_support/src/parser_snapshot/parse_errors.rs new file mode 100644 index 00000000..a503251a --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/parse_errors.rs @@ -0,0 +1,674 @@ +use super::SnapshotData; +use super::lexical::{ + SnapshotReadError, SnapshotRecord, fixed_fields, strict_record_lines, validate_u64, +}; +use html::ElementNamespace; +use html::conformance::*; +use std::fmt::Write; + +const HEADER: &str = "# format: html5-parse-errors-v1"; + +define_snapshot_types!(ParsedParseErrorsSnapshot, CanonicalParseErrorsSnapshot); + +pub(super) fn write( + state: &ObservationState>, +) -> Result { + let ObservationState::Captured(events) = state else { + return Err(()); + }; + let mut bytes = format!("{HEADER}\n"); + let mut records = Vec::new(); + for event in events { + let (context, token, mode, namespace) = context_fields(event.context.as_ref()); + let line = format!( + "PARSE_ERROR occurrence={} stage={} code={} recovery={} position={} context={} context-token={} context-mode={} context-namespace={}", + event.occurrence, + stage_name(event.stage), + code_name(event.code), + recovery_name(event.recovery.as_ref()), + position_name(&event.position), + context, + token, + mode, + namespace + ); + let _ = writeln!(&mut bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("occurrence {}", event.occurrence), + line, + }); + } + Ok(CanonicalParseErrorsSnapshot::new(SnapshotData::new( + bytes, records, + ))) +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let lines = strict_record_lines(bytes, HEADER, true)?; + let mut expected = 1u64; + let mut records = Vec::new(); + for (line_number, line) in lines { + let Some(fields) = fixed_fields( + line, + "PARSE_ERROR", + &[ + "occurrence", + "stage", + "code", + "recovery", + "position", + "context", + "context-token", + "context-mode", + "context-namespace", + ], + ) else { + return malformed(line_number, "invalid parse-error record shape"); + }; + if !validate_u64(fields[0]) || fields[0].parse::().ok() != Some(expected) { + return Err(SnapshotReadError::NonContiguousOrdinal { line: line_number }); + } + if !valid_stage(fields[1]) + || !valid_code(fields[2]) + || !valid_recovery(fields[3]) + || !valid_position(fields[4]) + || !valid_context(fields[5], fields[6], fields[7], fields[8]) + { + return malformed( + line_number, + "unknown spelling or malformed parse-error field", + ); + } + records.push(SnapshotRecord { + location: format!("occurrence {expected}"), + line: line.to_string(), + }); + expected = expected + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?; + } + let text = std::str::from_utf8(bytes).map_err(|_| SnapshotReadError::InvalidUtf8)?; + Ok(ParsedParseErrorsSnapshot::new(SnapshotData::new( + text.to_string(), + records, + ))) +} + +fn stage_name(stage: ParserStage) -> &'static str { + match stage { + ParserStage::InputPreprocessing(InputPreprocessingStage::Utf8Decoding) => { + "input-preprocessing:utf8-decoding" + } + ParserStage::InputPreprocessing(InputPreprocessingStage::NewlineNormalization) => { + "input-preprocessing:newline-normalization" + } + ParserStage::Tokenizer => "tokenizer", + ParserStage::TreeConstruction => "tree-construction", + ParserStage::Finalization => "finalization", + } +} + +fn valid_stage(value: &str) -> bool { + matches!( + value, + "input-preprocessing:utf8-decoding" + | "input-preprocessing:newline-normalization" + | "tokenizer" + | "tree-construction" + | "finalization" + ) +} + +fn code_name(code: ParseErrorCode) -> &'static str { + match code { + ParseErrorCode::Standard(code) => whatwg_code(code), + ParseErrorCode::TokenizerExtension(code) => tokenizer_extension_code(code), + ParseErrorCode::TreeConstruction(code) => tree_code(code), + } +} + +fn whatwg_code(code: WhatwgParseErrorCode) -> &'static str { + match code { + WhatwgParseErrorCode::UnexpectedNullCharacter => "standard:unexpected-null-character", + WhatwgParseErrorCode::EofBeforeTagName => "standard:eof-before-tag-name", + WhatwgParseErrorCode::InvalidFirstCharacterOfTagName => { + "standard:invalid-first-character-of-tag-name" + } + WhatwgParseErrorCode::MissingEndTagName => "standard:missing-end-tag-name", + WhatwgParseErrorCode::EofInTag => "standard:eof-in-tag", + WhatwgParseErrorCode::UnexpectedCharacterInAttributeName => { + "standard:unexpected-character-in-attribute-name" + } + WhatwgParseErrorCode::UnexpectedEqualsSignBeforeAttributeName => { + "standard:unexpected-equals-sign-before-attribute-name" + } + WhatwgParseErrorCode::DuplicateAttribute => "standard:duplicate-attribute", + WhatwgParseErrorCode::UnexpectedCharacterInUnquotedAttributeValue => { + "standard:unexpected-character-in-unquoted-attribute-value" + } + WhatwgParseErrorCode::MissingAttributeValue => "standard:missing-attribute-value", + WhatwgParseErrorCode::MissingWhitespaceBetweenAttributes => { + "standard:missing-whitespace-between-attributes" + } + WhatwgParseErrorCode::UnexpectedSolidusInTag => "standard:unexpected-solidus-in-tag", + WhatwgParseErrorCode::EofInComment => "standard:eof-in-comment", + WhatwgParseErrorCode::IncorrectlyOpenedComment => "standard:incorrectly-opened-comment", + WhatwgParseErrorCode::AbruptClosingOfEmptyComment => { + "standard:abrupt-closing-of-empty-comment" + } + WhatwgParseErrorCode::NestedComment => "standard:nested-comment", + WhatwgParseErrorCode::IncorrectlyClosedComment => "standard:incorrectly-closed-comment", + WhatwgParseErrorCode::EofInDoctype => "standard:eof-in-doctype", + WhatwgParseErrorCode::MissingWhitespaceBeforeDoctypeName => { + "standard:missing-whitespace-before-doctype-name" + } + WhatwgParseErrorCode::MissingDoctypeName => "standard:missing-doctype-name", + WhatwgParseErrorCode::InvalidCharacterSequenceAfterDoctypeName => { + "standard:invalid-character-sequence-after-doctype-name" + } + WhatwgParseErrorCode::EofInCdata => "standard:eof-in-cdata", + WhatwgParseErrorCode::EndTagWithAttributes => "standard:end-tag-with-attributes", + WhatwgParseErrorCode::EndTagWithTrailingSolidus => "standard:end-tag-with-trailing-solidus", + WhatwgParseErrorCode::InvalidFirstCharacterOfProcessingInstructionTarget => { + "standard:invalid-first-character-of-processing-instruction-target" + } + WhatwgParseErrorCode::InvalidProcessingInstructionTarget => { + "standard:invalid-processing-instruction-target" + } + WhatwgParseErrorCode::DisallowedProcessingInstructionTarget => { + "standard:disallowed-processing-instruction-target" + } + WhatwgParseErrorCode::EofInProcessingInstruction => { + "standard:eof-in-processing-instruction" + } + WhatwgParseErrorCode::MissingSemicolonAfterCharacterReference => { + "standard:missing-semicolon-after-character-reference" + } + WhatwgParseErrorCode::UnknownNamedCharacterReference => { + "standard:unknown-named-character-reference" + } + WhatwgParseErrorCode::AbsenceOfDigitsInNumericCharacterReference => { + "standard:absence-of-digits-in-numeric-character-reference" + } + WhatwgParseErrorCode::NullCharacterReference => "standard:null-character-reference", + WhatwgParseErrorCode::CharacterReferenceOutsideUnicodeRange => { + "standard:character-reference-outside-unicode-range" + } + WhatwgParseErrorCode::SurrogateCharacterReference => { + "standard:surrogate-character-reference" + } + WhatwgParseErrorCode::NoncharacterCharacterReference => { + "standard:noncharacter-character-reference" + } + WhatwgParseErrorCode::ControlCharacterReference => "standard:control-character-reference", + } +} + +fn tokenizer_extension_code(code: TokenizerExtensionParseErrorCode) -> &'static str { + match code { + TokenizerExtensionParseErrorCode::MalformedNumericCharacterReference => { + "tokenizer-extension:malformed-numeric-character-reference" + } + TokenizerExtensionParseErrorCode::DroppedGraveAccentBeforeAttributeName => { + "tokenizer-extension:dropped-grave-accent-before-attribute-name" + } + TokenizerExtensionParseErrorCode::GraveAccentInAttributeName => { + "tokenizer-extension:grave-accent-in-attribute-name" + } + TokenizerExtensionParseErrorCode::DroppedQuestionMarkBeforeAttributeName => { + "tokenizer-extension:dropped-question-mark-before-attribute-name" + } + TokenizerExtensionParseErrorCode::TerminatedUnquotedAttributeValueBeforeQuestionMark => { + "tokenizer-extension:terminated-unquoted-attribute-value-before-question-mark" + } + } +} + +fn tree_code(code: TreeConstructionParseErrorCode) -> &'static str { + match code { + TreeConstructionParseErrorCode::ExpectedDoctypeBeforeNonSpaceToken => { + "tree-construction:expected-doctype-before-non-space-token" + } + TreeConstructionParseErrorCode::DoctypeTokenNotAllowed => { + "tree-construction:doctype-token-not-allowed" + } + TreeConstructionParseErrorCode::StartTagForbiddenByActiveInsertionMode => { + "tree-construction:start-tag-forbidden-by-active-insertion-mode" + } + TreeConstructionParseErrorCode::EndTagForbiddenByActiveInsertionMode => { + "tree-construction:end-tag-forbidden-by-active-insertion-mode" + } + TreeConstructionParseErrorCode::HtmlStartTagAfterHtmlElement => { + "tree-construction:html-start-tag-after-html-element" + } + TreeConstructionParseErrorCode::BodyStartTagAfterBodyElement => { + "tree-construction:body-start-tag-after-body-element" + } + TreeConstructionParseErrorCode::TokenForbiddenAfterBody => { + "tree-construction:token-forbidden-after-body" + } + TreeConstructionParseErrorCode::TokenForbiddenAfterAfterBody => { + "tree-construction:token-forbidden-after-after-body" + } + TreeConstructionParseErrorCode::UnacknowledgedSelfClosingFlag => { + "tree-construction:unacknowledged-self-closing-flag" + } + TreeConstructionParseErrorCode::ElementEndTagNotInRequiredScope => { + "tree-construction:element-end-tag-not-in-required-scope" + } + TreeConstructionParseErrorCode::CurrentNodeMismatchAfterImpliedEndTags => { + "tree-construction:current-node-mismatch-after-implied-end-tags" + } + TreeConstructionParseErrorCode::ParagraphEndTagWithoutParagraphInButtonScope => { + "tree-construction:paragraph-end-tag-without-paragraph-in-button-scope" + } + TreeConstructionParseErrorCode::AnyOtherEndTagBlockedBySpecialElement => { + "tree-construction:any-other-end-tag-blocked-by-special-element" + } + TreeConstructionParseErrorCode::FormStartTagWithActiveFormPointer => { + "tree-construction:form-start-tag-with-active-form-pointer" + } + TreeConstructionParseErrorCode::FormEndTagWithoutFormElement => { + "tree-construction:form-end-tag-without-form-element" + } + TreeConstructionParseErrorCode::FormEndTagFormElementNotInScope => { + "tree-construction:form-end-tag-form-element-not-in-scope" + } + TreeConstructionParseErrorCode::SelectStartTagWithSelectInScope => { + "tree-construction:select-start-tag-with-select-in-scope" + } + TreeConstructionParseErrorCode::SelectFamilyElementRemainsAfterImpliedEndTags => { + "tree-construction:select-family-element-remains-after-implied-end-tags" + } + TreeConstructionParseErrorCode::ActiveAnchorStartTag => { + "tree-construction:active-anchor-start-tag" + } + TreeConstructionParseErrorCode::NobrStartTagWithNobrInScope => { + "tree-construction:nobr-start-tag-with-nobr-in-scope" + } + TreeConstructionParseErrorCode::AdoptionFormattingElementMissingFromOpenElements => { + "tree-construction:adoption-formatting-element-missing-from-open-elements" + } + TreeConstructionParseErrorCode::AdoptionFormattingElementNotInScope => { + "tree-construction:adoption-formatting-element-not-in-scope" + } + TreeConstructionParseErrorCode::AdoptionFormattingElementNotCurrentNode => { + "tree-construction:adoption-formatting-element-not-current-node" + } + TreeConstructionParseErrorCode::FormStartTagInTable => { + "tree-construction:form-start-tag-in-table" + } + TreeConstructionParseErrorCode::HiddenInputStartTagInTable => { + "tree-construction:hidden-input-start-tag-in-table" + } + TreeConstructionParseErrorCode::NonSpaceCharacterInTableText => { + "tree-construction:non-space-character-in-table-text" + } + TreeConstructionParseErrorCode::NonTableTokenInTable => { + "tree-construction:non-table-token-in-table" + } + TreeConstructionParseErrorCode::NestedTableStartTag => { + "tree-construction:nested-table-start-tag" + } + TreeConstructionParseErrorCode::CellStartTagWithoutOpenRow => { + "tree-construction:cell-start-tag-without-open-row" + } + TreeConstructionParseErrorCode::TableContextElementNotInRequiredScope => { + "tree-construction:table-context-element-not-in-required-scope" + } + TreeConstructionParseErrorCode::CurrentNodeNotColgroup => { + "tree-construction:current-node-not-colgroup" + } + TreeConstructionParseErrorCode::EofWithOpenTemplate => { + "tree-construction:eof-with-open-template" + } + TreeConstructionParseErrorCode::EofInTextMode => "tree-construction:eof-in-text-mode", + TreeConstructionParseErrorCode::HtmlTokenNotAllowedInForeignContent => { + "tree-construction:html-token-not-allowed-in-foreign-content" + } + TreeConstructionParseErrorCode::NullCharacterInForeignContent => { + "tree-construction:null-character-in-foreign-content" + } + TreeConstructionParseErrorCode::ForeignEndTagCurrentNodeMismatch => { + "tree-construction:foreign-end-tag-current-node-mismatch" + } + } +} + +fn recovery_name(recovery: Option<&ParserRecoveryAction>) -> String { + match recovery { + None => "null".to_string(), + Some(ParserRecoveryAction::IgnoreToken) => "ignore-token".to_string(), + Some(ParserRecoveryAction::ReprocessToken) => "reprocess-token".to_string(), + Some(ParserRecoveryAction::DropDuplicateAttribute) => { + "drop-duplicate-attribute".to_string() + } + Some(ParserRecoveryAction::DropInputCharacter { code_point }) => { + format!("drop-input-character:U+{:06X}", u32::from(*code_point)) + } + Some(ParserRecoveryAction::ReconsumeInputCharacter { code_point }) => { + format!("reconsume-input-character:U+{:06X}", u32::from(*code_point)) + } + Some(ParserRecoveryAction::EmitCurrentCommentAndSwitchToData) => { + "emit-current-comment-and-switch-to-data".to_string() + } + Some(ParserRecoveryAction::EmitCurrentCommentAtEof) => { + "emit-current-comment-at-eof".to_string() + } + Some(ParserRecoveryAction::StartBogusComment) => "start-bogus-comment".to_string(), + Some(ParserRecoveryAction::RetainNestedCommentDelimiterAndReconsumeInCommentEnd { + code_point, + }) => format!( + "retain-nested-comment-delimiter-and-reconsume-in-comment-end:U+{:06X}", + u32::from(*code_point) + ), + Some(ParserRecoveryAction::DropEndTagAttributes) => "drop-end-tag-attributes".to_string(), + Some(ParserRecoveryAction::IgnoreEndTagTrailingSolidus) => { + "ignore-end-tag-trailing-solidus".to_string() + } + Some(ParserRecoveryAction::PreserveCharacterReferenceLiteral) => { + "preserve-character-reference-literal".to_string() + } + Some(ParserRecoveryAction::InsertImpliedElement) => "insert-implied-element".to_string(), + Some(ParserRecoveryAction::GenerateImpliedEndTags) => { + "generate-implied-end-tags".to_string() + } + Some(ParserRecoveryAction::FosterParent) => "foster-parent".to_string(), + Some(ParserRecoveryAction::PopOpenElements) => "pop-open-elements".to_string(), + Some(ParserRecoveryAction::ReplaceInvalidInput) => "replace-invalid-input".to_string(), + Some(ParserRecoveryAction::IgnoreSelfClosingFlag) => "ignore-self-closing-flag".to_string(), + } +} + +fn position_name(position: &EventPosition) -> String { + match position { + EventPosition::Unavailable(PositionUnavailableReason::ParserDidNotProvidePosition) => { + "unavailable:parser-did-not-provide-position".to_string() + } + EventPosition::Known(position) => { + let source = match position.source_bytes { + SourceBytePosition::Exact(offset) => format!("exact:{offset}"), + SourceBytePosition::Unavailable( + SourcePositionUnavailableReason::NoInputProvenanceMap, + ) => "unavailable:no-input-provenance-map".to_string(), + }; + match position.normalized.space { + InputCoordinateSpace::NormalizedUtf8 => format!( + "normalized-utf8:{}:{}:{}:source-{source}", + position.normalized.utf8_byte_offset, + position.normalized.line.get(), + position.normalized.column.get() + ), + } + } + } +} + +fn context_fields( + context: Option<&ParserContextSummary>, +) -> (&'static str, &'static str, &'static str, &'static str) { + let Some(context) = context else { + return ("absent", "null", "null", "null"); + }; + ( + "present", + context.token_kind.map_or("null", token_kind_name), + context.insertion_mode.map_or("null", insertion_mode_name), + context + .adjusted_current_node_namespace + .map_or("null", namespace_name), + ) +} + +fn token_kind_name(value: ParserTokenKind) -> &'static str { + match value { + ParserTokenKind::Doctype => "doctype", + ParserTokenKind::StartTag => "start-tag", + ParserTokenKind::EndTag => "end-tag", + ParserTokenKind::Character => "character", + ParserTokenKind::Comment => "comment", + ParserTokenKind::ProcessingInstruction => "processing-instruction", + ParserTokenKind::Eof => "eof", + } +} +pub(crate) fn insertion_mode_name(value: ObservedInsertionMode) -> &'static str { + match value { + ObservedInsertionMode::Initial => "initial", + ObservedInsertionMode::BeforeHtml => "before-html", + ObservedInsertionMode::BeforeHead => "before-head", + ObservedInsertionMode::InHead => "in-head", + ObservedInsertionMode::AfterHead => "after-head", + ObservedInsertionMode::InBody => "in-body", + ObservedInsertionMode::AfterBody => "after-body", + ObservedInsertionMode::AfterAfterBody => "after-after-body", + ObservedInsertionMode::InTable => "in-table", + ObservedInsertionMode::InTableText => "in-table-text", + ObservedInsertionMode::InCaption => "in-caption", + ObservedInsertionMode::InColumnGroup => "in-column-group", + ObservedInsertionMode::InTableBody => "in-table-body", + ObservedInsertionMode::InRow => "in-row", + ObservedInsertionMode::InCell => "in-cell", + ObservedInsertionMode::InTemplate => "in-template", + ObservedInsertionMode::Text => "text", + } +} +fn namespace_name(value: ElementNamespace) -> &'static str { + match value { + ElementNamespace::Html => "html", + ElementNamespace::Svg => "svg", + ElementNamespace::MathMl => "mathml", + } +} + +fn valid_code(value: &str) -> bool { + if let Some(value) = value.strip_prefix("standard:") { + return matches!( + value, + "unexpected-null-character" + | "eof-before-tag-name" + | "invalid-first-character-of-tag-name" + | "missing-end-tag-name" + | "eof-in-tag" + | "unexpected-character-in-attribute-name" + | "unexpected-equals-sign-before-attribute-name" + | "duplicate-attribute" + | "unexpected-character-in-unquoted-attribute-value" + | "missing-attribute-value" + | "missing-whitespace-between-attributes" + | "unexpected-solidus-in-tag" + | "eof-in-comment" + | "incorrectly-opened-comment" + | "abrupt-closing-of-empty-comment" + | "nested-comment" + | "incorrectly-closed-comment" + | "eof-in-doctype" + | "missing-whitespace-before-doctype-name" + | "missing-doctype-name" + | "invalid-character-sequence-after-doctype-name" + | "eof-in-cdata" + | "end-tag-with-attributes" + | "end-tag-with-trailing-solidus" + | "invalid-first-character-of-processing-instruction-target" + | "invalid-processing-instruction-target" + | "disallowed-processing-instruction-target" + | "eof-in-processing-instruction" + | "missing-semicolon-after-character-reference" + | "unknown-named-character-reference" + | "absence-of-digits-in-numeric-character-reference" + | "null-character-reference" + | "character-reference-outside-unicode-range" + | "surrogate-character-reference" + | "noncharacter-character-reference" + | "control-character-reference" + ); + } + if let Some(value) = value.strip_prefix("tokenizer-extension:") { + return matches!( + value, + "malformed-numeric-character-reference" + | "dropped-grave-accent-before-attribute-name" + | "grave-accent-in-attribute-name" + | "dropped-question-mark-before-attribute-name" + | "terminated-unquoted-attribute-value-before-question-mark" + ); + } + if let Some(value) = value.strip_prefix("tree-construction:") { + return matches!( + value, + "expected-doctype-before-non-space-token" + | "doctype-token-not-allowed" + | "start-tag-forbidden-by-active-insertion-mode" + | "end-tag-forbidden-by-active-insertion-mode" + | "html-start-tag-after-html-element" + | "body-start-tag-after-body-element" + | "token-forbidden-after-body" + | "token-forbidden-after-after-body" + | "unacknowledged-self-closing-flag" + | "element-end-tag-not-in-required-scope" + | "current-node-mismatch-after-implied-end-tags" + | "paragraph-end-tag-without-paragraph-in-button-scope" + | "any-other-end-tag-blocked-by-special-element" + | "form-start-tag-with-active-form-pointer" + | "form-end-tag-without-form-element" + | "form-end-tag-form-element-not-in-scope" + | "select-start-tag-with-select-in-scope" + | "select-family-element-remains-after-implied-end-tags" + | "active-anchor-start-tag" + | "nobr-start-tag-with-nobr-in-scope" + | "adoption-formatting-element-missing-from-open-elements" + | "adoption-formatting-element-not-in-scope" + | "adoption-formatting-element-not-current-node" + | "form-start-tag-in-table" + | "hidden-input-start-tag-in-table" + | "non-space-character-in-table-text" + | "non-table-token-in-table" + | "nested-table-start-tag" + | "cell-start-tag-without-open-row" + | "table-context-element-not-in-required-scope" + | "current-node-not-colgroup" + | "eof-with-open-template" + | "eof-in-text-mode" + | "html-token-not-allowed-in-foreign-content" + | "null-character-in-foreign-content" + | "foreign-end-tag-current-node-mismatch" + ); + } + false +} +fn valid_recovery(value: &str) -> bool { + if matches!( + value, + "null" + | "ignore-token" + | "reprocess-token" + | "drop-duplicate-attribute" + | "emit-current-comment-and-switch-to-data" + | "emit-current-comment-at-eof" + | "start-bogus-comment" + | "drop-end-tag-attributes" + | "ignore-end-tag-trailing-solidus" + | "preserve-character-reference-literal" + | "insert-implied-element" + | "generate-implied-end-tags" + | "foster-parent" + | "pop-open-elements" + | "replace-invalid-input" + | "ignore-self-closing-flag" + ) { + return true; + } + [ + "drop-input-character:U+", + "reconsume-input-character:U+", + "retain-nested-comment-delimiter-and-reconsume-in-comment-end:U+", + ] + .into_iter() + .find_map(|prefix| value.strip_prefix(prefix)) + .is_some_and(valid_code_point) +} +fn valid_code_point(value: &str) -> bool { + value.len() == 6 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'A'..=b'F').contains(&b)) + && u32::from_str_radix(value, 16) + .ok() + .and_then(char::from_u32) + .is_some() +} +fn valid_position(value: &str) -> bool { + if value == "unavailable:parser-did-not-provide-position" { + return true; + } + let Some(rest) = value.strip_prefix("normalized-utf8:") else { + return false; + }; + let Some((offset, rest)) = rest.split_once(':') else { + return false; + }; + let Some((line, rest)) = rest.split_once(':') else { + return false; + }; + let Some((column, source)) = rest.split_once(":source-") else { + return false; + }; + validate_u64(offset) + && valid_positive(line) + && valid_positive(column) + && (source == "unavailable:no-input-provenance-map" + || source.strip_prefix("exact:").is_some_and(validate_u64)) +} +fn valid_positive(value: &str) -> bool { + validate_u64(value) && value != "0" +} +fn valid_context(context: &str, token: &str, mode: &str, namespace: &str) -> bool { + match context { + "absent" => token == "null" && mode == "null" && namespace == "null", + "present" => { + valid_token_kind(token) + && valid_mode(mode) + && matches!(namespace, "null" | "html" | "svg" | "mathml") + } + _ => false, + } +} +fn valid_token_kind(value: &str) -> bool { + matches!( + value, + "null" + | "doctype" + | "start-tag" + | "end-tag" + | "character" + | "comment" + | "processing-instruction" + | "eof" + ) +} +fn valid_mode(value: &str) -> bool { + matches!( + value, + "null" + | "initial" + | "before-html" + | "before-head" + | "in-head" + | "after-head" + | "in-body" + | "after-body" + | "after-after-body" + | "in-table" + | "in-table-text" + | "in-caption" + | "in-column-group" + | "in-table-body" + | "in-row" + | "in-cell" + | "in-template" + | "text" + ) +} + +fn malformed(line: usize, reason: &'static str) -> Result { + Err(SnapshotReadError::MalformedRecord { line, reason }) +} diff --git a/crates/html_test_support/src/parser_snapshot/patches.rs b/crates/html_test_support/src/parser_snapshot/patches.rs new file mode 100644 index 00000000..73c8816d --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/patches.rs @@ -0,0 +1,382 @@ +use super::SnapshotData; +use super::lexical::{ + SnapshotReadError, SnapshotRecord, escape_quoted, fixed_fields, optional_quoted, + strict_record_lines, validate_nullable_quoted, validate_quoted, validate_u64, +}; +use html::conformance::{ + ObservationState, ObservedDomAttribute, ObservedPatchOperation, ObservedPatchStream, + PatchNodeLabel, +}; +use std::collections::BTreeSet; +use std::fmt::Write; + +const HEADER: &str = "# format: html5-dompatch-v3"; + +define_snapshot_types!(ParsedPatchesSnapshot, CanonicalPatchesSnapshot); + +pub(super) fn write( + state: &ObservationState, +) -> Result { + let ObservationState::Captured(stream) = state else { + return Err(()); + }; + let mut bytes = format!("{HEADER}\n"); + let mut records = Vec::new(); + for (index, operation) in stream.operations.iter().enumerate() { + if !operation_labels_are_canonical(operation) { + return Err(()); + } + let ordinal = index.checked_add(1).ok_or(())?; + let line = match operation { + ObservedPatchOperation::Clear => format!("PATCH operation={ordinal} kind=clear"), + ObservedPatchOperation::CreateDocument { + node, + legacy_doctype, + } => format!( + "PATCH operation={ordinal} kind=create-document node={} legacy-doctype={}", + label(node), + optional_quoted(legacy_doctype.as_deref()) + ), + ObservedPatchOperation::CreateDocumentType { + node, + name, + public_id, + system_id, + } => format!( + "PATCH operation={ordinal} kind=create-document-type node={} name={} public-id={} system-id={}", + label(node), + optional_quoted(name.as_deref()), + optional_quoted(public_id.as_deref()), + optional_quoted(system_id.as_deref()) + ), + ObservedPatchOperation::CreateElement { + node, + namespace, + local_name, + .. + } => format!( + "PATCH operation={ordinal} kind=create-element node={} namespace={} local-name={}", + label(node), + namespace.snapshot_name(), + escape_quoted(local_name) + ), + ObservedPatchOperation::CreateTemplateContents { host, contents } => format!( + "PATCH operation={ordinal} kind=create-template-contents host={} contents={}", + label(host), + label(contents) + ), + ObservedPatchOperation::CreateText { node, text } => format!( + "PATCH operation={ordinal} kind=create-text node={} text={}", + label(node), + escape_quoted(text) + ), + ObservedPatchOperation::CreateComment { node, data } => format!( + "PATCH operation={ordinal} kind=create-comment node={} data={}", + label(node), + escape_quoted(data) + ), + ObservedPatchOperation::CreateProcessingInstruction { node, target, data } => format!( + "PATCH operation={ordinal} kind=create-processing-instruction node={} target={} data={}", + label(node), + escape_quoted(target), + escape_quoted(data) + ), + ObservedPatchOperation::AppendChild { parent, child } => format!( + "PATCH operation={ordinal} kind=append-child parent={} child={}", + label(parent), + label(child) + ), + ObservedPatchOperation::InsertBefore { + parent, + child, + before, + } => format!( + "PATCH operation={ordinal} kind=insert-before parent={} child={} before={}", + label(parent), + label(child), + label(before) + ), + ObservedPatchOperation::RemoveNode { node } => format!( + "PATCH operation={ordinal} kind=remove-node node={}", + label(node) + ), + ObservedPatchOperation::SetAttributes { node, .. } => format!( + "PATCH operation={ordinal} kind=set-attributes node={}", + label(node) + ), + ObservedPatchOperation::SetText { node, text } => format!( + "PATCH operation={ordinal} kind=set-text node={} text={}", + label(node), + escape_quoted(text) + ), + ObservedPatchOperation::AppendText { node, text } => format!( + "PATCH operation={ordinal} kind=append-text node={} text={}", + label(node), + escape_quoted(text) + ), + }; + let _ = writeln!(&mut bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("operation {ordinal}"), + line, + }); + match operation { + ObservedPatchOperation::CreateElement { attributes, .. } + | ObservedPatchOperation::SetAttributes { attributes, .. } => { + write_attributes(ordinal, attributes, &mut bytes, &mut records) + } + ObservedPatchOperation::Clear + | ObservedPatchOperation::CreateDocument { .. } + | ObservedPatchOperation::CreateDocumentType { .. } + | ObservedPatchOperation::CreateTemplateContents { .. } + | ObservedPatchOperation::CreateText { .. } + | ObservedPatchOperation::CreateComment { .. } + | ObservedPatchOperation::CreateProcessingInstruction { .. } + | ObservedPatchOperation::AppendChild { .. } + | ObservedPatchOperation::InsertBefore { .. } + | ObservedPatchOperation::RemoveNode { .. } + | ObservedPatchOperation::SetText { .. } + | ObservedPatchOperation::AppendText { .. } => {} + } + } + Ok(CanonicalPatchesSnapshot::new(SnapshotData::new( + bytes, records, + ))) +} + +fn label(value: &PatchNodeLabel) -> String { + escape_quoted(&value.0) +} + +fn operation_labels_are_canonical(operation: &ObservedPatchOperation) -> bool { + let valid = |label: &PatchNodeLabel| valid_label_text(&label.0); + match operation { + ObservedPatchOperation::Clear => true, + ObservedPatchOperation::CreateDocument { node, .. } + | ObservedPatchOperation::CreateDocumentType { node, .. } + | ObservedPatchOperation::CreateElement { node, .. } + | ObservedPatchOperation::CreateText { node, .. } + | ObservedPatchOperation::CreateComment { node, .. } + | ObservedPatchOperation::CreateProcessingInstruction { node, .. } + | ObservedPatchOperation::RemoveNode { node } + | ObservedPatchOperation::SetAttributes { node, .. } + | ObservedPatchOperation::SetText { node, .. } + | ObservedPatchOperation::AppendText { node, .. } => valid(node), + ObservedPatchOperation::CreateTemplateContents { host, contents } + | ObservedPatchOperation::AppendChild { + parent: host, + child: contents, + } => valid(host) && valid(contents), + ObservedPatchOperation::InsertBefore { + parent, + child, + before, + } => valid(parent) && valid(child) && valid(before), + } +} +fn write_attributes( + operation: usize, + attributes: &[ObservedDomAttribute], + bytes: &mut String, + records: &mut Vec, +) { + for (index, attribute) in attributes.iter().enumerate() { + let line = format!( + "PATCH_ATTRIBUTE operation={operation} index={index} namespace={} prefix={} local-name={} value={}", + attribute.namespace.snapshot_name(), + optional_quoted(attribute.prefix.as_deref()), + escape_quoted(&attribute.local_name), + escape_quoted(&attribute.value) + ); + let _ = writeln!(bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("operation {operation} attribute {index}"), + line, + }); + } +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let lines = strict_record_lines(bytes, HEADER, true)?; + let mut expected_operation = 1u64; + let mut expected_attribute = None::<(u64, u64)>; + let mut locations = BTreeSet::new(); + let mut records = Vec::new(); + for (line_number, line) in lines { + if line.starts_with("PATCH_ATTRIBUTE ") { + let Some((operation, index)) = expected_attribute else { + return malformed( + line_number, + "patch attribute is not grouped under create-element or set-attributes", + ); + }; + let Some(fields) = fixed_fields( + line, + "PATCH_ATTRIBUTE", + &[ + "operation", + "index", + "namespace", + "prefix", + "local-name", + "value", + ], + ) else { + return malformed(line_number, "invalid patch attribute shape"); + }; + if !validate_u64(fields[0]) + || !validate_u64(fields[1]) + || fields[0].parse::().ok() != Some(operation) + || fields[1].parse::().ok() != Some(index) + || !matches!(fields[2], "none" | "xml" | "xmlns" | "xlink") + || !validate_nullable_quoted(fields[3]) + || !validate_quoted(fields[4]) + || !validate_quoted(fields[5]) + { + return Err(SnapshotReadError::NonContiguousOrdinal { line: line_number }); + } + let location = format!("operation {operation} attribute {index}"); + if !locations.insert(location.clone()) { + return Err(SnapshotReadError::DuplicateLocation { line: line_number }); + } + records.push(SnapshotRecord { + location, + line: line.to_string(), + }); + expected_attribute = Some(( + operation, + index + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?, + )); + continue; + } + expected_attribute = None; + let mut prefix = line.splitn(4, ' '); + let (Some("PATCH"), Some(operation_field), Some(kind_field)) = + (prefix.next(), prefix.next(), prefix.next()) + else { + return malformed(line_number, "invalid patch prefix"); + }; + let Some(operation) = operation_field.strip_prefix("operation=") else { + return malformed(line_number, "missing patch operation ordinal"); + }; + let Some(kind) = kind_field.strip_prefix("kind=") else { + return malformed(line_number, "missing patch kind"); + }; + if !validate_u64(operation) || operation.parse::().ok() != Some(expected_operation) { + return Err(SnapshotReadError::NonContiguousOrdinal { line: line_number }); + } + let valid = match kind { + "clear" => fixed_fields(line, "PATCH", &["operation", "kind"]).is_some(), + "create-document" => fixed_fields( + line, + "PATCH", + &["operation", "kind", "node", "legacy-doctype"], + ) + .is_some_and(|f| validate_label(f[2]) && validate_nullable_quoted(f[3])), + "create-document-type" => fixed_fields( + line, + "PATCH", + &[ + "operation", + "kind", + "node", + "name", + "public-id", + "system-id", + ], + ) + .is_some_and(|f| { + validate_label(f[2]) + && validate_nullable_quoted(f[3]) + && validate_nullable_quoted(f[4]) + && validate_nullable_quoted(f[5]) + }), + "create-element" => fixed_fields( + line, + "PATCH", + &["operation", "kind", "node", "namespace", "local-name"], + ) + .is_some_and(|f| { + validate_label(f[2]) + && matches!(f[3], "html" | "svg" | "mathml") + && validate_quoted(f[4]) + }), + "create-template-contents" => { + fixed_fields(line, "PATCH", &["operation", "kind", "host", "contents"]) + .is_some_and(|f| validate_label(f[2]) && validate_label(f[3])) + } + "create-text" | "set-text" | "append-text" => { + fixed_fields(line, "PATCH", &["operation", "kind", "node", "text"]) + .is_some_and(|f| validate_label(f[2]) && validate_quoted(f[3])) + } + "create-comment" => fixed_fields(line, "PATCH", &["operation", "kind", "node", "data"]) + .is_some_and(|f| validate_label(f[2]) && validate_quoted(f[3])), + "create-processing-instruction" => fixed_fields( + line, + "PATCH", + &["operation", "kind", "node", "target", "data"], + ) + .is_some_and(|f| { + validate_label(f[2]) && validate_quoted(f[3]) && validate_quoted(f[4]) + }), + "append-child" => { + fixed_fields(line, "PATCH", &["operation", "kind", "parent", "child"]) + .is_some_and(|f| validate_label(f[2]) && validate_label(f[3])) + } + "insert-before" => fixed_fields( + line, + "PATCH", + &["operation", "kind", "parent", "child", "before"], + ) + .is_some_and(|f| validate_label(f[2]) && validate_label(f[3]) && validate_label(f[4])), + "remove-node" | "set-attributes" => { + fixed_fields(line, "PATCH", &["operation", "kind", "node"]) + .is_some_and(|f| validate_label(f[2])) + } + _ => false, + }; + if !valid { + return malformed(line_number, "unknown patch kind or malformed fixed fields"); + } + let location = format!("operation {expected_operation}"); + if !locations.insert(location.clone()) { + return Err(SnapshotReadError::DuplicateLocation { line: line_number }); + } + records.push(SnapshotRecord { + location, + line: line.to_string(), + }); + if matches!(kind, "create-element" | "set-attributes") { + expected_attribute = Some((expected_operation, 0)); + } + expected_operation = expected_operation + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?; + } + Ok(ParsedPatchesSnapshot::new(SnapshotData::new( + std::str::from_utf8(bytes) + .map_err(|_| SnapshotReadError::InvalidUtf8)? + .to_string(), + records, + ))) +} + +fn validate_label(value: &str) -> bool { + value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .is_some_and(valid_label_text) +} + +fn valid_label_text(value: &str) -> bool { + let Some(decimal) = value.strip_prefix("node-") else { + return false; + }; + validate_u64(decimal) && decimal != "0" +} + +fn malformed(line: usize, reason: &'static str) -> Result { + Err(SnapshotReadError::MalformedRecord { line, reason }) +} diff --git a/crates/html_test_support/src/parser_snapshot/tests.rs b/crates/html_test_support/src/parser_snapshot/tests.rs new file mode 100644 index 00000000..643105c2 --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/tests.rs @@ -0,0 +1,464 @@ +use super::*; +use html::conformance::{ObservationState, ObservedToken, ObservedTokenAttribute}; + +#[test] +fn token_v2_distinguishes_absent_doctype_name_from_literal_null() { + let state = ObservationState::Captured(vec![ + ObservedToken::Doctype { + name: None, + public_id: None, + system_id: None, + force_quirks: true, + }, + ObservedToken::Doctype { + name: Some("null".to_string()), + public_id: None, + system_id: None, + force_quirks: false, + }, + ObservedToken::Eof, + ]); + let written = token_v2::write(&state).expect("captured tokens serialize"); + assert!(written.data().bytes().contains("name=null public-id=null")); + assert!( + written + .data() + .bytes() + .contains("name=\"null\" public-id=null") + ); + let parsed = token_v2::read(written.data().bytes().as_bytes()).expect("writer output parses"); + assert_eq!(parsed.data().bytes(), written.data().bytes()); +} + +#[test] +fn strict_v2_framing_rejects_legacy_compatibility_forms() { + for malformed in [ + "# format: html5-token-v2\r\nTOKEN ordinal=1 kind=eof\r\n", + "# format: html5-token-v2\n\nTOKEN ordinal=1 kind=eof\n", + "# format: html5-token-v2\n# comment\nTOKEN ordinal=1 kind=eof\n", + "# format: html5-token-v2\nTOKEN ordinal=1 kind=eof", + "\u{feff}# format: html5-token-v2\nTOKEN ordinal=1 kind=eof\n", + ] { + assert!( + token_v2::read(malformed.as_bytes()).is_err(), + "accepted {malformed:?}" + ); + } +} + +#[test] +fn empty_collection_and_required_singleton_rules_are_surface_specific() { + assert!(parse_errors::read(b"# format: html5-parse-errors-v1\n").is_ok()); + assert!(tree::read(b"# format: html5-dom-v3\n").is_ok()); + assert!(patches::read(b"# format: html5-dompatch-v3\n").is_ok()); + assert!(document_mode::read(b"# format: html5-document-mode-v1\n").is_err()); + assert!(token_v2::read(b"# format: html5-token-v2\n").is_err()); +} + +#[test] +fn readers_validate_framing_without_reimplementing_parser_semantics() { + let implausible_tree = b"# format: html5-dom-v3\nNODE path=/root[0] kind=element namespace=svg local-name=\"html\"\n"; + assert!(tree::read(implausible_tree).is_ok()); + let implausible_patch = + b"# format: html5-dompatch-v3\nPATCH operation=1 kind=remove-node node=\"node-99\"\n"; + assert!(patches::read(implausible_patch).is_ok()); +} + +#[test] +fn token_v2_reader_rejects_unknown_spellings_duplicate_fields_bad_escapes_and_bad_locations() { + for malformed in [ + "# format: html5-token-v3\nTOKEN ordinal=1 kind=eof\n", + "# format: html5-token-v2\nTOKEN ordinal=1 kind=unknown\n", + "# format: html5-token-v2\nTOKEN ordinal=1 kind=character data=\"\\x\"\nTOKEN ordinal=2 kind=eof\n", + "# format: html5-token-v2\nTOKEN ordinal=1 kind=character data=\"x\" data=\"y\"\nTOKEN ordinal=2 kind=eof\n", + "# format: html5-token-v2\nTOKEN ordinal=1 kind=start-tag name=\"x\" self-closing=false\nTOKEN_ATTRIBUTE token=1 index=1 name=\"a\" value=\"b\"\nTOKEN ordinal=2 kind=eof\n", + "# format: html5-token-v2\nTOKEN ordinal=1 kind=eof\nTOKEN ordinal=2 kind=comment data=\"after\"\n", + ] { + assert!( + token_v2::read(malformed.as_bytes()).is_err(), + "accepted {malformed:?}" + ); + } +} + +#[test] +fn canonical_token_writer_preserves_vector_order_and_is_byte_identical() { + let state = ObservationState::Captured(vec![ + ObservedToken::StartTag { + name: "x".to_string(), + attributes: vec![ + ObservedTokenAttribute { + name: "z".to_string(), + value: "1".to_string(), + }, + ObservedTokenAttribute { + name: "a".to_string(), + value: "2".to_string(), + }, + ], + self_closing: false, + }, + ObservedToken::Eof, + ]); + let first = token_v2::write(&state).unwrap(); + let second = token_v2::write(&state).unwrap(); + assert_eq!(first, second); + let z = first.data().bytes().find("index=0 name=\"z\"").unwrap(); + let a = first.data().bytes().find("index=1 name=\"a\"").unwrap(); + assert!(z < a); +} + +#[test] +fn every_requested_canonical_writer_is_repeatable_and_strictly_readable() { + use html::conformance::{ + ObservationRequest, ParserObservationInput, ParserObservationRequest, + ParserObservationTarget, ScalarObservationRequest, execute_parser_observation, + }; + + let capture = ObservationRequest::Capture { capacity: 1_024 }; + let result = execute_parser_observation(ParserObservationRequest { + target: ParserObservationTarget::DocumentParser, + input: ParserObservationInput::Utf8(""), + tokens: capture, + parse_errors: capture, + implementation_diagnostics: capture, + transitions: capture, + unsupported_features: capture, + document_mode: ScalarObservationRequest::Capture, + tree: capture, + patches: capture, + }) + .expect("production canonical observation"); + + for surface in [ + ExpectationSurface::Tokens, + ExpectationSurface::ParseErrors, + ExpectationSurface::ImplementationDiagnostics, + ExpectationSurface::DocumentMode, + ExpectationSurface::Tree, + ExpectationSurface::Patches, + ExpectationSurface::Transitions, + ExpectationSurface::UnsupportedFeatures, + ] { + let first = serialize_snapshot(surface, &result).expect("requested surface serializes"); + let second = serialize_snapshot(surface, &result).expect("repeat serialization"); + assert_eq!(first, second, "{} serialization changed", surface.name()); + let parsed = read_snapshot(surface, first.snapshot().bytes().as_bytes()) + .expect("canonical writer output passes its strict reader"); + assert_eq!(parsed.surface(), surface); + assert_eq!(parsed.format(), first.format()); + } + + assert!( + !serialize_snapshot(ExpectationSurface::Transitions, &result) + .unwrap() + .snapshot() + .is_empty() + ); + assert!( + !serialize_snapshot(ExpectationSurface::UnsupportedFeatures, &result) + .unwrap() + .snapshot() + .is_empty() + ); +} + +#[test] +fn canonical_tree_and_patch_writers_preserve_typed_vector_order_without_relabeling() { + use html::conformance::{ + CanonicalParserResult, ObservedDomAttribute, ObservedPatchOperation, ObservedPatchStream, + ObservedTree, ObservedTreeNode, PatchNodeLabel, + }; + use html::{AttributeNamespace, ElementNamespace}; + + let mut result = CanonicalParserResult { + tokens: ObservationState::NotRequested, + parse_errors: ObservationState::NotRequested, + implementation_diagnostics: ObservationState::NotRequested, + document_mode: ObservationState::NotRequested, + tree: ObservationState::Captured(ObservedTree { + roots: vec![ObservedTreeNode::Document { + children: vec![ObservedTreeNode::Element { + namespace: ElementNamespace::Html, + local_name: "x".to_string(), + attributes: vec![ + ObservedDomAttribute { + namespace: AttributeNamespace::None, + prefix: None, + local_name: "z".to_string(), + value: "1".to_string(), + }, + ObservedDomAttribute { + namespace: AttributeNamespace::None, + prefix: None, + local_name: "a".to_string(), + value: "2".to_string(), + }, + ], + children: Vec::new(), + }], + }], + }), + patches: ObservationState::NotRequested, + transitions: ObservationState::NotRequested, + unsupported_features: ObservationState::NotRequested, + final_invariants: ObservationState::NotRequested, + }; + let tree = serialize_snapshot(ExpectationSurface::Tree, &result).unwrap(); + let tree_bytes = tree.snapshot().bytes(); + assert!( + tree_bytes.find("index=0 namespace=none").unwrap() + < tree_bytes.find("index=1 namespace=none").unwrap() + ); + assert!( + tree_bytes.find("local-name=\"z\"").unwrap() < tree_bytes.find("local-name=\"a\"").unwrap() + ); + + result.tree = ObservationState::NotRequested; + result.patches = ObservationState::Captured(ObservedPatchStream { + operations: vec![ + ObservedPatchOperation::RemoveNode { + node: PatchNodeLabel("node-2".to_string()), + }, + ObservedPatchOperation::RemoveNode { + node: PatchNodeLabel("node-1".to_string()), + }, + ], + }); + let patches = serialize_snapshot(ExpectationSurface::Patches, &result).unwrap(); + let patch_bytes = patches.snapshot().bytes(); + assert!(patch_bytes.find("operation=1").unwrap() < patch_bytes.find("operation=2").unwrap()); + assert!(patch_bytes.find("node-2").unwrap() < patch_bytes.find("node-1").unwrap()); +} + +#[test] +fn token_writer_rejects_missing_or_nonfinal_eof() { + assert!( + token_v2::write(&ObservationState::Captured(vec![ + ObservedToken::Character { + data: "x".to_string(), + }, + ])) + .is_err() + ); + assert!( + token_v2::write(&ObservationState::Captured(vec![ + ObservedToken::Eof, + ObservedToken::Comment { + data: "after".to_string(), + }, + ])) + .is_err() + ); +} + +#[test] +fn surface_readers_reject_unknown_closed_spellings() { + assert!(parse_errors::read(b"# format: html5-parse-errors-v1\nPARSE_ERROR occurrence=1 stage=tokenizer code=standard:not-real recovery=null position=unavailable:parser-did-not-provide-position context=absent context-token=null context-mode=null context-namespace=null\n").is_err()); + assert!(implementation_diagnostics::read(b"# format: html5-implementation-diagnostics-v1\nIMPLEMENTATION_DIAGNOSTIC occurrence=1 stage=tokenizer code=parser-guardrail:not-real payload=consecutive-stall-steps:1 position=unavailable:parser-did-not-provide-position context=absent context-token=null context-mode=null context-namespace=null\n").is_err()); + assert!(unsupported_features::read(b"# format: html5-unsupported-features-v1\nUNSUPPORTED_FEATURE occurrence=1 subsystem=tree-construction feature=not-real context-token=null context-mode=null context-namespace=null\n").is_err()); +} + +#[test] +fn canonical_tree_writer_is_iterative_for_deep_trees() { + use html::ElementNamespace; + use html::conformance::{ObservedTree, ObservedTreeNode}; + + const DEPTH: usize = 2_048; + let mut node = ObservedTreeNode::Text { + data: "leaf".to_string(), + }; + for _ in 0..DEPTH { + node = ObservedTreeNode::Element { + namespace: ElementNamespace::Html, + local_name: "x".to_string(), + attributes: Vec::new(), + children: vec![node], + }; + } + let tree = ObservedTree { + roots: vec![ObservedTreeNode::Document { + children: vec![node], + }], + }; + std::thread::Builder::new() + .name("iterative-canonical-tree-writer".to_string()) + .stack_size(64 * 1024) + .spawn(move || { + let state = ObservationState::Captured(tree); + let written = tree::write(&state).expect("deep canonical tree serializes iteratively"); + assert_eq!(written.data().record_count(), DEPTH + 2); + tree::read(written.data().bytes().as_bytes()) + .expect("deep writer output remains strict"); + let ObservationState::Captured(tree) = state else { + unreachable!() + }; + drop_tree_iteratively(tree); + }) + .expect("deep-tree test thread starts") + .join() + .expect("deep-tree test thread remains stack-safe"); +} + +#[test] +fn nested_template_contents_writer_output_round_trips_through_tree_framing() { + use html::conformance::{ObservedTemplateContents, ObservedTree, ObservedTreeNode}; + + let state = ObservationState::Captured(ObservedTree { + roots: vec![ObservedTreeNode::HtmlTemplateElement { + attributes: Vec::new(), + ordinary_children: Vec::new(), + contents: ObservedTemplateContents { + children: vec![ObservedTreeNode::HtmlTemplateElement { + attributes: Vec::new(), + ordinary_children: Vec::new(), + contents: ObservedTemplateContents { + children: vec![ObservedTreeNode::Text { + data: "nested".to_string(), + }], + }, + }], + }, + }], + }); + + let written = tree::write(&state).expect("nested templates serialize"); + let expected = concat!( + "# format: html5-dom-v3\n", + "NODE path=/root[0] kind=html-template-host\n", + "TEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\n", + "NODE path=/root[0]/contents/child[0] kind=html-template-host\n", + "TEMPLATE_CONTENTS path=/root[0]/contents/child[0]/contents host=/root[0]/contents/child[0]\n", + "NODE path=/root[0]/contents/child[0]/contents/child[0] kind=text data=\"nested\"\n", + ); + assert_eq!(written.data().bytes(), expected); + assert_eq!(written.data().bytes().matches("/contents").count(), 7); + tree::read(written.data().bytes().as_bytes()) + .expect("strict reader accepts nested template contents paths"); +} + +fn drop_tree_iteratively(mut tree: html::conformance::ObservedTree) { + use html::conformance::ObservedTreeNode; + + let mut work = std::mem::take(&mut tree.roots); + while let Some(node) = work.pop() { + match node { + ObservedTreeNode::Document { mut children } + | ObservedTreeNode::Element { mut children, .. } => work.append(&mut children), + ObservedTreeNode::HtmlTemplateElement { + mut ordinary_children, + mut contents, + .. + } => { + work.append(&mut ordinary_children); + work.append(&mut contents.children); + } + ObservedTreeNode::DocumentType { .. } + | ObservedTreeNode::Comment { .. } + | ObservedTreeNode::Text { .. } + | ObservedTreeNode::ProcessingInstruction { .. } => {} + } + } +} + +#[test] +fn tree_reader_enforces_canonical_preorder_and_template_framing() { + let malformed = [ + "# format: html5-dom-v3\nNODE path=/root[0]/child[0] kind=text data=\"x\"\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=element namespace=html local-name=\"x\"\nNODE path=/root[0]/child[0] kind=text data=\"x\"\nATTRIBUTE path=/root[0] index=0 namespace=none prefix=null local-name=\"a\" value=\"b\"\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nNODE path=/root[0]/contents/child[0] kind=text data=\"x\"\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nNODE path=/root[0]/child[0] kind=text data=\"late\"\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=document\nNODE path=/root[0]/child[0] kind=element namespace=html local-name=\"x\"\nNODE path=/root[0]/child[0]/child[0] kind=text data=\"x\"\nNODE path=/root[0]/child[1] kind=text data=\"y\"\nNODE path=/root[0]/child[0]/child[1] kind=text data=\"late\"\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=document\nNODE path=/root[0]/child[1] kind=text data=\"skipped\"\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\n", + ]; + for snapshot in malformed { + assert!( + tree::read(snapshot.as_bytes()).is_err(), + "accepted:\n{snapshot}" + ); + } + + let implausible_but_framed = "# format: html5-dom-v3\nNODE path=/root[0] kind=document\nNODE path=/root[0]/child[0] kind=element namespace=svg local-name=\"html\"\nNODE path=/root[0]/child[0]/child[0] kind=element namespace=mathml local-name=\"body\"\n"; + assert!(tree::read(implausible_but_framed.as_bytes()).is_ok()); +} + +#[test] +fn tree_reader_rejects_malformed_nested_template_boundaries() { + let malformed = [ + // Inner boundary before the inner template host. + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nTEMPLATE_CONTENTS path=/root[0]/contents/child[0]/contents host=/root[0]/contents/child[0]\n", + // Inner contents child before the inner boundary. + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nNODE path=/root[0]/contents/child[0] kind=html-template-host\nNODE path=/root[0]/contents/child[0]/contents/child[0] kind=text data=\"early\"\n", + // Duplicate boundary for the same inner host. + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nNODE path=/root[0]/contents/child[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents/child[0]/contents host=/root[0]/contents/child[0]\nTEMPLATE_CONTENTS path=/root[0]/contents/child[0]/contents host=/root[0]/contents/child[0]\n", + // Ordinary child after the inner host entered its contents phase. + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nNODE path=/root[0]/contents/child[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents/child[0]/contents host=/root[0]/contents/child[0]\nNODE path=/root[0]/contents/child[0]/child[0] kind=text data=\"late\"\n", + // Complete but consecutive contents segments have no intervening host. + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nTEMPLATE_CONTENTS path=/root[0]/contents/contents host=/root[0]/contents\n", + // Noncanonical and malformed child indices remain lexically invalid. + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nNODE path=/root[0]/contents/child[01] kind=html-template-host\n", + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nNODE path=/root[0]/contents/child[-1] kind=html-template-host\n", + // The inner template host must receive its own boundary at EOF. + "# format: html5-dom-v3\nNODE path=/root[0] kind=html-template-host\nTEMPLATE_CONTENTS path=/root[0]/contents host=/root[0]\nNODE path=/root[0]/contents/child[0] kind=html-template-host\n", + ]; + for snapshot in malformed { + assert!( + tree::read(snapshot.as_bytes()).is_err(), + "accepted malformed nested template snapshot:\n{snapshot}" + ); + } +} + +#[test] +fn patch_codec_requires_canonical_labels_and_decimals() { + let malformed = [ + "# format: html5-dompatch-v3\nPATCH operation=1 kind=remove-node node=\"node-0\"\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=remove-node node=\"node-01\"\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=remove-node node=\"other-1\"\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=remove-node node=\"\"\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=remove-node node=\"node-x\"\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=remove-node node=node-1\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=create-comment node=\"arbitrary\" data=\"x\"\n", + "# format: html5-dompatch-v3\nPATCH operation=01 kind=remove-node node=\"node-1\"\n", + "# format: html5-dompatch-v3\nPATCH operation=+1 kind=remove-node node=\"node-1\"\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=create-element node=\"node-1\" namespace=html local-name=\"x\"\nPATCH_ATTRIBUTE operation=1 index=00 namespace=none prefix=null local-name=\"a\" value=\"b\"\n", + "# format: html5-dompatch-v3\nPATCH operation=1 kind=create-element node=\"node-1\" namespace=html local-name=\"x\"\nPATCH_ATTRIBUTE operation=01 index=0 namespace=none prefix=null local-name=\"a\" value=\"b\"\n", + ]; + for snapshot in malformed { + assert!( + patches::read(snapshot.as_bytes()).is_err(), + "accepted:\n{snapshot}" + ); + } + + use html::conformance::{ObservedPatchOperation, ObservedPatchStream, PatchNodeLabel}; + assert!( + patches::write(&ObservationState::Captured(ObservedPatchStream { + operations: vec![ObservedPatchOperation::RemoveNode { + node: PatchNodeLabel("arbitrary".to_string()), + }], + })) + .is_err() + ); +} + +#[test] +fn snapshot_surface_variants_are_distinct_and_compile_time_specific() { + use std::any::TypeId; + + let _: fn(token_v2::ParsedTokenSnapshot) -> ParsedSnapshot = ParsedSnapshot::Tokens; + let _: fn(patches::ParsedPatchesSnapshot) -> ParsedSnapshot = ParsedSnapshot::Patches; + let _: fn(token_v2::CanonicalTokenSnapshot) -> CanonicalSnapshot = CanonicalSnapshot::Tokens; + let _: fn(patches::CanonicalPatchesSnapshot) -> CanonicalSnapshot = CanonicalSnapshot::Patches; + + assert_ne!( + TypeId::of::(), + TypeId::of::() + ); + assert_ne!( + TypeId::of::(), + TypeId::of::() + ); +} diff --git a/crates/html_test_support/src/parser_snapshot/token_v2.rs b/crates/html_test_support/src/parser_snapshot/token_v2.rs new file mode 100644 index 00000000..4bd6a7aa --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/token_v2.rs @@ -0,0 +1,231 @@ +use super::SnapshotData; +use super::lexical::{ + SnapshotReadError, SnapshotRecord, escape_quoted, fixed_fields, optional_quoted, + strict_record_lines, validate_bool, validate_nullable_quoted, validate_quoted, validate_u64, +}; +use html::conformance::{ObservationState, ObservedToken}; +use std::collections::BTreeSet; +use std::fmt::Write; + +const HEADER: &str = "# format: html5-token-v2"; + +define_snapshot_types!(ParsedTokenSnapshot, CanonicalTokenSnapshot); + +pub(super) fn write( + state: &ObservationState>, +) -> Result { + let ObservationState::Captured(tokens) = state else { + return Err(()); + }; + if !matches!(tokens.last(), Some(ObservedToken::Eof)) + || tokens[..tokens.len().saturating_sub(1)] + .iter() + .any(|token| matches!(token, ObservedToken::Eof)) + { + return Err(()); + } + let mut bytes = format!("{HEADER}\n"); + let mut records = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + let ordinal = index.checked_add(1).ok_or(())?; + let line = match token { + ObservedToken::Doctype { + name, + public_id, + system_id, + force_quirks, + } => format!( + "TOKEN ordinal={ordinal} kind=doctype name={} public-id={} system-id={} force-quirks={force_quirks}", + optional_quoted(name.as_deref()), + optional_quoted(public_id.as_deref()), + optional_quoted(system_id.as_deref()) + ), + ObservedToken::StartTag { + name, + attributes: _, + self_closing, + } => format!( + "TOKEN ordinal={ordinal} kind=start-tag name={} self-closing={self_closing}", + escape_quoted(name) + ), + ObservedToken::EndTag { name } => format!( + "TOKEN ordinal={ordinal} kind=end-tag name={}", + escape_quoted(name) + ), + ObservedToken::Character { data } => format!( + "TOKEN ordinal={ordinal} kind=character data={}", + escape_quoted(data) + ), + ObservedToken::Comment { data } => format!( + "TOKEN ordinal={ordinal} kind=comment data={}", + escape_quoted(data) + ), + ObservedToken::ProcessingInstruction { target, data } => format!( + "TOKEN ordinal={ordinal} kind=processing-instruction target={} data={}", + escape_quoted(target), + escape_quoted(data) + ), + ObservedToken::Eof => format!("TOKEN ordinal={ordinal} kind=eof"), + }; + let _ = writeln!(&mut bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("token {ordinal}"), + line, + }); + if let ObservedToken::StartTag { attributes, .. } = token { + for (attribute_index, attribute) in attributes.iter().enumerate() { + let line = format!( + "TOKEN_ATTRIBUTE token={ordinal} index={attribute_index} name={} value={}", + escape_quoted(&attribute.name), + escape_quoted(&attribute.value) + ); + let _ = writeln!(&mut bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("token {ordinal} attribute {attribute_index}"), + line, + }); + } + } + } + Ok(CanonicalTokenSnapshot::new(SnapshotData::new( + bytes, records, + ))) +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let lines = strict_record_lines(bytes, HEADER, false)?; + let mut expected_ordinal = 1u64; + let mut expected_attribute = None::<(u64, u64)>; + let mut saw_eof = false; + let mut locations = BTreeSet::new(); + let mut records = Vec::new(); + for (line_number, line) in lines { + if saw_eof { + return Err(SnapshotReadError::TrailingContent { line: line_number }); + } + if line.starts_with("TOKEN_ATTRIBUTE ") { + let Some((token, index)) = expected_attribute else { + return malformed( + line_number, + "attribute record is not grouped below a start tag", + ); + }; + let Some(fields) = fixed_fields( + line, + "TOKEN_ATTRIBUTE", + &["token", "index", "name", "value"], + ) else { + return malformed(line_number, "invalid token attribute shape"); + }; + if !validate_u64(fields[0]) + || !validate_u64(fields[1]) + || !validate_quoted(fields[2]) + || !validate_quoted(fields[3]) + { + return malformed(line_number, "invalid token attribute field"); + } + if fields[0].parse::().ok() != Some(token) + || fields[1].parse::().ok() != Some(index) + { + return Err(SnapshotReadError::NonContiguousOrdinal { line: line_number }); + } + let location = format!("token {token} attribute {index}"); + if !locations.insert(location.clone()) { + return Err(SnapshotReadError::DuplicateLocation { line: line_number }); + } + records.push(SnapshotRecord { + location, + line: line.to_string(), + }); + expected_attribute = Some(( + token, + index + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?, + )); + continue; + } + + expected_attribute = None; + let mut prefix = line.splitn(4, ' '); + let (Some("TOKEN"), Some(ordinal_field), Some(kind_field)) = + (prefix.next(), prefix.next(), prefix.next()) + else { + return malformed(line_number, "invalid token record prefix"); + }; + let Some(ordinal) = ordinal_field.strip_prefix("ordinal=") else { + return malformed(line_number, "invalid token ordinal field"); + }; + let Some(kind) = kind_field.strip_prefix("kind=") else { + return malformed(line_number, "invalid token kind field"); + }; + if !validate_u64(ordinal) || ordinal.parse::().ok() != Some(expected_ordinal) { + return Err(SnapshotReadError::NonContiguousOrdinal { line: line_number }); + } + let valid = match kind { + "doctype" => fixed_fields( + line, + "TOKEN", + &[ + "ordinal", + "kind", + "name", + "public-id", + "system-id", + "force-quirks", + ], + ) + .is_some_and(|f| { + validate_nullable_quoted(f[2]) + && validate_nullable_quoted(f[3]) + && validate_nullable_quoted(f[4]) + && validate_bool(f[5]) + }), + "start-tag" => { + fixed_fields(line, "TOKEN", &["ordinal", "kind", "name", "self-closing"]) + .is_some_and(|f| validate_quoted(f[2]) && validate_bool(f[3])) + } + "end-tag" => fixed_fields(line, "TOKEN", &["ordinal", "kind", "name"]) + .is_some_and(|f| validate_quoted(f[2])), + "character" | "comment" => fixed_fields(line, "TOKEN", &["ordinal", "kind", "data"]) + .is_some_and(|f| validate_quoted(f[2])), + "processing-instruction" => { + fixed_fields(line, "TOKEN", &["ordinal", "kind", "target", "data"]) + .is_some_and(|f| validate_quoted(f[2]) && validate_quoted(f[3])) + } + "eof" => fixed_fields(line, "TOKEN", &["ordinal", "kind"]).is_some(), + _ => false, + }; + if !valid { + return malformed(line_number, "unknown token kind or invalid fixed fields"); + } + let location = format!("token {expected_ordinal}"); + if !locations.insert(location.clone()) { + return Err(SnapshotReadError::DuplicateLocation { line: line_number }); + } + records.push(SnapshotRecord { + location, + line: line.to_string(), + }); + if kind == "start-tag" { + expected_attribute = Some((expected_ordinal, 0)); + } else if kind == "eof" { + saw_eof = true; + } + expected_ordinal = expected_ordinal + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?; + } + if !saw_eof { + return malformed(1, "token snapshot requires one final EOF record"); + } + let text = std::str::from_utf8(bytes).map_err(|_| SnapshotReadError::InvalidUtf8)?; + Ok(ParsedTokenSnapshot::new(SnapshotData::new( + text.to_string(), + records, + ))) +} + +fn malformed(line: usize, reason: &'static str) -> Result { + Err(SnapshotReadError::MalformedRecord { line, reason }) +} diff --git a/crates/html_test_support/src/parser_snapshot/transitions.rs b/crates/html_test_support/src/parser_snapshot/transitions.rs new file mode 100644 index 00000000..e2397df9 --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/transitions.rs @@ -0,0 +1,186 @@ +use super::SnapshotData; +use super::lexical::{ + SnapshotReadError, SnapshotRecord, escape_quoted, fixed_fields, strict_record_lines, + validate_bool, validate_nullable_quoted, validate_u64, +}; +use crate::parser_snapshot::parse_errors::insertion_mode_name; +use html::conformance::{ + ObservationState, TransitionTokenSummary, TreeDispatchPath, TreeTransitionEvent, +}; +use std::fmt::Write; + +const HEADER: &str = "# format: html5-tree-transitions-v1"; + +define_snapshot_types!(ParsedTransitionsSnapshot, CanonicalTransitionsSnapshot); + +pub(super) fn write( + state: &ObservationState>, +) -> Result { + let ObservationState::Captured(events) = state else { + return Err(()); + }; + let mut bytes = format!("{HEADER}\n"); + let mut records = Vec::new(); + for event in events { + let (kind, name, data, self_closing) = match event.token.as_ref() { + TransitionTokenSummary::Doctype => { + ("doctype", "null".to_string(), "null".to_string(), "null") + } + TransitionTokenSummary::StartTag { name, self_closing } => ( + "start-tag", + escape_quoted(name), + "null".to_string(), + if *self_closing { "true" } else { "false" }, + ), + TransitionTokenSummary::EndTag { name } => { + ("end-tag", escape_quoted(name), "null".to_string(), "null") + } + TransitionTokenSummary::Character { data } => { + ("character", "null".to_string(), escape_quoted(data), "null") + } + TransitionTokenSummary::Comment => { + ("comment", "null".to_string(), "null".to_string(), "null") + } + TransitionTokenSummary::ProcessingInstruction { target } => ( + "processing-instruction", + escape_quoted(target), + "null".to_string(), + "null", + ), + TransitionTokenSummary::Eof => ("eof", "null".to_string(), "null".to_string(), "null"), + }; + let dispatch = match event.dispatch_path { + TreeDispatchPath::HtmlInsertionMode(mode) => { + format!("html-insertion-mode:{}", insertion_mode_name(mode)) + } + TreeDispatchPath::SharedTemplateRules => "shared-template-rules".to_string(), + TreeDispatchPath::ForeignContent => "foreign-content".to_string(), + TreeDispatchPath::TextMode => "text-mode".to_string(), + }; + let line = format!( + "TRANSITION occurrence={} token-kind={kind} token-name={name} token-data={data} token-self-closing={self_closing} mode-before={} dispatch={} mode-after={} reprocessed={}", + event.occurrence, + insertion_mode_name(event.insertion_mode_before), + dispatch, + insertion_mode_name(event.insertion_mode_after), + event.reprocessed + ); + let _ = writeln!(&mut bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("occurrence {}", event.occurrence), + line, + }); + } + Ok(CanonicalTransitionsSnapshot::new(SnapshotData::new( + bytes, records, + ))) +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let lines = strict_record_lines(bytes, HEADER, true)?; + let mut expected = 1u64; + let mut records = Vec::new(); + for (line_number, line) in lines { + let Some(f) = fixed_fields( + line, + "TRANSITION", + &[ + "occurrence", + "token-kind", + "token-name", + "token-data", + "token-self-closing", + "mode-before", + "dispatch", + "mode-after", + "reprocessed", + ], + ) else { + return malformed(line_number); + }; + if !validate_u64(f[0]) + || f[0].parse::().ok() != Some(expected) + || !valid_token(f[1], f[2], f[3], f[4]) + || !valid_mode(f[5]) + || !valid_dispatch(f[6]) + || !valid_mode(f[7]) + || !validate_bool(f[8]) + { + return malformed(line_number); + } + records.push(SnapshotRecord { + location: format!("occurrence {expected}"), + line: line.to_string(), + }); + expected = expected + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?; + } + Ok(ParsedTransitionsSnapshot::new(SnapshotData::new( + std::str::from_utf8(bytes) + .map_err(|_| SnapshotReadError::InvalidUtf8)? + .to_string(), + records, + ))) +} + +fn valid_token(kind: &str, name: &str, data: &str, self_closing: &str) -> bool { + match kind { + "doctype" | "comment" | "eof" => name == "null" && data == "null" && self_closing == "null", + "start-tag" => { + validate_nullable_quoted(name) + && name != "null" + && data == "null" + && validate_bool(self_closing) + } + "end-tag" | "processing-instruction" => { + validate_nullable_quoted(name) + && name != "null" + && data == "null" + && self_closing == "null" + } + "character" => { + name == "null" + && validate_nullable_quoted(data) + && data != "null" + && self_closing == "null" + } + _ => false, + } +} +fn valid_mode(value: &str) -> bool { + matches!( + value, + "initial" + | "before-html" + | "before-head" + | "in-head" + | "after-head" + | "in-body" + | "after-body" + | "after-after-body" + | "in-table" + | "in-table-text" + | "in-caption" + | "in-column-group" + | "in-table-body" + | "in-row" + | "in-cell" + | "in-template" + | "text" + ) +} +fn valid_dispatch(value: &str) -> bool { + matches!( + value, + "shared-template-rules" | "foreign-content" | "text-mode" + ) || value + .strip_prefix("html-insertion-mode:") + .is_some_and(valid_mode) +} +fn malformed(line: usize) -> Result { + Err(SnapshotReadError::MalformedRecord { + line, + reason: "invalid tree-transition record", + }) +} diff --git a/crates/html_test_support/src/parser_snapshot/tree.rs b/crates/html_test_support/src/parser_snapshot/tree.rs new file mode 100644 index 00000000..5243738f --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/tree.rs @@ -0,0 +1,509 @@ +use super::SnapshotData; +use super::lexical::{ + SnapshotReadError, SnapshotRecord, escape_quoted, fixed_fields, optional_quoted, + strict_record_lines, validate_nullable_quoted, validate_quoted, validate_u64, +}; +use html::conformance::{ObservationState, ObservedDomAttribute, ObservedTree, ObservedTreeNode}; +use std::collections::BTreeSet; +use std::fmt::Write; + +const HEADER: &str = "# format: html5-dom-v3"; + +define_snapshot_types!(ParsedTreeSnapshot, CanonicalTreeSnapshot); + +pub(super) fn write(state: &ObservationState) -> Result { + let ObservationState::Captured(tree) = state else { + return Err(()); + }; + let mut bytes = format!("{HEADER}\n"); + let mut records = Vec::new(); + let mut work = Vec::new(); + for (index, node) in tree.roots.iter().enumerate().rev() { + work.push(TreeWriteWork::Node { + node, + path: format!("/root[{index}]"), + }); + } + while let Some(item) = work.pop() { + match item { + TreeWriteWork::Node { node, path } => { + write_node_record(node, &path, &mut bytes, &mut records); + match node { + ObservedTreeNode::Document { children } + | ObservedTreeNode::Element { children, .. } => { + push_children(&mut work, children, &path); + } + ObservedTreeNode::HtmlTemplateElement { + ordinary_children, + contents, + .. + } => { + work.push(TreeWriteWork::TemplateContents { + host: path.clone(), + children: &contents.children, + }); + push_children(&mut work, ordinary_children, &path); + } + ObservedTreeNode::DocumentType { .. } + | ObservedTreeNode::Comment { .. } + | ObservedTreeNode::Text { .. } + | ObservedTreeNode::ProcessingInstruction { .. } => {} + } + } + TreeWriteWork::TemplateContents { host, children } => { + let path = format!("{host}/contents"); + let line = format!("TEMPLATE_CONTENTS path={path} host={host}"); + let _ = writeln!(bytes, "{line}"); + records.push(SnapshotRecord { + location: path.clone(), + line, + }); + push_children(&mut work, children, &path); + } + } + } + Ok(CanonicalTreeSnapshot::new(SnapshotData::new( + bytes, records, + ))) +} + +enum TreeWriteWork<'a> { + Node { + node: &'a ObservedTreeNode, + path: String, + }, + TemplateContents { + host: String, + children: &'a [ObservedTreeNode], + }, +} + +fn push_children<'a>( + work: &mut Vec>, + children: &'a [ObservedTreeNode], + parent: &str, +) { + for (index, child) in children.iter().enumerate().rev() { + work.push(TreeWriteWork::Node { + node: child, + path: format!("{parent}/child[{index}]"), + }); + } +} + +fn write_node_record( + node: &ObservedTreeNode, + path: &str, + bytes: &mut String, + records: &mut Vec, +) { + let line = match node { + ObservedTreeNode::Document { .. } => format!("NODE path={path} kind=document"), + ObservedTreeNode::DocumentType { + name, + public_id, + system_id, + } => format!( + "NODE path={path} kind=document-type name={} public-id={} system-id={}", + optional_quoted(name.as_deref()), + optional_quoted(public_id.as_deref()), + optional_quoted(system_id.as_deref()) + ), + ObservedTreeNode::Comment { data } => { + format!("NODE path={path} kind=comment data={}", escape_quoted(data)) + } + ObservedTreeNode::Text { data } => { + format!("NODE path={path} kind=text data={}", escape_quoted(data)) + } + ObservedTreeNode::ProcessingInstruction { target, data } => format!( + "NODE path={path} kind=processing-instruction target={} data={}", + escape_quoted(target), + escape_quoted(data) + ), + ObservedTreeNode::Element { + namespace, + local_name, + .. + } => format!( + "NODE path={path} kind=element namespace={} local-name={}", + namespace.snapshot_name(), + escape_quoted(local_name) + ), + ObservedTreeNode::HtmlTemplateElement { .. } => { + format!("NODE path={path} kind=html-template-host") + } + }; + let _ = writeln!(bytes, "{line}"); + records.push(SnapshotRecord { + location: path.to_string(), + line, + }); + match node { + ObservedTreeNode::Element { attributes, .. } => { + write_attributes(path, attributes, bytes, records); + } + ObservedTreeNode::HtmlTemplateElement { attributes, .. } => { + write_attributes(path, attributes, bytes, records); + } + ObservedTreeNode::Document { .. } + | ObservedTreeNode::DocumentType { .. } + | ObservedTreeNode::Comment { .. } + | ObservedTreeNode::Text { .. } + | ObservedTreeNode::ProcessingInstruction { .. } => {} + } +} + +fn write_attributes( + path: &str, + attributes: &[ObservedDomAttribute], + bytes: &mut String, + records: &mut Vec, +) { + for (index, attribute) in attributes.iter().enumerate() { + let line = format!( + "ATTRIBUTE path={path} index={index} namespace={} prefix={} local-name={} value={}", + attribute.namespace.snapshot_name(), + optional_quoted(attribute.prefix.as_deref()), + escape_quoted(&attribute.local_name), + escape_quoted(&attribute.value) + ); + let _ = writeln!(bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("{path} attribute {index}"), + line, + }); + } +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let lines = strict_record_lines(bytes, HEADER, true)?; + let mut locations = BTreeSet::new(); + let mut expected_attribute = None::<(String, u64)>; + let mut framing = TreeFraming::default(); + let mut records = Vec::new(); + for (line_number, line) in lines { + if line.starts_with("ATTRIBUTE ") { + let Some((node_path, index)) = expected_attribute.as_mut() else { + return malformed( + line_number, + "attribute is not grouped below an element record", + ); + }; + let Some(fields) = fixed_fields( + line, + "ATTRIBUTE", + &[ + "path", + "index", + "namespace", + "prefix", + "local-name", + "value", + ], + ) else { + return malformed(line_number, "invalid attribute shape"); + }; + if fields[0] != node_path + || !validate_u64(fields[1]) + || fields[1].parse::().ok() != Some(*index) + || !matches!(fields[2], "none" | "xml" | "xmlns" | "xlink") + || !validate_nullable_quoted(fields[3]) + || !validate_quoted(fields[4]) + || !validate_quoted(fields[5]) + { + return malformed(line_number, "invalid attribute field or local index"); + } + let location = format!("{} attribute {}", fields[0], fields[1]); + if !locations.insert(location.clone()) { + return Err(SnapshotReadError::DuplicateLocation { line: line_number }); + } + records.push(SnapshotRecord { + location, + line: line.to_string(), + }); + *index = index + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?; + continue; + } + expected_attribute = None; + if line.starts_with("TEMPLATE_CONTENTS ") { + let Some(fields) = fixed_fields(line, "TEMPLATE_CONTENTS", &["path", "host"]) else { + return malformed(line_number, "invalid template-contents shape"); + }; + if fields[0] != format!("{}/contents", fields[1]) + || !valid_tree_path(fields[0], true) + || !valid_tree_path(fields[1], false) + || !framing.accept_template_contents(fields[1]) + { + return malformed( + line_number, + "template contents must name its serialized HTML template host", + ); + } + if !locations.insert(fields[0].to_string()) { + return Err(SnapshotReadError::DuplicateLocation { line: line_number }); + } + records.push(SnapshotRecord { + location: fields[0].to_string(), + line: line.to_string(), + }); + continue; + } + let mut prefix = line.splitn(4, ' '); + let (Some("NODE"), Some(path_field), Some(kind_field)) = + (prefix.next(), prefix.next(), prefix.next()) + else { + return malformed(line_number, "invalid node prefix"); + }; + let Some(path) = path_field.strip_prefix("path=") else { + return malformed(line_number, "node path is missing"); + }; + let Some(kind) = kind_field.strip_prefix("kind=") else { + return malformed(line_number, "node kind is missing"); + }; + let valid = match kind { + "document" | "html-template-host" => { + fixed_fields(line, "NODE", &["path", "kind"]).is_some() + } + "document-type" => fixed_fields( + line, + "NODE", + &["path", "kind", "name", "public-id", "system-id"], + ) + .is_some_and(|f| { + validate_nullable_quoted(f[2]) + && validate_nullable_quoted(f[3]) + && validate_nullable_quoted(f[4]) + }), + "comment" | "text" => fixed_fields(line, "NODE", &["path", "kind", "data"]) + .is_some_and(|f| validate_quoted(f[2])), + "processing-instruction" => { + fixed_fields(line, "NODE", &["path", "kind", "target", "data"]) + .is_some_and(|f| validate_quoted(f[2]) && validate_quoted(f[3])) + } + "element" => fixed_fields(line, "NODE", &["path", "kind", "namespace", "local-name"]) + .is_some_and(|f| { + matches!(f[2], "html" | "svg" | "mathml") && validate_quoted(f[3]) + }), + _ => false, + }; + if !valid { + return malformed(line_number, "unknown node kind or malformed node fields"); + } + let container = match kind { + "document" | "element" => Some(ContainerKind::Ordinary { next_child: 0 }), + "html-template-host" => Some(ContainerKind::Template { + next_ordinary_child: 0, + next_contents_child: None, + }), + "document-type" | "comment" | "text" | "processing-instruction" => None, + _ => unreachable!("closed node kind validated above"), + }; + if !valid_tree_path(path, false) || !framing.accept_node(path, container) { + return malformed(line_number, "invalid or non-preorder tree path"); + } + if !locations.insert(path.to_string()) { + return Err(SnapshotReadError::DuplicateLocation { line: line_number }); + } + records.push(SnapshotRecord { + location: path.to_string(), + line: line.to_string(), + }); + if matches!(kind, "element" | "html-template-host") { + expected_attribute = Some((path.to_string(), 0)); + } + } + if !framing.finish() { + return malformed(1, "HTML template host is missing its contents boundary"); + } + Ok(ParsedTreeSnapshot::new(SnapshotData::new( + std::str::from_utf8(bytes) + .map_err(|_| SnapshotReadError::InvalidUtf8)? + .to_string(), + records, + ))) +} + +#[derive(Clone, Debug)] +enum ContainerKind { + Ordinary { + next_child: u64, + }, + Template { + next_ordinary_child: u64, + next_contents_child: Option, + }, +} + +#[derive(Clone, Debug)] +struct ContainerFrame { + path: String, + kind: ContainerKind, +} + +#[derive(Default)] +struct TreeFraming { + next_root: u64, + frames: Vec, +} + +impl TreeFraming { + fn accept_node(&mut self, path: &str, container: Option) -> bool { + let accepted = if let Some((parent, index)) = child_location(path) { + if let Some(host) = parent.strip_suffix("/contents") { + self.close_to(host) + && self.frames.last_mut().is_some_and(|frame| { + let ContainerKind::Template { + next_contents_child: Some(expected), + .. + } = &mut frame.kind + else { + return false; + }; + advance_index(expected, index) + }) + } else { + self.close_to(parent) + && self + .frames + .last_mut() + .is_some_and(|frame| match &mut frame.kind { + ContainerKind::Ordinary { next_child } => { + advance_index(next_child, index) + } + ContainerKind::Template { + next_ordinary_child, + next_contents_child: None, + } => advance_index(next_ordinary_child, index), + ContainerKind::Template { + next_contents_child: Some(_), + .. + } => false, + }) + } + } else if let Some(index) = root_location(path) { + self.close_all() && advance_index(&mut self.next_root, index) + } else { + false + }; + if accepted && let Some(kind) = container { + self.frames.push(ContainerFrame { + path: path.to_string(), + kind, + }); + } + accepted + } + + fn accept_template_contents(&mut self, host: &str) -> bool { + self.close_to(host) + && self.frames.last_mut().is_some_and(|frame| { + let ContainerKind::Template { + next_contents_child, + .. + } = &mut frame.kind + else { + return false; + }; + if next_contents_child.is_some() { + return false; + } + *next_contents_child = Some(0); + true + }) + } + + fn close_to(&mut self, path: &str) -> bool { + while self.frames.last().is_some_and(|frame| frame.path != path) { + if !self.pop_complete() { + return false; + } + } + self.frames.last().is_some_and(|frame| frame.path == path) + } + + fn close_all(&mut self) -> bool { + while !self.frames.is_empty() { + if !self.pop_complete() { + return false; + } + } + true + } + + fn pop_complete(&mut self) -> bool { + self.frames.pop().is_some_and(|frame| match frame.kind { + ContainerKind::Ordinary { .. } => true, + ContainerKind::Template { + next_contents_child, + .. + } => next_contents_child.is_some(), + }) + } + + fn finish(&mut self) -> bool { + self.close_all() + } +} + +fn advance_index(expected: &mut u64, actual: u64) -> bool { + if *expected != actual { + return false; + } + let Some(next) = expected.checked_add(1) else { + return false; + }; + *expected = next; + true +} + +fn child_location(path: &str) -> Option<(&str, u64)> { + let (parent, tail) = path.rsplit_once("/child[")?; + let index = tail.strip_suffix(']')?; + validate_u64(index) + .then(|| index.parse::().ok()) + .flatten() + .map(|index| (parent, index)) +} + +fn root_location(path: &str) -> Option { + let index = path.strip_prefix("/root[")?.strip_suffix(']')?; + validate_u64(index) + .then(|| index.parse::().ok()) + .flatten() +} + +fn valid_tree_path(path: &str, allow_contents_terminal: bool) -> bool { + let Some(mut rest) = path.strip_prefix("/root[") else { + return false; + }; + let Some(end) = rest.find(']') else { + return false; + }; + if !validate_u64(&rest[..end]) { + return false; + } + rest = &rest[end + 1..]; + while !rest.is_empty() { + if let Some(next) = rest.strip_prefix("/contents") { + rest = next; + continue; + } + let Some(next) = rest.strip_prefix("/child[") else { + return false; + }; + let Some(end) = next.find(']') else { + return false; + }; + if !validate_u64(&next[..end]) { + return false; + } + rest = &next[end + 1..]; + } + allow_contents_terminal || !path.ends_with("/contents") +} + +fn malformed(line: usize, reason: &'static str) -> Result { + Err(SnapshotReadError::MalformedRecord { line, reason }) +} diff --git a/crates/html_test_support/src/parser_snapshot/unsupported_features.rs b/crates/html_test_support/src/parser_snapshot/unsupported_features.rs new file mode 100644 index 00000000..177e17a0 --- /dev/null +++ b/crates/html_test_support/src/parser_snapshot/unsupported_features.rs @@ -0,0 +1,170 @@ +use super::SnapshotData; +use super::lexical::{ + SnapshotReadError, SnapshotRecord, fixed_fields, strict_record_lines, validate_u64, +}; +use crate::parser_snapshot::parse_errors::insertion_mode_name; +use html::conformance::{ + ObservationState, ParserTokenKind, TreeConstructionUnsupportedFeature, UnsupportedFeatureEvent, +}; +use std::fmt::Write; + +const HEADER: &str = "# format: html5-unsupported-features-v1"; + +define_snapshot_types!( + ParsedUnsupportedFeaturesSnapshot, + CanonicalUnsupportedFeaturesSnapshot +); + +pub(super) fn write( + state: &ObservationState>, +) -> Result { + let ObservationState::Captured(events) = state else { + return Err(()); + }; + let mut bytes = format!("{HEADER}\n"); + let mut records = Vec::new(); + for event in events { + let UnsupportedFeatureEvent::TreeConstruction { + occurrence, + feature, + context, + } = event; + let line = format!( + "UNSUPPORTED_FEATURE occurrence={occurrence} subsystem=tree-construction feature={} context-token={} context-mode={} context-namespace={}", + feature_name(*feature), + context.token_kind.map_or("null", token_name), + context.insertion_mode.map_or("null", insertion_mode_name), + context + .adjusted_current_node_namespace + .map_or("null", |v| v.snapshot_name()) + ); + let _ = writeln!(&mut bytes, "{line}"); + records.push(SnapshotRecord { + location: format!("occurrence {occurrence}"), + line, + }); + } + Ok(CanonicalUnsupportedFeaturesSnapshot::new( + SnapshotData::new(bytes, records), + )) +} + +pub(super) fn read(bytes: &[u8]) -> Result { + let lines = strict_record_lines(bytes, HEADER, true)?; + let mut expected = 1u64; + let mut records = Vec::new(); + for (line_number, line) in lines { + let Some(f) = fixed_fields( + line, + "UNSUPPORTED_FEATURE", + &[ + "occurrence", + "subsystem", + "feature", + "context-token", + "context-mode", + "context-namespace", + ], + ) else { + return malformed(line_number); + }; + if !validate_u64(f[0]) + || f[0].parse::().ok() != Some(expected) + || f[1] != "tree-construction" + || !valid_feature(f[2]) + || !valid_token(f[3]) + || !valid_mode(f[4]) + || !matches!(f[5], "null" | "html" | "svg" | "mathml") + { + return malformed(line_number); + } + records.push(SnapshotRecord { + location: format!("occurrence {expected}"), + line: line.to_string(), + }); + expected = expected + .checked_add(1) + .ok_or(SnapshotReadError::NonContiguousOrdinal { line: line_number })?; + } + Ok(ParsedUnsupportedFeaturesSnapshot::new(SnapshotData::new( + std::str::from_utf8(bytes) + .map_err(|_| SnapshotReadError::InvalidUtf8)? + .to_string(), + records, + ))) +} + +fn feature_name(value: TreeConstructionUnsupportedFeature) -> &'static str { + match value { + TreeConstructionUnsupportedFeature::MergeAttributesIntoExistingHtmlElement => "merge-attributes-into-existing-html-element", + TreeConstructionUnsupportedFeature::MergeAttributesIntoExistingBodyElement => "merge-attributes-into-existing-body-element", + TreeConstructionUnsupportedFeature::MarkFramesetNotOkForRepeatedBodyStartTag => "mark-frameset-not-ok-for-repeated-body-start-tag", + TreeConstructionUnsupportedFeature::RequireSameNamedTableCellInScopeForEndTag => "require-same-named-table-cell-in-scope-for-end-tag", + TreeConstructionUnsupportedFeature::GenerateImpliedEndTagsAndCheckCurrentNodeBeforeClosingTableCell => "generate-implied-end-tags-and-check-current-node-before-closing-table-cell", + TreeConstructionUnsupportedFeature::GenerateImpliedEndTagsAndCheckCurrentNodeBeforeClosingCaption => "generate-implied-end-tags-and-check-current-node-before-closing-caption", +} +} +fn token_name(value: ParserTokenKind) -> &'static str { + match value { + ParserTokenKind::Doctype => "doctype", + ParserTokenKind::StartTag => "start-tag", + ParserTokenKind::EndTag => "end-tag", + ParserTokenKind::Character => "character", + ParserTokenKind::Comment => "comment", + ParserTokenKind::ProcessingInstruction => "processing-instruction", + ParserTokenKind::Eof => "eof", + } +} +fn valid_feature(v: &str) -> bool { + matches!( + v, + "merge-attributes-into-existing-html-element" + | "merge-attributes-into-existing-body-element" + | "mark-frameset-not-ok-for-repeated-body-start-tag" + | "require-same-named-table-cell-in-scope-for-end-tag" + | "generate-implied-end-tags-and-check-current-node-before-closing-table-cell" + | "generate-implied-end-tags-and-check-current-node-before-closing-caption" + ) +} +fn valid_token(v: &str) -> bool { + matches!( + v, + "null" + | "doctype" + | "start-tag" + | "end-tag" + | "character" + | "comment" + | "processing-instruction" + | "eof" + ) +} +fn valid_mode(v: &str) -> bool { + matches!( + v, + "null" + | "initial" + | "before-html" + | "before-head" + | "in-head" + | "after-head" + | "in-body" + | "after-body" + | "after-after-body" + | "in-table" + | "in-table-text" + | "in-caption" + | "in-column-group" + | "in-table-body" + | "in-row" + | "in-cell" + | "in-template" + | "text" + ) +} +fn malformed(line: usize) -> Result { + Err(SnapshotReadError::MalformedRecord { + line, + reason: "invalid unsupported-feature record", + }) +} diff --git a/crates/html_test_support/src/token_snapshot.rs b/crates/html_test_support/src/token_snapshot.rs index a0f487e7..54b3d4b6 100644 --- a/crates/html_test_support/src/token_snapshot.rs +++ b/crates/html_test_support/src/token_snapshot.rs @@ -337,6 +337,30 @@ mod tests { ); } + #[test] + fn html5_token_v1_reader_preserves_legacy_physical_acceptance() { + let compatibility = + b"# preliminary comment\r\n\r\nCHAR text=\"x\"\r\nEOF\r\n# format: html5-token-v1"; + assert_eq!( + read_html5_token_v1(compatibility), + Ok(vec!["CHAR text=\"x\"".to_string(), "EOF".to_string()]) + ); + assert_eq!( + read_html5_token_v1(b"# format: html5-token-v1\nEOF"), + Ok(vec!["EOF".to_string()]) + ); + assert!(matches!( + read_html5_token_v1(b"\xEF\xBB\xBF# format: html5-token-v1\nEOF\n"), + Err(TokenSnapshotReadError::MalformedTokenLine { line: 1, .. }) + )); + assert!(matches!( + read_html5_token_v1( + b"# format: html5-token-v1\nEOF\n# trailing comment\nCHAR text=\"x\"\n" + ), + Err(TokenSnapshotReadError::ContentAfterEof { line: 4 }) + )); + } + #[test] fn html5_token_v1_reader_rejects_malformed_content() { assert!(matches!( diff --git a/docs/engine-feature-gap-tracker.md b/docs/engine-feature-gap-tracker.md index 00ad3247..f9ed8fbe 100644 --- a/docs/engine-feature-gap-tracker.md +++ b/docs/engine-feature-gap-tracker.md @@ -421,7 +421,8 @@ Current supported subset: independently bounded capture is exposed only through the non-default `html::conformance` engine-test boundary; ordinary parser APIs and output are unchanged. UTF-8 decoding now uses one chunk-independent constrained state - machine, and fixture-v1 serialized diagnostic surfaces remain inactive. + machine. Fixture-v1 remains compatibility-only; AE13b5 activates strict + fixture-v2 serialized diagnostic surfaces. - AE13b2 tree diagnostics and document-mode capture: the same parser-owned fanout now records typed tree-construction parse errors, Borrowser implementation deviations, and configured tree resource limits at their @@ -444,9 +445,8 @@ Current supported subset: semantic capacity but remain fallibly allocated. Patch capture retains an operation-count prefix, assigns first-appearance labels, and validates retained creation history. These capacities are not byte budgets, and the existing parser-owned - materializer is not claimed to be stack-independent. Canonical serializers, - fixture sidecars/corpus migration, and AE13c final invariants remain - deferred. AE13b4 adds always-compiled parser-owned tree dispatch transitions + materializer is not claimed to be stack-independent. AE13b4 adds + always-compiled parser-owned tree dispatch transitions and exact unsupported-feature observations. One central event records each ordinary HTML, shared-template, Text-mode, or foreign attempt; foreign breakout/end fallback uses a visible one-shot HTML-rules-only redispatch. @@ -459,6 +459,36 @@ Current supported subset: implemented by AE13b4. Full doctype classification conformance is not claimed. See `docs/html5/ae13-parser-conformance-regression-harness.md`. +- AE13b5 deterministic parser snapshots and fixture diagnostics: native + `borrowser-html-parser-fixture-v2` bundles dispatch through an exact typed + loader and execute supported whole Unicode-scalar tokenizer/document + deliveries once per planned delivery. Requests union ordinary and + delivery-specific transition surfaces; every declared delivery is + capability-checked, unused supported deliveries do not execute, and + incomplete observations never compare. Test support owns strict versioned + codecs for tokens v2, diagnostics, document mode, canonical tree v3, + canonical patch stream v3, transitions, and unsupported features, with stable + first-record mismatch diagnostics. Fixture-v1 parsing, token-v1 acceptance, + and scalar failure identities remain unchanged. The focused native corpus + covers absent versus literal `null` doctype names, template contents, foreign + namespaces, empty requested diagnostics, canonical patch order, + delivery-specific transitions, and unsupported-feature identity. AE13c final + invariants and parity, broad corpus migration, external adapters, blessing, + fragments, scripting, rendering, and public APIs remain deferred. See + `docs/html5/parser-fixture-format-v2.md` and + `docs/html5/ae13b5-parser-snapshot-formats.md`. Architecture-review hardening + now uses iterative stack-safe tree writing, strict preorder/template framing, + canonical `node-` patch labels, exact incomplete-state + identities, single-owner v2 delivery results, injectable private test + guardrails, one failure-spelling codec, and surface-specific snapshot types. + Nested template paths may contain repeated complete `/contents` segments; + lexical validation remains separate from the per-host traversal framing + stack. Fixture-v2 sidecars receive metadata-only validation, and the v2 + runner's ordered execution phase is the sole complete-content read boundary, + so skipped v2 fixtures read zero sidecar bytes. Fixture-v1 retains its legacy + validation-time full read and execution-time token-sidecar reread. + Implementation remains pending formal review and AE13b5 is not yet recorded + as closed. - AE1 HTML parser ownership architecture: HTML/parser owns tokenizer input preprocessing, tokenizer states, typed tokens, parse errors, tree-construction state, insertion modes, stack of open elements, active diff --git a/docs/html5/ae13-parser-conformance-regression-harness.md b/docs/html5/ae13-parser-conformance-regression-harness.md index ca6dc010..0fed3709 100644 --- a/docs/html5/ae13-parser-conformance-regression-harness.md +++ b/docs/html5/ae13-parser-conformance-regression-harness.md @@ -890,28 +890,69 @@ AE13a intentionally has no speculative generic adapter registry. A real known semantic extension must later receive an exact-version typed adapter at the single validation boundary. -## Snapshot format status - -- `html5-token-v1`: executable AE13a compatibility format. -- `html5-dom-v2`: existing compatibility tree format. -- `html5-dompatch-v2`: existing compatibility patch format. -- `html5-dompatch-v3`: planned native AE13 patch format using labels assigned by - first semantic appearance and no normative transport batch boundaries. -- Native parse-error, implementation-diagnostic, document-mode, transition, - unsupported-feature, and final-invariant formats are reserved/planned for - later AE13 slices. AE13b2 captures tree diagnostics and document mode only in - memory; it does not activate canonical serializers or fixture sidecars. - -The `html5-token-v1` reader lives beside the existing token formatter and emits -dedicated typed snapshot-format errors. Malformed snapshots are not reported as -fixture-TOML errors. +## AE13b5 snapshot and runner status + +Fixture v2 activates `html5-token-v2`, `html5-parse-errors-v1`, +`html5-implementation-diagnostics-v1`, `html5-document-mode-v1`, +`html5-dom-v3`, `html5-dompatch-v3`, `html5-tree-transitions-v1`, and +`html5-unsupported-features-v1`. Writers consume only typed +`CanonicalParserResult` surfaces. The native standalone token path no longer +runs `TokenFmt` and canonical observation as two semantic executions. + +Fixture v1 and `html5-token-v1` remain isolated compatibility contracts. Their +accepted syntax and scalar failure identities are unchanged. + +Every declared delivery is capability-checked. Only reference and +transition-selected deliveries are planned; requests are unioned per delivery +and each planned delivery executes once in declaration order. No comparison +begins until every execution succeeds and every requested observation is +authoritative. Completed reports are all-or-nothing. + +Private fixture guardrails are expectation-independent. Canonical tree capacity +counts document, document type, element/template host, text, comment, +processing instruction, and template-contents boundary. Attributes and the +outer `ObservedTree` wrapper do not consume units. Incomplete prefixes never +reach serialization or comparison. The private policy is injected through +request construction so tests exercise exact and capacity-plus-one boundaries; +fixture declarations and sidecars cannot configure it. Incomplete diagnostics +retain exact delivery, surface, reason, retained count, and dropped count. + +Canonical tree snapshot writing and framing validation are iterative. The +writer preserves canonical preorder and the template ordinary/content split +without recursive descent. The reader enforces only physical traversal +framing, including immediate owner attributes and one contents boundary per +template host, while remaining deliberately agnostic about parser and namespace +correctness. Lexical paths accept repeated complete `/contents` segments for +nested template hosts; the independent framing stack proves which host owns +each boundary and rejects consecutive boundaries without an intervening host. +Canonical patch labels are quoted `node-` values, +and all patch ordinals/attribute indices use canonical decimal spellings. + +Fixture-v2 declaration validation inspects expected-sidecar metadata without +opening or reading the complete content. Non-skipped v2 execution reads content +only in the ordered expected-sidecar phase. A skipped v2 disposition therefore +performs zero sidecar-content reads as well as zero parser executions and +exposes no canonical result. Fixture-v1 preserves its frozen legacy boundary: +validation fully reads declared sidecars, including for skipped fixtures, and +the legacy token runner rereads its sidecar during execution. + +Surface-specific parsed/canonical snapshot types seal each format at compile +time. V2 completed reports store each delivery result once and borrow the +reference result through the compatibility accessor. One authoritative +failure-spelling codec owns structured declaration parsing and stable identity +formatting. + +See `parser-fixture-format-v2.md` and +`ae13b5-parser-snapshot-formats.md` for normative grammar, precedence, and +diagnostics. ## Later slices - AE13b1: parser-owned token and tokenizer-diagnostic observation foundation. - AE13b2: tree-construction diagnostics and production document-mode capture. -- AE13b3 through AE13b5: remaining parser observations, shared escaping, and - stable serializers. +- AE13b3 and AE13b4: canonical tree/patch/transition/unsupported observations + are complete. AE13b5 strict serializers and fixture diagnostics are + implemented; issue closure remains pending formal architecture review. - AE13c: semantic whole/chunked parity and production final-invariant execution. - AE13d: existing corpus consolidation and migration. - AE13e: external html5lib/WPT adapter, intentional snapshot updates, final @@ -919,4 +960,4 @@ fixture-TOML errors. Fragment execution, scripting-dependent parsing, original source-byte provenance, Layout, Paint, JavaScript execution, navigation, and resource -loading are not implemented by AE13a. +loading are not implemented by AE13b5. diff --git a/docs/html5/ae13b5-parser-snapshot-formats.md b/docs/html5/ae13b5-parser-snapshot-formats.md new file mode 100644 index 00000000..844c288d --- /dev/null +++ b/docs/html5/ae13b5-parser-snapshot-formats.md @@ -0,0 +1,281 @@ +# AE13b5 Canonical Parser Snapshot Formats + +## Ownership + +`html::conformance::execute_parser_observation` and its typed +`CanonicalParserResult` are the only semantic source. `html-test-support` owns +the textual codecs, sidecar comparison, and diagnostics. Writers never consume +legacy `TokenFmt`, legacy DOM/patch serializers, raw parser IDs, `PatchKey`, +materialized IDs, runtime patch batches, memory addresses, hash-map iteration, +platform path syntax, descriptions, or Rust `Debug`. + +Readers validate versioned grammar and format-local framing. They do not decide +whether the parser should emit a token, namespace, mode, tree topology, patch, +transition, or unsupported feature. + +## Shared physical and lexical grammar + +Every canonical snapshot is valid UTF-8 without BOM. It uses LF only, has its +exact header on physical line one, and ends with one terminal LF. CR, CRLF, +comments, blank lines, duplicate headers, leading/trailing whitespace, double +field spaces, and trailing tokens are illegal. Each semantic record occupies +exactly one physical line. + +Fields have fixed order. Unsigned integers use canonical base-10 with no sign +or leading zero except `0`. Booleans are `true` or `false`. Optional strings are +unquoted `null` or a quoted string; therefore `null` and `"null"` differ. + +Quoted strings preserve UTF-8 scalar values. Canonical escapes are `\"`, +`\\`, `\n`, `\r`, `\t`, and uppercase fixed four-digit `\uXXXX` for remaining +C0 controls and DEL. Raw control characters, unknown escapes, noncanonical +alternate escapes, and unpaired scalar encodings are malformed. + +Malformed headers, unknown versions, unknown closed spellings, missing or +duplicate fields, invalid escapes, invalid locations, duplicate locations, +noncontiguous local indices, and trailing content are `SnapshotFormat(surface)`. +Future incompatible grammar receives a new format identifier. + +## Inventory and empty forms + +| Surface | Exact header | Empty/header-only legal | +|---|---|---| +| tokens | `# format: html5-token-v2` | no; one final EOF required | +| parse errors | `# format: html5-parse-errors-v1` | yes | +| implementation diagnostics | `# format: html5-implementation-diagnostics-v1` | yes | +| document mode | `# format: html5-document-mode-v1` | no; exactly one record | +| canonical tree | `# format: html5-dom-v3` | yes | +| canonical patch stream | `# format: html5-dompatch-v3` | yes | +| transitions | `# format: html5-tree-transitions-v1` | yes | +| unsupported features | `# format: html5-unsupported-features-v1` | yes | + +An empty requested collection is its header-only snapshot. An absent +expectation is not read, requested, serialized, or compared. + +The per-format framing rules are: + +| Surface | Record ordinal/local index | Duplicate location | Content after the final allowed record | +|---|---|---|---| +| tokens | token ordinals start at 1 and are contiguous; attributes start at 0 for each start tag and are contiguous | rejected | rejected; EOF must be the sole final token record | +| parse errors | occurrences start at 1 and are contiguous | rejected by occurrence framing | rejected as an unknown additional record | +| implementation diagnostics | occurrences start at 1 and are contiguous | rejected by occurrence framing | rejected as an unknown additional record | +| document mode | no ordinal; exactly one `MODE` location | a second mode is rejected | rejected | +| canonical tree | root indices start at 0; child indices start at 0 for each parent; attribute indices start at 0 for each owner; all are contiguous | rejected for node, boundary, and attribute locations | rejected as an unknown additional record | +| canonical patches | operation ordinals start at 1 and are contiguous; attributes start at 0 for each owning operation and are contiguous | rejected | rejected as an unknown additional record | +| transitions | occurrences start at 1 and are contiguous | rejected by occurrence framing | rejected as an unknown additional record | +| unsupported features | occurrences start at 1 and are contiguous | rejected by occurrence framing | rejected as an unknown additional record | + +There is no separate trailer syntax in any format. Header-only tree and patch +snapshots are grammatically legal even though a successful document execution +normally emits structural records and patch operations. + +## Tokens: html5-token-v2 + +Token records use contiguous one-based ordinals: + +```text +TOKEN ordinal= kind=doctype name= public-id= system-id= force-quirks= +TOKEN ordinal= kind=start-tag name= self-closing= +TOKEN ordinal= kind=end-tag name= +TOKEN ordinal= kind=character data= +TOKEN ordinal= kind=comment data= +TOKEN ordinal= kind=processing-instruction target= data= +TOKEN ordinal= kind=eof +``` + +Start-tag attributes immediately follow their owning token and use contiguous +zero-based local indices: + +```text +TOKEN_ATTRIBUTE token= index= name= value= +``` + +There is exactly one EOF and it is the final record. Semantic names are quoted; +v1 bare-name and sentinel restrictions do not apply. V1 remains compatibility +only because `DOCTYPE name=null` cannot distinguish absence from the literal +name `null`. An incompatible token change requires `html5-token-v3`. + +## Parse errors and implementation diagnostics + +Parse errors use contiguous one-based occurrences and the exact form: + +```text +PARSE_ERROR occurrence= stage= code= recovery= position= context= context-token= context-mode= context-namespace= +``` + +Implementation diagnostics use: + +```text +IMPLEMENTATION_DIAGNOSTIC occurrence= stage= code= payload= position= context= context-token= context-mode= context-namespace= +``` + +The closed spellings are exhaustive matches over the current typed enums. +Descriptions are forbidden because their wording is not semantic identity. +Known positions encode normalized UTF-8 offset, one-based line/column, and +typed source provenance; unavailable positions retain their exact reason. +Context presence is explicit, so absent context differs from a present context +whose optional fields are all null. + +## Document mode + +Exactly one record follows the header: + +```text +MODE value= +``` + +The reader validates only the closed spelling. It does not reclassify the +doctype. + +## Canonical tree: html5-dom-v3 + +Structural records are depth-first in canonical vector/traversal order: + +```text +NODE path= kind=document +NODE path= kind=document-type name= public-id= system-id= +NODE path= kind=element namespace= local-name= +NODE path= kind=html-template-host +NODE path= kind=text data= +NODE path= kind=comment data= +NODE path= kind=processing-instruction target= data= +``` + +Attributes immediately follow their element or template host: + +```text +ATTRIBUTE path= index= namespace= prefix= local-name= value= +``` + +Tree-path lexical grammar is `/root[]` followed by zero or more +complete `/child[]` or `/contents` segments. A node or host path +cannot end in `/contents`; the explicit boundary form can. Multiple `/contents` +segments are legal when templates are nested, for example +`/root[0]/contents/child[0]/contents`. Malformed segment prefixes, suffixes, +signs, and noncanonical decimal indices are rejected lexically without deciding +whether any segment names a real template host. A template contents boundary is +explicit: + +```text +TEMPLATE_CONTENTS path=/contents host= +``` + +Its children use `/contents/child[n]`. The writer uses an explicit +work stack and never recursively descends the canonical tree. It emits +canonical preorder: the node, its attributes, then ordinary children in vector +order; an HTML template host then emits its one contents boundary followed by +contents children in vector order. The serializer therefore introduces no +native-call-stack limit below the canonical tree-unit guardrail. + +Lexical path validation is independent from the iterative traversal/framing +state machine. Framing rejects a +child before its parent, an attribute anywhere except immediately after its +owner, contents children before the boundary, ordinary template children after +the boundary, non-preorder sibling or ancestor transitions, duplicate or +skipped root/child/attribute indices, duplicate locations, a repeated contents +boundary for one host, and a template host whose boundary never appears. Each +nested template host has an independent frame and exactly one boundary. The +reader validates only serialized traversal framing: semantically implausible element names, +namespaces, and otherwise framed topology remain legal snapshot data. It does +not reconstruct a DOM or judge parser behavior. Attributes and the +`ObservedTree` wrapper are records but not production canonical tree-capacity +units. + +## Canonical patches: html5-dompatch-v3 + +Patch records use contiguous one-based operation ordinals and one exact shape +for each `ObservedPatchOperation` variant: + +```text +PATCH operation= kind= +``` + +`create-element` and `set-attributes` may be followed by contiguous zero-based: + +```text +PATCH_ATTRIBUTE operation= index= namespace= prefix= local-name= value= +``` + +Labels and operation order come only from AE13b3 canonical projection. Every +quoted node label has the exact form `node-`: +`node-1` is valid, while `node-0`, `node-01`, an empty label, an unquoted label, +or another prefix is malformed. Every operation ordinal and patch-attribute +index is validated as a canonical unsigned decimal before conversion; signs +and leading zeroes are forbidden. Readers validate operation framing and +attribute grouping, not live-DOM applicability, creation history, or semantic +patch ordering. + +The complete patch-record inventory and fixed field order is: + +```text +PATCH operation= kind=clear +PATCH operation= kind=create-document node= legacy-doctype= +PATCH operation= kind=create-document-type node= name= public-id= system-id= +PATCH operation= kind=create-element node= namespace= local-name= +PATCH operation= kind=create-template-contents host= contents= +PATCH operation= kind=create-text node= text= +PATCH operation= kind=create-comment node= data= +PATCH operation= kind=create-processing-instruction node= target= data= +PATCH operation= kind=append-child parent= child= +PATCH operation= kind=insert-before parent= child= before= +PATCH operation= kind=remove-node node= +PATCH operation= kind=set-attributes node= +PATCH operation= kind=set-text node= text= +PATCH operation= kind=append-text node= text= +``` + +Only `create-element` and `set-attributes` may own `PATCH_ATTRIBUTE` records. +An empty owned attribute group is legal. A patch attribute under any other +operation, before its operation, after another operation, with a different +operation ordinal, or with a noncontiguous local index is malformed framing. + +## Transitions and unsupported features + +Transitions use contiguous occurrences: + +```text +TRANSITION occurrence= token-kind= token-name= token-data= token-self-closing= mode-before= dispatch= mode-after= reprocessed= +``` + +Unsupported features use: + +```text +UNSUPPORTED_FEATURE occurrence= subsystem=tree-construction feature= context-token= context-mode= context-namespace= +``` + +Ordering and identities come only from AE13b4 production observations. Readers +do not reproduce dispatch selection or unsupported-feature eligibility. + +Closed token spellings are `doctype`, `start-tag`, `end-tag`, `character`, +`comment`, `processing-instruction`, and `eof`. Closed insertion modes are +`initial`, `before-html`, `before-head`, `in-head`, `after-head`, `in-body`, +`after-body`, `after-after-body`, `in-table`, `in-table-text`, `in-caption`, +`in-column-group`, `in-table-body`, `in-row`, `in-cell`, `in-template`, and +`text`. Transition dispatch is `html-insertion-mode:`, +`shared-template-rules`, `foreign-content`, or `text-mode`. Unsupported feature +spellings are `merge-attributes-into-existing-html-element`, +`merge-attributes-into-existing-body-element`, +`mark-frameset-not-ok-for-repeated-body-start-tag`, +`require-same-named-table-cell-in-scope-for-end-tag`, +`generate-implied-end-tags-and-check-current-node-before-closing-table-cell`, +and `generate-implied-end-tags-and-check-current-node-before-closing-caption`. + +Closed parse-error and implementation-diagnostic codes retain their explicit +family prefixes (`standard:`, `tokenizer-extension:`, `tree-construction:`, +`parser-resource-limit:`, `parser-guardrail:`, or the exact UTF-8 replacement +identity). Their codec matches are exhaustive over the typed production enums; +there is no generic string, description, `Debug`, or unknown-code branch. + +## Diagnostics + +Mismatch diagnostics name fixture ID, normalized repository-relative fixture +path, stable surface, transition delivery where applicable, expected sidecar, +format, first differing record and semantic location, expected/actual line, +nearby context, and both record counts. Surface and format are sealed together +in typed parsed/canonical snapshot variants rather than independently supplied. + +Each codec owns distinct parsed and canonical newtypes with private +constructors. Outer variants accept only the matching surface type, so a token +codec cannot construct a patch snapshot. Accessors derive surface and format +exhaustively from the variant. Snapshot storage retains one UTF-8 backing +string plus semantic locations and byte ranges for records; it does not retain +another owned copy of every record line. diff --git a/docs/html5/dompatch-contract.md b/docs/html5/dompatch-contract.md index fd22d6ef..5ac43a43 100644 --- a/docs/html5/dompatch-contract.md +++ b/docs/html5/dompatch-contract.md @@ -273,3 +273,17 @@ apply. Strict validation and Browser application are staged and atomic, and Browser calls the same `html::internal` validator instead of reimplementing payload validity. Deterministic patch formatting escapes target and data as separate fields. + +## AE13b5 canonical patch snapshots + +`html5-dompatch-v3` is the native parser-fixture patch format. Its writer +consumes only the AE13b3 `ObservedPatchStream`, including first-appearance +labels and canonical operation order. Runtime batch boundaries, `PatchKey`, +materialized IDs, and legacy patch strings are forbidden. + +The strict reader validates fixed operation shapes, canonical contiguous +one-based operation ordinals, canonical grouped zero-based attribute indices, +and every quoted label as `node-`. Zero or +leading-zero label numbers, arbitrary prefixes, unquoted labels, signs, and +leading-zero integer spellings are malformed. It does not validate live-DOM +applicability or recreate patch creation history. diff --git a/docs/html5/html5-core-v0.md b/docs/html5/html5-core-v0.md index d2d68f2d..5d83ac91 100644 --- a/docs/html5/html5-core-v0.md +++ b/docs/html5/html5-core-v0.md @@ -701,3 +701,19 @@ Any change to supported/unsupported status MUST update all of: 3. acceptance fixtures and/or WPT manifest policy as applicable. Without those updates, behavior changes are non-contractual and must not be treated as stabilized API/engine behavior. + +## AE13b5 Canonical Fixture Serialization Boundary + +Fixture v2 is the native serialized regression boundary for the AE13b1-b4 +observations. Test support executes the production tokenizer or document parser +once per planned whole Unicode-scalar delivery, validates authoritative states, +then writes explicit versioned token, diagnostic, mode, tree, patch, transition, +and unsupported-feature snapshots. No textual reader reconstructs tokenizer, +tree-construction, namespace, document-mode, patch-order, transition, or +unsupported-feature semantics. + +The native token format is `html5-token-v2`; quoted semantic names keep an +absent optional name distinct from the literal string `null`. V1 remains a +legacy compatibility format. Final invariants, chunk parity, fragments, +scripting, external adapters, blessing, rendering integration, and public APIs +remain outside AE13b5. diff --git a/docs/html5/invariants.md b/docs/html5/invariants.md index a1afe2d5..33e44d93 100644 --- a/docs/html5/invariants.md +++ b/docs/html5/invariants.md @@ -385,3 +385,50 @@ Table cell and AFE interaction: - DOM identity does not imply retained render identity. Selector indexing ignores the non-element; Layout records central PI suppression; Paint receives no PI artifact. + +## AE13b5 Snapshot and Fixture-Runner Invariants + +- A minimal format envelope selects the exact fixture-v1 or fixture-v2 strict + schema. The loader never probes schemas and never treats the envelope as a + permissive shared declaration. +- Fixture-v1 grammar and scalar failure identities retain their original + interpretation. Fixture-v2 structured identities cannot reinterpret them. +- A validated skipped fixture-v2 produces its exact `NotExecuted` + classification after metadata-only sidecar validation, without opening or + reading sidecar contents, planning deliveries, executing the parser, + serializing, comparing, or exposing canonical results. Non-skipped v2 + execution reads content only in the ordered sidecar phase. Fixture-v1 retains + its legacy validation-time full reads, including for skipped fixtures, and + its token runner's execution-time reread. +- Required unknown extensions are selected in ASCII lexicographic ID order. +- Every declared delivery is capability-checked. Only planned deliveries run, + and each runs once with one request containing the union of its surfaces. +- Every planned execution and requested state must be authoritative before any + serialization or comparison. Incomplete outcomes retain delivery, surface, + typed reason, retained count, and dropped count; retained prefixes never + serialize or compare. Unrequested incomplete capture is a distinct impossible + runner state. +- Snapshot surface and format are sealed by surface-specific parsed and + canonical newtypes with codec-private constructors. Writers consume only + `CanonicalParserResult`; readers validate grammar and local framing, not + parser algorithms. One backing string plus record ranges avoids duplicated + owned record text. +- Canonical tree writing and reading are iterative. The writer preserves + canonical preorder and template boundaries without native-stack recursion; + lexical paths accept any sequence of complete canonical `/child[n]` and + `/contents` segments, including repeated contents segments for nested + templates. The independent reader framing state enforces parent-before-child, + immediate attributes, contiguous local indices, and one ordinary/content + phase per template host without deciding whether the tree is browser-correct. +- Canonical patch labels are quoted `node-` values; + operation ordinals and attribute indices use canonical decimals before + conversion. +- Completed reports are all-or-nothing and v2 owns each delivery result once. + The first mismatch follows the fixed documented surface and + transition-delivery order and retains the exact failing delivery. +- One fixture-v2 failure spelling codec owns both declaration parsing and + diagnostic formatting for every nested parser-observation identity and + validated-runner invariant. +- Canonical tree guardrail accounting counts parser-created structural units + plus typed template contents. Attributes and the outer `ObservedTree` wrapper + do not consume tree units. diff --git a/docs/html5/parser-fixture-format-v1.md b/docs/html5/parser-fixture-format-v1.md index 849a4297..1f5dd545 100644 --- a/docs/html5/parser-fixture-format-v1.md +++ b/docs/html5/parser-fixture-format-v1.md @@ -12,6 +12,13 @@ format = "borrowser-html-parser-fixture-v1" Core fields are strict. Unknown fields at any core nesting level are malformed fixture errors. Future optional metadata belongs only under `extensions`. +AE13b5 freezes this complete grammar as a compatibility contract. Fixture v1 +continues to deserialize directly into its original strict schema after typed +format dispatch. Its scalar expected-failure spellings and interpretations are +unchanged. Native AE13 canonical fixtures use +`borrowser-html-parser-fixture-v2`; v1 declarations are never normalized into +broader v2 failure identities. + Minimal AE13a fixture: ```toml @@ -157,11 +164,18 @@ requested and completed with zero events. Recognized default sidecars that are present but undeclared are rejected. A declared sidecar must exist even when its execution surface belongs to a later -AE13 slice. The feature-gated AE13b3 execution API can capture canonical trees -and complete semantic patch histories in memory. Fixture-v1 still executes -only `tokens.txt` with `html5-token-v1`: tree and patch serializers, sidecars, -and corpus migration remain deliberately absent, so an active fixture -requesting either sidecar still fails with typed `UnsupportedExpectation`. +AE13 slice. As part of the frozen fixture-v1 compatibility boundary, declaration +validation fully reads every declared sidecar and reports an unreadable sidecar +as a `FixtureLoadError`, including for a skipped fixture. The legacy token +runner rereads `tokens.txt` during execution; AE13b5 does not optimize or alter +that behavior. Fixture-v2 alone owns metadata-only declaration validation and +the skipped zero-content-read guarantee. + +The feature-gated AE13b3 execution API can capture canonical trees and complete +semantic patch histories in memory. Fixture-v1 still executes only `tokens.txt` +with `html5-token-v1`: tree and patch serializers, sidecars, and corpus +migration remain deliberately absent, so an active fixture requesting either +sidecar still fails with typed `UnsupportedExpectation`. ## Dispositions and sources @@ -258,6 +272,24 @@ The compatibility reader requires exactly one `# format: html5-token-v1` header, valid `html5-token-v1` token lines, and a final `EOF`. Snapshot-format failures are distinct from fixture-TOML failures. +The exact accepted compatibility behavior is intentionally not tightened: + +- a line beginning exactly `# format:` is a format header; the value is + trimmed, the header may occur after token records or after EOF, and a second + header is rejected; +- other lines beginning `#` are comments and are ignored, including after EOF; +- physically empty lines are ignored, while whitespace-only lines remain + token content and are malformed; +- Rust `str::lines` compatibility accepts CRLF and does not treat a lone CR as + a separator; +- a final physical newline is optional; +- EOF is mandatory; semantic token content after EOF is rejected, while the + accepted comments, empty lines, and first format header may follow EOF; +- a UTF-8 BOM is not accepted as part of the header or token grammar. + +These rules belong only to `html5-token-v1`. The strict v2 lexer is not reused +for v1 unless complete acceptance compatibility is first proven. + Bare token names follow the production tokenizer and compatibility-writer grammar: they must be non-empty, and ASCII whitespace is rejected because it is structural syntax. Non-ASCII Unicode whitespace is not treated as a delimiter @@ -276,6 +308,6 @@ AE13b1 formalizes payload-safe in-memory implementation diagnostics before any implementation-diagnostic sidecar serializer is enabled. This does not change the fixture-v1 declaration, expectation surfaces, or serialized formats. AE13b2 likewise captures typed tree-construction diagnostics and the -production-selected scalar document mode in memory. It does not activate -`parse_errors`, `implementation_diagnostics`, or `document_mode` sidecars and -does not define canonical serializers for them. +production-selected scalar document mode in memory. AE13b5 activates those +surfaces only through fixture v2; fixture-v1 syntax and interpretation remain +unchanged. diff --git a/docs/html5/parser-fixture-format-v2.md b/docs/html5/parser-fixture-format-v2.md new file mode 100644 index 00000000..e4567b49 --- /dev/null +++ b/docs/html5/parser-fixture-format-v2.md @@ -0,0 +1,284 @@ +# Borrowser HTML Parser Fixture Format v2 + +## Identity and dispatch + +Native AE13 canonical fixtures use: + +```toml +format = "borrowser-html-parser-fixture-v2" +``` + +The loader first deserializes only a minimal `format` envelope. It then +deserializes the original complete TOML into the selected strict schema. +`FixtureFileV1` and `FixtureFileV2` both deny unknown fields. The envelope is a +selector, not a permissive common schema, and the loader never probes schemas +until one happens to deserialize. Unknown identifiers are +`UnsupportedFixtureFormat`. + +Fixture IDs are obtained through an exhaustive accessor over the typed v1/v2 +declaration, so duplicate and ASCII case-collision checks are version-neutral. +Fixture v1 remains governed by `parser-fixture-format-v1.md`; none of its scalar +failure identities acquire v2 meanings. + +## Difference from fixture v1 + +V2 activates the typed AE13b1-b4 canonical observation surfaces, strict +versioned snapshot codecs, document execution, delivery-specific transitions, +and structured expected failures. V1 retains its scalar expected-failure field +and legacy token-driver compatibility path. The small native AE13 corpus uses +v2; this does not migrate WPT, html5lib, or broad legacy golden corpora. + +All shared fixture fields retain their v1 syntax: source, exact input and hash, +target, declared deliveries, expectation paths, metadata, extensions, and +disposition status. Complete v2 structures remain `deny_unknown_fields`. + +## Structured expected failures + +V2 uses a real TOML subtable: + +```toml +[disposition] +status = "expected-failure" +reason = "Tracked parser invariant." +reference = { kind = "tracking-issue", value = "#123" } + +[disposition.failure] +kind = "parser-observation" +identity = "tokenizer-invariant" +code = "pending-text-range-invalid" +``` + +Failure kinds and fields are exact: + +- `snapshot-read`: requires `surface` only. +- `snapshot-format`: requires `surface` only. +- `parser-observation`: requires `identity` plus exactly the `code` or `site` + owned by that identity. +- `validated-runner-invariant`: requires `code` only. +- `expectation-mismatch`: requires `surface` only. +- `final-invariant`: requires `code` only. Execution remains unsupported until + AE13c. + +V1 scalar values such as `token-snapshot-read`, `token-snapshot-format`, and +`tokenizer-driver` are rejected by the v2 schema rather than reinterpreted. + +## Declared and planned deliveries + +```text +declared delivery = required fixture semantics +planned delivery = requires production execution +``` + +Every declared delivery is capability-checked in fixture declaration order, +including unused deliveries. The first unsupported declaration makes the +fixture unsupported. This check does not execute the parser. + +Only these deliveries are planned: + +- the reference delivery when an ordinary surface is expected; +- each delivery named by a transition expectation; +- one unioned execution when those roles select the same delivery. + +Unused supported deliveries do not execute. Planned deliveries execute in +validated declaration order and at most once. Each production request contains +the union of the surfaces required from that delivery. + +AE13b5 supports whole Unicode-scalar delivery. Raw bytes, byte delivery, +Unicode-scalar boundaries, fragment parsing, and scripting-enabled parsing are +typed unsupported semantics. This is not whole/chunked parity. + +## Skipped disposition + +After declaration, path, input, hash, disposition, and cross-field validation, +a sealed `skipped` fixture short-circuits: + +```text +validated fixture -> skipped-disposition check -> ordinary precedence +``` + +The result is `NotExecuted` with the exact validated skip classification. No +sidecar content is read, no capability plan is built, no parser runs, and no +canonical result is exposed. Disposition evaluation still compares the exact +declared classification. Expected-failure and expected-unsupported fixtures do +not use this short-circuit; they execute so the expected outcome can be +verified. + +Declaration validation checks each sidecar using metadata only: normalized +bundle-relative containment, every path component's symlink status, existence, +regular-file type, case/path collisions, declaration consistency, and orphan +rules. It does not open the sidecar, inspect its length, or read and discard its +bytes. Complete sidecar content is read only in phase 5 below, after the skipped +short-circuit and unsupported-semantics precedence. The injected private file +access boundary distinguishes metadata inspection from content reads so tests +prove skipped fixtures perform zero sidecar-content reads and active fixtures +read each declared expectation once in fixed order. + +## Authoritative precedence + +For non-skipped v2 fixtures: + +1. Start from the completely validated fixture. +2. Reject the first required unknown extension in ASCII lexicographic ID order. +3. Reject the first unsupported expectation surface; final invariants are + unsupported in AE13b5. +4. Reject unsupported target, input, scripting, and declared-delivery + semantics. Delivery selection follows declaration order. +5. Read and strictly parse all supported sidecars in surface order. +6. Build the deterministic execution plan. +7. Execute planned deliveries in declaration order. +8. Validate every requested `ObservationState` after every execution succeeds. +9. Reject `Incomplete`, requested `NotRequested`, unexpected `NotApplicable`, + and prohibited unrequested capture before serialization. +10. Serialize all requested typed canonical surfaces. +11. Compare in the order below. +12. Return a completed report only after every delivery and expectation passes. + +Earlier phases win. Thus unsupported input masks malformed sidecars, parser +failure masks mismatch, and incomplete capture masks every mismatch. + +The following simultaneous failures therefore resolve exactly as follows: + +- unknown required extension plus malformed snapshot: unknown required + extension; +- final-invariant expectation plus malformed unrelated snapshot: unsupported + final-invariant expectation; +- unsupported raw-byte input plus malformed snapshot: raw-byte input; +- parser execution failure plus snapshot mismatch: parser-observation failure; +- incomplete reference delivery plus transition-delivery mismatch: incomplete + observation; +- multiple mismatching ordinary surfaces: the first surface in comparison + order; +- multiple mismatching transition expectations: the first transition in + fixture declaration order. + +Required unknown extensions originate from a map and are sorted by ASCII ID; +TOML source ordering is not semantic. + +## Comparison order and results + +The first mismatch is returned in this exact order: + +1. tokens; +2. parse errors; +3. implementation diagnostics; +4. document mode; +5. canonical tree; +6. canonical patches; +7. transitions in transition-expectation declaration order; +8. unsupported features. + +Transitions precede unsupported features because they describe the dispatch +attempt leading to an unsupported-feature fallback. + +Completed reports are all-or-nothing and contain every planned delivery result +in declaration order. A v2 report owns each `CanonicalParserResult` exactly +once, inside its delivery report; the compatibility `result()` accessor borrows +the reference-delivery entry. A failure can retain small private context for +diagnostics, but it cannot expose a completed partial report or usable partial +canonical result. Parser failures name the failing delivery but do not retain +earlier successful delivery results or a canonical result for the failing +delivery. Incomplete states name their delivery and surface. A mismatch retains +the exact failing delivery, surface, and textual difference, not a cloned +canonical result; in particular a transition mismatch never attaches the +reference-delivery result. + +## Observation guardrails + +The runner passes one private `FixtureObservationGuardrails` policy through +request construction. Production fixture runs use these fixed defensive +capacities: + +| Surface | Capacity | Policy reason | +|---|---:|---| +| tokens | 65,536 | bounded focused-fixture event capture | +| parse errors | 65,536 | bounded dense recovery capture | +| implementation diagnostics | 65,536 | bounded diagnostic capture | +| unsupported features | 65,536 | bounded unsupported event capture | +| canonical tree units | 131,072 | bounded structural projection | +| transitions | 262,144 | dispatch attempts may outnumber tokens | +| patch operations | 262,144 | construction operations may outnumber nodes | + +Canonical tree units are parser-created structural units: document, document +type, element or HTML template host, text, comment, processing instruction, and +typed template-contents boundary. Attributes and the outer `ObservedTree` +wrapper do not consume units. + +These are defensive harness policy, not production parser limits, fixture input +size limits, or byte budgets. They cannot be configured by TOML or sidecars and +never derive from expected record counts or content. Derived arithmetic is +checked. Tests inject smaller private policies through the same request builder +to prove exact-capacity capture and capacity-plus-one incompleteness for every +retained collection. Capacity overflow remains authoritative +`IncompleteObservation`; its retained prefix is never serialized or compared, +even if that prefix equals the expected sidecar. + +An incomplete outcome retains the delivery, expectation surface, complete +typed `IncompleteObservationReason`, retained count, and dropped count. A +requested incomplete state is this typed outcome. Requested `NotRequested` or +unexpected `NotApplicable`, and unrequested `Captured` or `Incomplete`, are +distinct validated-runner invariants. Diagnostics spell the delivery, surface, +reason, retained count, and dropped count without Rust `Debug`. + +## Stable failure identities + +Canonical execution failures are one of: + +- `SnapshotRead(surface)`; +- `SnapshotFormat(surface)`; +- `ParserObservation(closed identity)`; +- `ValidatedFixtureInvariant(stable code)`. + +The HTML subsystem maps every typed production execution error to a closed, +feature-gated identity. One authoritative fixture-v2 spelling codec both +parses structured declarations and formats diagnostic/disposition identities; +there are no independent string-to-enum and enum-to-string tables. Test support +does not classify messages or Rust `Debug`. Stable runner-invariant codes name impossible post-validation states; +ordinary fixture mistakes remain validation, unsupported, snapshot-format, or +mismatch outcomes. + +The exact parser-observation identity spellings are: + +- `parser-fatal-engine-invariant` (no `code` or `site`); +- `parser-fatal-resource-exhaustion` (`site` required); +- `parser-invariant` (no `code` or `site`); +- `tokenizer-invariant` (`code` required); +- `token-canonicalization-invariant` (no `code` or `site`); +- `tree-transition-token-canonicalization-invariant` (no `code` or `site`); +- `unsupported-feature-observation-invariant` (`code` required); +- `observation-recorder-missing` (no `code` or `site`); +- `patch-history-capture-missing` (no `code` or `site`); +- `observation-invariant` (`code` required); +- `observation-resource-exhaustion` (`site` required). + +Parser-fatal sites are `known-tag-atom-storage`, +`known-tag-lookup-storage`, `template-child-storage`, and +`patch-history-observation-storage`. Observation sites are +`canonical-tree-projection`, `canonical-patch-projection`, and +`snapshot-label-storage`. Codes are closed exhaustive mappings of the nested +typed tokenizer, unsupported-feature-observation, and observation-invariant +enums; an identity with a missing, extra, or unknown owned field is invalid +fixture-v2 disposition syntax. + +The exact validated-runner invariant codes are: + +- `planned-reference-delivery-missing`; +- `planned-delivery-missing`; +- `duplicate-planned-delivery`; +- `requested-surface-unexpectedly-not-requested`; +- `requested-surface-unexpectedly-not-applicable`; +- `unrequested-surface-unexpectedly-captured`; +- `unrequested-surface-unexpectedly-incomplete`; +- `snapshot-variant-surface-contradiction`; +- `canonical-serializer-surface-contradiction`; +- `comparison-surface-contradiction`; +- `missing-executed-delivery-result`; +- `duplicate-executed-delivery-result`; +- `duplicate-expectation-identity`. + +These codes are reserved for impossible runner states. They cannot be used to +reclassify an ordinary validation error, unsupported semantic, malformed +snapshot, incomplete capture, or mismatch. + +Final-invariant execution, parity, fragment parsing, external adapters, +snapshot blessing, scripting, rendering, and public parser/DOM APIs are outside +fixture v2 AE13b5.