From df6b23b2342a26f324b34c7e303b83042d45aa29 Mon Sep 17 00:00:00 2001
From: Charles Lowell
Date: Fri, 30 Oct 2026 13:23:07 -0500
Subject: [PATCH 1/2] Add reusable DOM compatibility suite
---
knip.config.ts | 7 +
oxlint.config.ts | 14 +
package.json | 4 +-
packages/dom/LICENSE | 21 +
packages/dom/README.md | 82 +++
packages/dom/docs/references.md | 16 +
packages/dom/docs/wpt-css-forms.md | 34 +
packages/dom/docs/wpt-ranges-parsing.md | 50 ++
packages/dom/docs/wpt-shadow-input.md | 36 ++
packages/dom/docs/wpt-state-styles.md | 31 +
packages/dom/knip.json | 5 +
packages/dom/package.json | 37 ++
.../src/compatibility/character-data/data.ts | 45 ++
.../compatibility/character-data/editing.ts | 48 ++
.../character-data/split-text.ts | 42 ++
.../character-data/string-storage.ts | 27 +
packages/dom/src/compatibility/context.ts | 60 ++
.../compatibility/contracts/collections.ts | 64 ++
.../src/compatibility/contracts/elements.ts | 214 +++++++
.../dom/src/compatibility/contracts/events.ts | 174 +++++
.../dom/src/compatibility/contracts/nodes.ts | 198 ++++++
.../compatibility/contracts/observation.ts | 41 ++
.../src/compatibility/contracts/selection.ts | 75 +++
.../dom/src/compatibility/contracts/shadow.ts | 47 ++
.../compatibility/custom-elements/adoption.ts | 48 ++
.../custom-elements/connection.ts | 64 ++
.../custom-elements/construction.ts | 52 ++
.../custom-elements/exception-reporting.ts | 43 ++
.../custom-elements/form-association.ts | 265 ++++++++
.../custom-elements/observed-attributes.ts | 69 ++
.../custom-elements/reaction-ordering.ts | 45 ++
.../custom-elements/reentrancy.ts | 33 +
.../custom-elements/registration.ts | 57 ++
.../custom-elements/upgrading.ts | 65 ++
.../compatibility/document/basic-creation.ts | 25 +
.../src/compatibility/document/creation.ts | 45 ++
.../src/compatibility/document/import-node.ts | 41 ++
.../dom/src/compatibility/document/lookup.ts | 47 ++
.../dom/src/compatibility/document/parsing.ts | 51 ++
.../document/xml-serialization.ts | 162 +++++
.../dom/src/compatibility/document/xml.ts | 43 ++
.../compatibility/element/attribute-nodes.ts | 59 ++
.../src/compatibility/element/attributes.ts | 62 ++
.../compatibility/element/child-elements.ts | 28 +
.../src/compatibility/element/class-list.ts | 56 ++
.../dom/src/compatibility/element/dataset.ts | 30 +
.../src/compatibility/element/namespaces.ts | 43 ++
.../src/compatibility/element/reflection.ts | 41 ++
.../src/compatibility/element/selectors.ts | 40 ++
.../elements/button/activation.ts | 43 ++
.../elements/button/attributes.ts | 37 ++
.../elements/button/form-association.ts | 54 ++
.../src/compatibility/elements/datalist.ts | 38 ++
.../elements/details/exclusive-groups.ts | 45 ++
.../compatibility/elements/details/open.ts | 32 +
.../elements/details/toggle-events.ts | 51 ++
.../compatibility/elements/dialog/close.ts | 58 ++
.../compatibility/elements/dialog/focus.ts | 43 ++
.../elements/dialog/form-submission.ts | 49 ++
.../compatibility/elements/dialog/modal.ts | 42 ++
.../elements/dialog/request-close.ts | 46 ++
.../src/compatibility/elements/dialog/show.ts | 32 +
.../elements/fieldset/controls.ts | 37 ++
.../elements/fieldset/disabled.ts | 46 ++
.../compatibility/elements/form/controls.ts | 36 ++
.../elements/form/data-events.ts | 121 ++++
.../compatibility/elements/form/form-data.ts | 74 +++
.../elements/form/named-controls.ts | 53 ++
.../src/compatibility/elements/form/reset.ts | 131 ++++
.../compatibility/elements/form/submission.ts | 71 +++
.../compatibility/elements/form/validation.ts | 46 ++
.../elements/input/activation.ts | 51 ++
.../elements/input/checkedness.ts | 40 ++
.../compatibility/elements/input/cloning.ts | 51 ++
.../elements/input/color-value.ts | 21 +
.../elements/input/date-value.ts | 44 ++
.../elements/input/form-association.ts | 42 ++
.../compatibility/elements/input/labels.ts | 59 ++
.../elements/input/numeric-value.ts | 39 ++
.../elements/input/radio-groups.ts | 47 ++
.../elements/input/range-text.ts | 33 +
.../elements/input/sanitization.ts | 47 ++
.../compatibility/elements/input/selection.ts | 64 ++
.../compatibility/elements/input/stepping.ts | 40 ++
.../elements/input/validation.ts | 54 ++
.../src/compatibility/elements/input/value.ts | 52 ++
.../elements/label/activation.ts | 51 ++
.../elements/label/association.ts | 52 ++
.../dom/src/compatibility/elements/legend.ts | 32 +
.../dom/src/compatibility/elements/meter.ts | 42 ++
.../src/compatibility/elements/optgroup.ts | 33 +
.../elements/option/attributes.ts | 36 ++
.../elements/option/selectedness.ts | 54 ++
.../src/compatibility/elements/option/text.ts | 35 +
.../compatibility/elements/output/html-for.ts | 34 +
.../compatibility/elements/output/reset.ts | 34 +
.../compatibility/elements/output/value.ts | 34 +
.../src/compatibility/elements/progress.ts | 41 ++
.../compatibility/elements/select/cloning.ts | 45 ++
.../elements/select/indexed-options.ts | 57 ++
.../compatibility/elements/select/mutation.ts | 45 ++
.../compatibility/elements/select/options.ts | 42 ++
.../elements/select/selection.ts | 47 ++
.../elements/select/validation.ts | 41 ++
.../compatibility/elements/selectedcontent.ts | 29 +
.../elements/summary/activation.ts | 56 ++
.../elements/textarea/cloning.ts | 39 ++
.../elements/textarea/form-association.ts | 48 ++
.../compatibility/elements/textarea/labels.ts | 58 ++
.../elements/textarea/range-text.ts | 50 ++
.../elements/textarea/selection.ts | 40 ++
.../elements/textarea/validation.ts | 49 ++
.../compatibility/elements/textarea/value.ts | 38 ++
.../src/compatibility/events/data/custom.ts | 52 ++
.../src/compatibility/events/data/focus.ts | 44 ++
.../src/compatibility/events/data/input.ts | 55 ++
.../src/compatibility/events/data/keyboard.ts | 84 +++
.../src/compatibility/events/data/mouse.ts | 75 +++
.../compatibility/events/dispatch/bubbling.ts | 60 ++
.../events/dispatch/cancellation.ts | 56 ++
.../compatibility/events/dispatch/capture.ts | 92 +++
.../events/dispatch/path-snapshot.ts | 66 ++
.../events/dispatch/reentrancy.ts | 53 ++
.../compatibility/events/dispatch/reuse.ts | 46 ++
.../dispatch/stop-immediate-propagation.ts | 60 ++
.../events/dispatch/stop-propagation.ts | 127 ++++
.../compatibility/events/dispatch/target.ts | 71 +++
.../events/listeners/abort-signal.ts | 85 +++
.../events/listeners/exception-reporting.ts | 45 ++
.../compatibility/events/listeners/objects.ts | 66 ++
.../compatibility/events/listeners/once.ts | 66 ++
.../compatibility/events/listeners/passive.ts | 75 +++
.../events/listeners/registration.ts | 65 ++
.../events/listeners/snapshots.ts | 102 +++
packages/dom/src/compatibility/features.ts | 394 ++++++++++++
packages/dom/src/compatibility/fixture.ts | 93 +++
.../src/compatibility/focus/eligibility.ts | 50 ++
packages/dom/src/compatibility/focus/inert.ts | 131 ++++
.../dom/src/compatibility/focus/reentrancy.ts | 45 ++
.../dom/src/compatibility/focus/removal.ts | 37 ++
.../src/compatibility/focus/transitions.ts | 58 ++
packages/dom/src/compatibility/globals.ts | 61 ++
.../dom/src/compatibility/input-driver.ts | 86 +++
.../input/keyboard/activation.ts | 91 +++
.../compatibility/input/keyboard/delivery.ts | 112 ++++
.../compatibility/input/keyboard/editing.ts | 217 +++++++
.../input/keyboard/focus-styles.ts | 70 ++
.../input/keyboard/focus-visible.ts | 40 ++
.../input/keyboard/implicit-submission.ts | 130 ++++
.../compatibility/input/keyboard/modifiers.ts | 80 +++
.../input/keyboard/multiline-editing.ts | 173 +++++
.../input/keyboard/navigation.ts | 85 +++
.../input/keyboard/number-stepping.ts | 79 +++
.../compatibility/input/keyboard/shadow.ts | 169 +++++
.../input/keyboard/trusted-events.ts | 37 ++
.../compatibility/input/mouse/activation.ts | 160 +++++
.../src/compatibility/input/mouse/buttons.ts | 144 +++++
.../src/compatibility/input/mouse/delivery.ts | 100 +++
.../input/mouse/focus-visible.ts | 58 ++
.../src/compatibility/input/mouse/focus.ts | 86 +++
.../src/compatibility/input/mouse/hover.ts | 114 ++++
.../src/compatibility/input/mouse/shadow.ts | 147 +++++
.../compatibility/input/mouse/state-styles.ts | 131 ++++
.../input/mouse/trusted-events.ts | 48 ++
.../mutation-observer/detached.ts | 55 ++
.../mutation-observer/disconnect.ts | 53 ++
.../mutation-observer/filtering.ts | 57 ++
.../mutation-observer/microtask-delivery.ts | 62 ++
.../mutation-observer/old-value.ts | 52 ++
.../mutation-observer/records/attributes.ts | 69 ++
.../records/character-data.ts | 60 ++
.../mutation-observer/records/child-list.ts | 72 +++
.../mutation-observer/reentrancy.ts | 59 ++
.../mutation-observer/registration.ts | 88 +++
.../mutation-observer/subtree.ts | 51 ++
.../mutation-observer/take-records.ts | 54 ++
.../dom/src/compatibility/node/adoption.ts | 51 ++
packages/dom/src/compatibility/node/append.ts | 57 ++
.../src/compatibility/node/basic-append.ts | 25 +
.../dom/src/compatibility/node/cloning.ts | 62 ++
.../dom/src/compatibility/node/comparison.ts | 44 ++
.../src/compatibility/node/connectivity.ts | 35 +
.../dom/src/compatibility/node/fragments.ts | 66 ++
.../src/compatibility/node/insert-before.ts | 58 ++
.../compatibility/node/live-collections.ts | 58 ++
.../src/compatibility/node/normalization.ts | 33 +
.../src/compatibility/node/relationships.ts | 43 ++
packages/dom/src/compatibility/node/remove.ts | 40 ++
.../dom/src/compatibility/node/replace.ts | 63 ++
.../src/compatibility/node/text-content.ts | 50 ++
.../compatibility/node/variadic-mutation.ts | 56 ++
.../src/compatibility/parsing/contextual.ts | 33 +
.../src/compatibility/parsing/fragments.ts | 37 ++
.../compatibility/parsing/markup-mutation.ts | 40 ++
.../src/compatibility/parsing/namespaces.ts | 26 +
.../dom/src/compatibility/parsing/recovery.ts | 97 +++
.../compatibility/parsing/serialization.ts | 38 ++
packages/dom/src/compatibility/registry.ts | 450 +++++++++++++
.../selection/document/change-events.ts | 124 ++++
.../selection/document/connectivity.ts | 39 ++
.../selection/document/direction.ts | 51 ++
.../selection/document/live-updates.ts | 47 ++
.../selection/document/mutation.ts | 167 +++++
.../selection/document/range-management.ts | 51 ++
.../selection/range/boundaries.ts | 60 ++
.../selection/range/clone-contents.ts | 51 ++
.../selection/range/comparison.ts | 45 ++
.../selection/range/delete-contents.ts | 45 ++
.../selection/range/extract-contents.ts | 40 ++
.../selection/range/live-boundaries.ts | 76 +++
.../compatibility/selection/range/mutation.ts | 205 ++++++
.../selection/range/stringification.ts | 30 +
.../src/compatibility/shadow/declarative.ts | 25 +
.../shadow/events/composed-path.ts | 142 +++++
.../shadow/events/related-target.ts | 29 +
.../shadow/events/retargeting.ts | 45 ++
.../compatibility/shadow/focus/delegation.ts | 18 +
.../compatibility/shadow/focus/retargeting.ts | 23 +
.../compatibility/shadow/roots/adoption.ts | 36 ++
.../compatibility/shadow/roots/attachment.ts | 39 ++
.../src/compatibility/shadow/roots/cloning.ts | 43 ++
.../shadow/roots/connectivity.ts | 46 ++
.../shadow/roots/host-eligibility.ts | 25 +
.../src/compatibility/shadow/serialization.ts | 31 +
.../compatibility/shadow/slots/fallback.ts | 40 ++
.../src/compatibility/shadow/slots/manual.ts | 45 ++
.../src/compatibility/shadow/slots/named.ts | 45 ++
.../shadow/slots/observer-ordering.ts | 107 ++++
.../compatibility/shadow/slots/slotchange.ts | 49 ++
.../dom/src/compatibility/style-fixture.ts | 21 +
.../style/attribute-synchronization.ts | 41 ++
.../dom/src/compatibility/style/cascade.ts | 89 +++
.../dom/src/compatibility/style/computed.ts | 69 ++
.../style/constructed-stylesheets.ts | 149 +++++
.../compatibility/style/control-selectors.ts | 162 +++++
.../compatibility/style/custom-properties.ts | 27 +
.../src/compatibility/style/focus-state.ts | 131 ++++
.../dom/src/compatibility/style/inline.ts | 47 ++
.../dom/src/compatibility/style/layers.ts | 58 ++
.../style/mutation-observation.ts | 101 +++
.../dom/src/compatibility/style/parsing.ts | 36 ++
.../style/relational-selectors.ts | 87 +++
.../src/compatibility/style/stylesheets.ts | 49 ++
.../src/compatibility/templates/cloning.ts | 40 ++
.../src/compatibility/templates/content.ts | 29 +
.../src/compatibility/templates/parsing.ts | 28 +
.../compatibility/test-dom-compatibility.ts | 78 +++
.../compatibility/traversal/node-iterator.ts | 36 ++
.../compatibility/traversal/tree-walker.ts | 217 +++++++
packages/dom/src/compatibility/unique-name.ts | 5 +
packages/dom/src/index.ts | 24 +
.../dom/test/browser/compatibility-types.mts | 43 ++
packages/dom/test/browser/compatibility.html | 8 +
packages/dom/test/browser/compatibility.mjs | 189 ++++++
packages/dom/test/browser/features.ts | 35 +
packages/dom/test/browser/input-driver.mts | 62 ++
.../dom/test/browser/run-compatibility.mjs | 138 ++++
packages/dom/test/browser/tsconfig.json | 16 +
packages/dom/test/browser/vite.config.mjs | 15 +
.../dom/test/compatibility-harness.test.ts | 600 ++++++++++++++++++
packages/dom/test/wpt/HTML5LIB-LICENSE.txt | 21 +
packages/dom/test/wpt/LICENSE.txt | 11 +
packages/dom/test/wpt/README.md | 13 +
packages/dom/tsconfig.json | 20 +
packages/ui/package.json | 5 +-
packages/ui/src/dom.compatibility.test.ts | 11 +
packages/ui/test/dom-compatibility.md | 34 +
packages/ui/test/dom-features.ts | 306 +++++++++
pnpm-lock.yaml | 421 +++++-------
269 files changed, 17819 insertions(+), 254 deletions(-)
create mode 100644 knip.config.ts
create mode 100644 packages/dom/LICENSE
create mode 100644 packages/dom/README.md
create mode 100644 packages/dom/docs/references.md
create mode 100644 packages/dom/docs/wpt-css-forms.md
create mode 100644 packages/dom/docs/wpt-ranges-parsing.md
create mode 100644 packages/dom/docs/wpt-shadow-input.md
create mode 100644 packages/dom/docs/wpt-state-styles.md
create mode 100644 packages/dom/knip.json
create mode 100644 packages/dom/package.json
create mode 100644 packages/dom/src/compatibility/character-data/data.ts
create mode 100644 packages/dom/src/compatibility/character-data/editing.ts
create mode 100644 packages/dom/src/compatibility/character-data/split-text.ts
create mode 100644 packages/dom/src/compatibility/character-data/string-storage.ts
create mode 100644 packages/dom/src/compatibility/context.ts
create mode 100644 packages/dom/src/compatibility/contracts/collections.ts
create mode 100644 packages/dom/src/compatibility/contracts/elements.ts
create mode 100644 packages/dom/src/compatibility/contracts/events.ts
create mode 100644 packages/dom/src/compatibility/contracts/nodes.ts
create mode 100644 packages/dom/src/compatibility/contracts/observation.ts
create mode 100644 packages/dom/src/compatibility/contracts/selection.ts
create mode 100644 packages/dom/src/compatibility/contracts/shadow.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/adoption.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/connection.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/construction.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/exception-reporting.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/form-association.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/observed-attributes.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/reaction-ordering.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/reentrancy.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/registration.ts
create mode 100644 packages/dom/src/compatibility/custom-elements/upgrading.ts
create mode 100644 packages/dom/src/compatibility/document/basic-creation.ts
create mode 100644 packages/dom/src/compatibility/document/creation.ts
create mode 100644 packages/dom/src/compatibility/document/import-node.ts
create mode 100644 packages/dom/src/compatibility/document/lookup.ts
create mode 100644 packages/dom/src/compatibility/document/parsing.ts
create mode 100644 packages/dom/src/compatibility/document/xml-serialization.ts
create mode 100644 packages/dom/src/compatibility/document/xml.ts
create mode 100644 packages/dom/src/compatibility/element/attribute-nodes.ts
create mode 100644 packages/dom/src/compatibility/element/attributes.ts
create mode 100644 packages/dom/src/compatibility/element/child-elements.ts
create mode 100644 packages/dom/src/compatibility/element/class-list.ts
create mode 100644 packages/dom/src/compatibility/element/dataset.ts
create mode 100644 packages/dom/src/compatibility/element/namespaces.ts
create mode 100644 packages/dom/src/compatibility/element/reflection.ts
create mode 100644 packages/dom/src/compatibility/element/selectors.ts
create mode 100644 packages/dom/src/compatibility/elements/button/activation.ts
create mode 100644 packages/dom/src/compatibility/elements/button/attributes.ts
create mode 100644 packages/dom/src/compatibility/elements/button/form-association.ts
create mode 100644 packages/dom/src/compatibility/elements/datalist.ts
create mode 100644 packages/dom/src/compatibility/elements/details/exclusive-groups.ts
create mode 100644 packages/dom/src/compatibility/elements/details/open.ts
create mode 100644 packages/dom/src/compatibility/elements/details/toggle-events.ts
create mode 100644 packages/dom/src/compatibility/elements/dialog/close.ts
create mode 100644 packages/dom/src/compatibility/elements/dialog/focus.ts
create mode 100644 packages/dom/src/compatibility/elements/dialog/form-submission.ts
create mode 100644 packages/dom/src/compatibility/elements/dialog/modal.ts
create mode 100644 packages/dom/src/compatibility/elements/dialog/request-close.ts
create mode 100644 packages/dom/src/compatibility/elements/dialog/show.ts
create mode 100644 packages/dom/src/compatibility/elements/fieldset/controls.ts
create mode 100644 packages/dom/src/compatibility/elements/fieldset/disabled.ts
create mode 100644 packages/dom/src/compatibility/elements/form/controls.ts
create mode 100644 packages/dom/src/compatibility/elements/form/data-events.ts
create mode 100644 packages/dom/src/compatibility/elements/form/form-data.ts
create mode 100644 packages/dom/src/compatibility/elements/form/named-controls.ts
create mode 100644 packages/dom/src/compatibility/elements/form/reset.ts
create mode 100644 packages/dom/src/compatibility/elements/form/submission.ts
create mode 100644 packages/dom/src/compatibility/elements/form/validation.ts
create mode 100644 packages/dom/src/compatibility/elements/input/activation.ts
create mode 100644 packages/dom/src/compatibility/elements/input/checkedness.ts
create mode 100644 packages/dom/src/compatibility/elements/input/cloning.ts
create mode 100644 packages/dom/src/compatibility/elements/input/color-value.ts
create mode 100644 packages/dom/src/compatibility/elements/input/date-value.ts
create mode 100644 packages/dom/src/compatibility/elements/input/form-association.ts
create mode 100644 packages/dom/src/compatibility/elements/input/labels.ts
create mode 100644 packages/dom/src/compatibility/elements/input/numeric-value.ts
create mode 100644 packages/dom/src/compatibility/elements/input/radio-groups.ts
create mode 100644 packages/dom/src/compatibility/elements/input/range-text.ts
create mode 100644 packages/dom/src/compatibility/elements/input/sanitization.ts
create mode 100644 packages/dom/src/compatibility/elements/input/selection.ts
create mode 100644 packages/dom/src/compatibility/elements/input/stepping.ts
create mode 100644 packages/dom/src/compatibility/elements/input/validation.ts
create mode 100644 packages/dom/src/compatibility/elements/input/value.ts
create mode 100644 packages/dom/src/compatibility/elements/label/activation.ts
create mode 100644 packages/dom/src/compatibility/elements/label/association.ts
create mode 100644 packages/dom/src/compatibility/elements/legend.ts
create mode 100644 packages/dom/src/compatibility/elements/meter.ts
create mode 100644 packages/dom/src/compatibility/elements/optgroup.ts
create mode 100644 packages/dom/src/compatibility/elements/option/attributes.ts
create mode 100644 packages/dom/src/compatibility/elements/option/selectedness.ts
create mode 100644 packages/dom/src/compatibility/elements/option/text.ts
create mode 100644 packages/dom/src/compatibility/elements/output/html-for.ts
create mode 100644 packages/dom/src/compatibility/elements/output/reset.ts
create mode 100644 packages/dom/src/compatibility/elements/output/value.ts
create mode 100644 packages/dom/src/compatibility/elements/progress.ts
create mode 100644 packages/dom/src/compatibility/elements/select/cloning.ts
create mode 100644 packages/dom/src/compatibility/elements/select/indexed-options.ts
create mode 100644 packages/dom/src/compatibility/elements/select/mutation.ts
create mode 100644 packages/dom/src/compatibility/elements/select/options.ts
create mode 100644 packages/dom/src/compatibility/elements/select/selection.ts
create mode 100644 packages/dom/src/compatibility/elements/select/validation.ts
create mode 100644 packages/dom/src/compatibility/elements/selectedcontent.ts
create mode 100644 packages/dom/src/compatibility/elements/summary/activation.ts
create mode 100644 packages/dom/src/compatibility/elements/textarea/cloning.ts
create mode 100644 packages/dom/src/compatibility/elements/textarea/form-association.ts
create mode 100644 packages/dom/src/compatibility/elements/textarea/labels.ts
create mode 100644 packages/dom/src/compatibility/elements/textarea/range-text.ts
create mode 100644 packages/dom/src/compatibility/elements/textarea/selection.ts
create mode 100644 packages/dom/src/compatibility/elements/textarea/validation.ts
create mode 100644 packages/dom/src/compatibility/elements/textarea/value.ts
create mode 100644 packages/dom/src/compatibility/events/data/custom.ts
create mode 100644 packages/dom/src/compatibility/events/data/focus.ts
create mode 100644 packages/dom/src/compatibility/events/data/input.ts
create mode 100644 packages/dom/src/compatibility/events/data/keyboard.ts
create mode 100644 packages/dom/src/compatibility/events/data/mouse.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/bubbling.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/cancellation.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/capture.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/path-snapshot.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/reentrancy.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/reuse.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/stop-immediate-propagation.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/stop-propagation.ts
create mode 100644 packages/dom/src/compatibility/events/dispatch/target.ts
create mode 100644 packages/dom/src/compatibility/events/listeners/abort-signal.ts
create mode 100644 packages/dom/src/compatibility/events/listeners/exception-reporting.ts
create mode 100644 packages/dom/src/compatibility/events/listeners/objects.ts
create mode 100644 packages/dom/src/compatibility/events/listeners/once.ts
create mode 100644 packages/dom/src/compatibility/events/listeners/passive.ts
create mode 100644 packages/dom/src/compatibility/events/listeners/registration.ts
create mode 100644 packages/dom/src/compatibility/events/listeners/snapshots.ts
create mode 100644 packages/dom/src/compatibility/features.ts
create mode 100644 packages/dom/src/compatibility/fixture.ts
create mode 100644 packages/dom/src/compatibility/focus/eligibility.ts
create mode 100644 packages/dom/src/compatibility/focus/inert.ts
create mode 100644 packages/dom/src/compatibility/focus/reentrancy.ts
create mode 100644 packages/dom/src/compatibility/focus/removal.ts
create mode 100644 packages/dom/src/compatibility/focus/transitions.ts
create mode 100644 packages/dom/src/compatibility/globals.ts
create mode 100644 packages/dom/src/compatibility/input-driver.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/activation.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/delivery.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/editing.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/focus-styles.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/focus-visible.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/implicit-submission.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/modifiers.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/multiline-editing.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/navigation.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/number-stepping.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/shadow.ts
create mode 100644 packages/dom/src/compatibility/input/keyboard/trusted-events.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/activation.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/buttons.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/delivery.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/focus-visible.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/focus.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/hover.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/shadow.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/state-styles.ts
create mode 100644 packages/dom/src/compatibility/input/mouse/trusted-events.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/detached.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/disconnect.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/filtering.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/microtask-delivery.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/old-value.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/records/attributes.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/records/character-data.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/records/child-list.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/reentrancy.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/registration.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/subtree.ts
create mode 100644 packages/dom/src/compatibility/mutation-observer/take-records.ts
create mode 100644 packages/dom/src/compatibility/node/adoption.ts
create mode 100644 packages/dom/src/compatibility/node/append.ts
create mode 100644 packages/dom/src/compatibility/node/basic-append.ts
create mode 100644 packages/dom/src/compatibility/node/cloning.ts
create mode 100644 packages/dom/src/compatibility/node/comparison.ts
create mode 100644 packages/dom/src/compatibility/node/connectivity.ts
create mode 100644 packages/dom/src/compatibility/node/fragments.ts
create mode 100644 packages/dom/src/compatibility/node/insert-before.ts
create mode 100644 packages/dom/src/compatibility/node/live-collections.ts
create mode 100644 packages/dom/src/compatibility/node/normalization.ts
create mode 100644 packages/dom/src/compatibility/node/relationships.ts
create mode 100644 packages/dom/src/compatibility/node/remove.ts
create mode 100644 packages/dom/src/compatibility/node/replace.ts
create mode 100644 packages/dom/src/compatibility/node/text-content.ts
create mode 100644 packages/dom/src/compatibility/node/variadic-mutation.ts
create mode 100644 packages/dom/src/compatibility/parsing/contextual.ts
create mode 100644 packages/dom/src/compatibility/parsing/fragments.ts
create mode 100644 packages/dom/src/compatibility/parsing/markup-mutation.ts
create mode 100644 packages/dom/src/compatibility/parsing/namespaces.ts
create mode 100644 packages/dom/src/compatibility/parsing/recovery.ts
create mode 100644 packages/dom/src/compatibility/parsing/serialization.ts
create mode 100644 packages/dom/src/compatibility/registry.ts
create mode 100644 packages/dom/src/compatibility/selection/document/change-events.ts
create mode 100644 packages/dom/src/compatibility/selection/document/connectivity.ts
create mode 100644 packages/dom/src/compatibility/selection/document/direction.ts
create mode 100644 packages/dom/src/compatibility/selection/document/live-updates.ts
create mode 100644 packages/dom/src/compatibility/selection/document/mutation.ts
create mode 100644 packages/dom/src/compatibility/selection/document/range-management.ts
create mode 100644 packages/dom/src/compatibility/selection/range/boundaries.ts
create mode 100644 packages/dom/src/compatibility/selection/range/clone-contents.ts
create mode 100644 packages/dom/src/compatibility/selection/range/comparison.ts
create mode 100644 packages/dom/src/compatibility/selection/range/delete-contents.ts
create mode 100644 packages/dom/src/compatibility/selection/range/extract-contents.ts
create mode 100644 packages/dom/src/compatibility/selection/range/live-boundaries.ts
create mode 100644 packages/dom/src/compatibility/selection/range/mutation.ts
create mode 100644 packages/dom/src/compatibility/selection/range/stringification.ts
create mode 100644 packages/dom/src/compatibility/shadow/declarative.ts
create mode 100644 packages/dom/src/compatibility/shadow/events/composed-path.ts
create mode 100644 packages/dom/src/compatibility/shadow/events/related-target.ts
create mode 100644 packages/dom/src/compatibility/shadow/events/retargeting.ts
create mode 100644 packages/dom/src/compatibility/shadow/focus/delegation.ts
create mode 100644 packages/dom/src/compatibility/shadow/focus/retargeting.ts
create mode 100644 packages/dom/src/compatibility/shadow/roots/adoption.ts
create mode 100644 packages/dom/src/compatibility/shadow/roots/attachment.ts
create mode 100644 packages/dom/src/compatibility/shadow/roots/cloning.ts
create mode 100644 packages/dom/src/compatibility/shadow/roots/connectivity.ts
create mode 100644 packages/dom/src/compatibility/shadow/roots/host-eligibility.ts
create mode 100644 packages/dom/src/compatibility/shadow/serialization.ts
create mode 100644 packages/dom/src/compatibility/shadow/slots/fallback.ts
create mode 100644 packages/dom/src/compatibility/shadow/slots/manual.ts
create mode 100644 packages/dom/src/compatibility/shadow/slots/named.ts
create mode 100644 packages/dom/src/compatibility/shadow/slots/observer-ordering.ts
create mode 100644 packages/dom/src/compatibility/shadow/slots/slotchange.ts
create mode 100644 packages/dom/src/compatibility/style-fixture.ts
create mode 100644 packages/dom/src/compatibility/style/attribute-synchronization.ts
create mode 100644 packages/dom/src/compatibility/style/cascade.ts
create mode 100644 packages/dom/src/compatibility/style/computed.ts
create mode 100644 packages/dom/src/compatibility/style/constructed-stylesheets.ts
create mode 100644 packages/dom/src/compatibility/style/control-selectors.ts
create mode 100644 packages/dom/src/compatibility/style/custom-properties.ts
create mode 100644 packages/dom/src/compatibility/style/focus-state.ts
create mode 100644 packages/dom/src/compatibility/style/inline.ts
create mode 100644 packages/dom/src/compatibility/style/layers.ts
create mode 100644 packages/dom/src/compatibility/style/mutation-observation.ts
create mode 100644 packages/dom/src/compatibility/style/parsing.ts
create mode 100644 packages/dom/src/compatibility/style/relational-selectors.ts
create mode 100644 packages/dom/src/compatibility/style/stylesheets.ts
create mode 100644 packages/dom/src/compatibility/templates/cloning.ts
create mode 100644 packages/dom/src/compatibility/templates/content.ts
create mode 100644 packages/dom/src/compatibility/templates/parsing.ts
create mode 100644 packages/dom/src/compatibility/test-dom-compatibility.ts
create mode 100644 packages/dom/src/compatibility/traversal/node-iterator.ts
create mode 100644 packages/dom/src/compatibility/traversal/tree-walker.ts
create mode 100644 packages/dom/src/compatibility/unique-name.ts
create mode 100644 packages/dom/src/index.ts
create mode 100644 packages/dom/test/browser/compatibility-types.mts
create mode 100644 packages/dom/test/browser/compatibility.html
create mode 100644 packages/dom/test/browser/compatibility.mjs
create mode 100644 packages/dom/test/browser/features.ts
create mode 100644 packages/dom/test/browser/input-driver.mts
create mode 100644 packages/dom/test/browser/run-compatibility.mjs
create mode 100644 packages/dom/test/browser/tsconfig.json
create mode 100644 packages/dom/test/browser/vite.config.mjs
create mode 100644 packages/dom/test/compatibility-harness.test.ts
create mode 100644 packages/dom/test/wpt/HTML5LIB-LICENSE.txt
create mode 100644 packages/dom/test/wpt/LICENSE.txt
create mode 100644 packages/dom/test/wpt/README.md
create mode 100644 packages/dom/tsconfig.json
create mode 100644 packages/ui/src/dom.compatibility.test.ts
create mode 100644 packages/ui/test/dom-compatibility.md
create mode 100644 packages/ui/test/dom-features.ts
diff --git a/knip.config.ts b/knip.config.ts
new file mode 100644
index 0000000..5f19af7
--- /dev/null
+++ b/knip.config.ts
@@ -0,0 +1,7 @@
+import dom from './packages/dom/knip.json' with { type: 'json' };
+
+export default {
+ workspaces: {
+ 'packages/dom': { entry: dom.entry, project: dom.project },
+ },
+};
diff --git a/oxlint.config.ts b/oxlint.config.ts
index 07d583e..0a494ae 100644
--- a/oxlint.config.ts
+++ b/oxlint.config.ts
@@ -2,5 +2,19 @@ import config from './node_modules/@bomb.sh/tools/oxlintrc.json' with { type: 'j
export default {
...config,
+ overrides: [
+ ...(config.overrides ?? []),
+ {
+ files: [
+ 'packages/dom/src/**/*.ts',
+ 'packages/dom/test/browser/*.ts',
+ 'packages/dom/test/browser/*.mts',
+ ],
+ rules: {
+ // Test registration and factories must return synchronously to the runner.
+ 'bombshell-dev/exported-function-async': 'off',
+ },
+ },
+ ],
jsPlugins: ['./node_modules/@bomb.sh/tools/rules/plugin.js'],
};
diff --git a/package.json b/package.json
index 7b83d8e..3b03ccc 100644
--- a/package.json
+++ b/package.json
@@ -28,12 +28,12 @@
},
"scripts": {
"dev": "bsh dev",
- "build": "pnpm --filter \"@clack/*\" run build",
+ "build": "pnpm --filter @bomb.sh/dom build && pnpm --filter \"@clack/*\" run build",
"bench": "vitest bench --reporter=verbose",
"format": "bsh format",
"format:check": "bsh format --check",
"lint": "bsh lint .",
- "test": "bsh test"
+ "test": "pnpm --filter @bomb.sh/dom build && bsh test"
},
"devDependencies": {
"@bomb.sh/tools": "^0.8.0",
diff --git a/packages/dom/LICENSE b/packages/dom/LICENSE
new file mode 100644
index 0000000..8272839
--- /dev/null
+++ b/packages/dom/LICENSE
@@ -0,0 +1,21 @@
+MIT License Copyright (c) 2025-Present [Bombshell contributors](https://bomb.sh/team)
+
+Permission is hereby granted, free of
+charge, to any person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice
+(including the next paragraph) shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/packages/dom/README.md b/packages/dom/README.md
new file mode 100644
index 0000000..a0f03fc
--- /dev/null
+++ b/packages/dom/README.md
@@ -0,0 +1,82 @@
+# @bomb.sh/dom
+
+Private workspace package containing reusable DOM compatibility assertions. It contains no DOM implementation. Only `@clack/ui` tests consume its package API; browser validation is development tooling.
+
+The suite includes adaptations of [Web Platform Tests (WPT)](https://github.com/web-platform-tests/wpt), covering DOM operations, state/style interactions, input, shadow DOM, forms, CSSOM, selection, traversal, and parsing. Pinned source references, original authors, the WPT BSD-3-Clause notice, and the html5lib corpus MIT notice are recorded in [standards references and licenses](docs/references.md).
+
+
+```ts
+import { testDOMCompatibility, type DOMFeatures } from '@bomb.sh/dom';
+import { describe, it, expect } from 'vitest';
+import { features, createDocument, globals, dispose } from './implementation-fixture.ts';
+
+testDOMCompatibility({
+ runner: { describe, it, expect },
+ features,
+ create: createDocument,
+ globals,
+ dispose,
+ // input: document => createHostInputSession(document),
+});
+```
+
+
+### Selecting features to verify
+
+The runner supplies `describe`, `it`, and Vitest-compatible `expect` matchers and a set of `features` to verify conforming to the DOMFeatures interface. Every feature must be delared to either be supported or not supported, and features that are supported will have tests run against them.
+
+### Harness
+
+* `create()`: A factory to create your `Document`,
+* `globals`: `globalThis` used in test cases. For example, if a test needs to look up a the global `HTMLInputElement`, it will find it on this object.
+* `input`: Optional `HostInput` session for sending keyboard and mouse events. This allows testing of default actions like focus on click, form submit on `Enter`, and trusted events.
+
+
+### Development
+
+```sh
+pnpm build
+pnpm test
+pnpm --filter @bomb.sh/dom typecheck
+pnpm --filter @bomb.sh/dom exec playwright install chromium
+pnpm --filter @bomb.sh/dom test:browser:build
+pnpm --filter @bomb.sh/dom test:browser:input
+pnpm --filter @bomb.sh/dom test:browser:run
+# Manual, without input: open /compatibility.html on the printed URL
+pnpm --filter @bomb.sh/dom test:browser
+```
+In order to ensure the validity of the test suite itself as a browser compatibility layer, it can be run against an actual browser runtime. However, this is only for developing the tes suite itself and browser dependencies are development-only.
+
+The automation CLI uses Playwright's Chromium with an isolated browser context and fresh standards-mode iframe per case.
+
+The suite asserts resulting DOM behavior, never browser pixel values. Browser target resolution uses the browser's own rectangles and iframe coordinate offsets; a terminal adapter must use its own renderer.
+
+#### Known Chromium disagreements
+
+The following seven cases are allowed to fail when validating the suite in Chromium. They remain enabled, and the runner reports their failures and returns a nonzero exit code. Other failures require investigation.
+
+| Feature and case | Chromium behavior | Retained expectation/reference |
+| --- | --- | --- |
+| [`document.xmlSerialization`](src/compatibility/document/xml-serialization.ts): omits a redundant empty default namespace declaration | Retains the child's `xmlns=""` when the inherited namespace is already empty. | [XML serialization](https://w3c.github.io/DOM-Parsing/#xml-serialization-algorithm) omits the redundant declaration; [pinned WPT](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/domparsing/XMLSerializer-serializeToString.html). |
+| [`document.xmlSerialization`](src/compatibility/document/xml-serialization.ts): omits a redundant nonempty default namespace declaration | Retains the child's declaration when it repeats the inherited namespace. | Same XML serialization rule and pinned WPT. |
+| [`document.xmlSerialization`](src/compatibility/document/xml-serialization.ts): emits attribute nodes as empty strings even when their values are nonempty | Serializes the attribute's escaped value. | The [Attr serialization branch](https://w3c.github.io/DOM-Parsing/#xml-serializing-a-node) returns an empty string. The local case extends WPT's empty-valued Attr with a nonempty value. |
+| [`focus.removal`](src/compatibility/focus/removal.ts): removing a focused node clears focus without firing blur | Fires `blur` during removal. | [HTML removing steps](https://html.spec.whatwg.org/multipage/infrastructure.html#html-element-removing-steps). |
+| [`elements.input.radioGroups`](src/compatibility/elements/input/radio-groups.ts): groups radios by tree and nonempty name | The previous group member stays checked after a checked detached radio changes name. | [Radio group updates](https://html.spec.whatwg.org/multipage/input.html#radio-button-state-(type=radio)); [WPT disconnected groups](https://github.com/web-platform-tests/wpt/blob/master/html/semantics/forms/the-input-element/radio-disconnected-group-owner.html). |
+| [`elements.input.labels`](src/compatibility/elements/input/labels.ts): keeps retained labels live when a detached control moves under a label | Retained labels stay empty. | [Live labels in the same tree](https://html.spec.whatwg.org/multipage/forms.html#dom-lfe-labels). |
+| [`elements.textarea.labels`](src/compatibility/elements/textarea/labels.ts): keeps retained labels live when a detached control moves under a label | Retained labels stay empty. | Same live-label rule. |
+
+### Coverage boundaries
+
+The [feature catalogue](src/compatibility/features.ts) and [registry](src/compatibility/registry.ts) describe the executable coverage. Harness tests check that every feature has a module, registered cases, and specification references. [UI capability decisions](../ui/test/dom-compatibility.md) document which features its implementation supports. [WPT references and licenses](docs/references.md) identify the selected upstream adaptations. Passing selected cases does not imply complete standards conformance.
+
+Remaining test gaps include:
+
+- DOM mutation validity and adoption permutations, Web IDL conversions and brands, collection descriptors and iteration mutations, complete selector grammar and HTML reflection, and XML namespace/entity/PI/CDATA cases.
+- The full HTML tree-construction and contextual parsing corpora, malformed foreign markup, sanitization, and Trusted Types.
+- Legacy event initialization and listener-option conversions; overlapping observer registrations and custom-element/slotchange/promise ordering; complete Range endpoint and mutation matrices, NodeIterator filtering/mutations, and text-control selectionchange scheduling.
+- Complete control-type value/validity/stepping matrices, files and pickers, FormData and submission interleavings, network submission, and select UI policies.
+- Customized built-ins, scoped custom-element registries, ElementInternals and form-state restoration, reaction ordering, nested-slot redistribution, focus navigation scopes, focus-visible preferences, and modal/inert exceptions.
+- Full CSS property grammar and computed values, revert/revert-layer, layer/import interactions, stylesheet loading/CORS/grouping and cross-document adoption, media/container queries, and animation/transition timelines.
+- IME/composition, clipboard, repeat timing, drag/drop, touch/pen/wheel, multi-click selection, editing history and input target ranges, further textarea/contenteditable editing, nested-shadow navigation, and platform command bindings.
+
+Exact geometry, visual-line navigation, caret painting, scroll positioning, occlusion, hit-testing geometry matrices, and visual-output assertions require additional renderer integration. Shared input tests assert resulting behavior without assuming browser-equivalent pixels. Platform interaction policies and trusted-event support remain independently selectable.
diff --git a/packages/dom/docs/references.md b/packages/dom/docs/references.md
new file mode 100644
index 0000000..ee5b171
--- /dev/null
+++ b/packages/dom/docs/references.md
@@ -0,0 +1,16 @@
+# Standards references and licenses
+
+The compatibility suite tests behavior defined by the [DOM Standard](https://dom.spec.whatwg.org/), [HTML Standard](https://html.spec.whatwg.org/multipage/), [CSSOM](https://drafts.csswg.org/cssom/), and [CSS Cascade](https://drafts.csswg.org/css-cascade/). Each feature module links the relevant specification in its metadata; the [registry](../src/compatibility/registry.ts) maps features to those modules.
+
+Web Platform Tests provide additional coverage references for [DOM](https://github.com/web-platform-tests/wpt/tree/master/dom), [HTML](https://github.com/web-platform-tests/wpt/tree/master/html), [CSSOM](https://github.com/web-platform-tests/wpt/tree/master/css/cssom), and [shadow DOM](https://github.com/web-platform-tests/wpt/tree/master/shadow-dom). A reference to WPT does not mean the entire upstream test file or directory runs here.
+
+[Option selectedness cases](../src/compatibility/elements/option/selectedness.ts) adapt [option-selected.html](https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/html/semantics/forms/the-option-element/option-selected.html) at commit `b4b303c8dca22989c43e2d76fce199c36d4b77a7`. Copyright © web-platform-tests contributors; original author Corey Farwell. Attribution is retained in the module, with the BSD-3-Clause notice in [test/wpt/LICENSE.txt](../test/wpt/LICENSE.txt).
+
+The suite and browser tooling use the repository's [MIT license](../LICENSE), copyright 2025-Present Bombshell contributors.
+
+Additional WPT adaptations retain pinned sources, authors, license notices, and
+their exact scope in the [state and style](wpt-state-styles.md),
+[CSS and forms](wpt-css-forms.md), [shadow and input](wpt-shadow-input.md), and
+[range, selection, traversal, and parsing](wpt-ranges-parsing.md) inventories.
+
+[WPT implicit submission](https://github.com/web-platform-tests/wpt/blob/master/html/semantics/forms/form-submission-0/implicit-submission.optional.html) is optional. The suite keeps this interaction policy independently selectable from input delivery and trusted-event assertions.
diff --git a/packages/dom/docs/wpt-css-forms.md b/packages/dom/docs/wpt-css-forms.md
new file mode 100644
index 0000000..241bec9
--- /dev/null
+++ b/packages/dom/docs/wpt-css-forms.md
@@ -0,0 +1,34 @@
+# CSS and form behavior references
+
+The cases below adapt assertions from web-platform-tests (WPT), pinned to
+[`b719612f963a05ece9c89a7dd7598c1c59618874`](https://github.com/web-platform-tests/wpt/tree/b719612f963a05ece9c89a7dd7598c1c59618874).
+Copyright © web-platform-tests contributors. Adapted code is distributed under
+the upstream [BSD 3-Clause license](../test/wpt/LICENSE.txt). Authors named in the
+upstream files are credited below; the other listed files do not declare an
+individual author.
+
+| Local coverage | Upstream files and authors | Assertions adapted |
+| --- | --- | --- |
+| `style.constructedStylesheets` | [CSSStyleSheet-constructable-replace-cssRules.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/cssom/CSSStyleSheet-constructable-replace-cssRules.html), Emilio Cobos Álvarez and Mozilla; [CSSStyleSheet-constructable.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/cssom/CSSStyleSheet-constructable.html), Rakina Zata Amni | Constructor defaults, replacement and rule-list identity, adoption, ordering, disabled sheets, nonconstructed-sheet errors and shared shadow adopters. |
+| `style.layers` | [layer-basic.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/css-cascade/layer-basic.html), Antti Koivisto; [layer-important.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/css-cascade/layer-important.html), Romain Menke | Anonymous and named ordering, reopened and nested layers, statement ordering, specificity and reversed importance order. |
+| `style.mutationObservation` | [MutationObserver-style.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/cssom/MutationObserver-style.html) | Same-value declaration assignment versus repeated `cssText`; additional local cases cover batching, serialized old values and invalid/no-op declaration changes. |
+| `elements.form.dataEvents` | [constructing-form-data-set.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/forms/form-submission-0/constructing-form-data-set.html) | Synchronous bubbling in connected and detached trees, noncancelability, ordered listener modifications and newline preservation. An additional case checks entry construction before listener-driven control changes. |
+| `elements.form.reset` | [reset-form.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/forms/resetting-a-form/reset-form.html), Intel | Single/multiple select defaults through `reset()`, reset-button activation and reset-input activation, extended with cancellation, disabled activation and post-reset default changes. |
+
+`customElements.formAssociation` adapts the following files in
+[`custom-elements/form-associated`](https://github.com/web-platform-tests/wpt/tree/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated):
+
+- [form-associated-callback.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/form-associated-callback.html) and [ElementInternals-form.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/ElementInternals-form.html): ownership, form collections and association callbacks.
+- [ElementInternals-setFormValue.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/ElementInternals-setFormValue.html): string, null and multiple-entry form values. Local assertions inspect `FormData` entry lists without network submission.
+- [ElementInternals-validation.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/ElementInternals-validation.html): live validity, invalid-event delivery, clearing errors and missing-message errors.
+- [form-disabled-callback.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/form-disabled-callback.html): effective disabled state, callback suppression, validation and successful-control filtering.
+- [form-reset-callback.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/form-reset-callback.html): reset reactions after built-in control reset, extended with reset-event cancellation.
+- [ElementInternals-labels.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/ElementInternals-labels.html): explicit and wrapping label association.
+- [ElementInternals-NotSupportedError.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/ElementInternals-NotSupportedError.html): form API rejection on non-form-associated elements.
+
+These are selected adaptations with additional local assertions. They do not run
+the complete upstream files or establish complete coverage of their directories.
+Computed color checks require no browser pixel geometry. Network submission,
+file-upload transport, visual validation messages, state restoration and broader
+stylesheet/import matrices remain separate coverage gaps. Reset activation here
+tests the public `click()` API; host-input activation belongs to the input cases.
diff --git a/packages/dom/docs/wpt-ranges-parsing.md b/packages/dom/docs/wpt-ranges-parsing.md
new file mode 100644
index 0000000..1a0a9a4
--- /dev/null
+++ b/packages/dom/docs/wpt-ranges-parsing.md
@@ -0,0 +1,50 @@
+# Range, selection, traversal, and parsing references
+
+These compatibility tests adapt selected Web Platform Tests (WPT) at commit
+[`b719612f963a05ece9c89a7dd7598c1c59618874`](https://github.com/web-platform-tests/wpt/tree/b719612f963a05ece9c89a7dd7598c1c59618874).
+Copyright © web-platform-tests contributors. The upstream BSD-3-Clause terms are
+retained in [the WPT license](../test/wpt/LICENSE.txt).
+
+The parser data retains the original html5lib notice: Copyright (c) 2006-2013
+James Graham, Geoffrey Sneddon, and other contributors. Its
+[MIT license](../test/wpt/HTML5LIB-LICENSE.txt) is preserved from
+[html5lib-tests at `c777c408b61078ea2eb4acefc2535f54dbc8b28a`](https://github.com/html5lib/html5lib-tests/blob/c777c408b61078ea2eb4acefc2535f54dbc8b28a/LICENSE).
+The historical
+[WebKit TreeWalker script](https://github.com/WebKit/WebKit/blob/fd948ebbdca8cea5652815e432d911db953146b9/LayoutTests/fast/dom/TreeWalker/script-tests/acceptNode-filter.js)
+and its companion HTML file contain no additional copyright or license notice;
+the WPT attribution to that script is retained in the local module.
+
+| Local module | Inspected upstream sources and credits | Adaptation |
+| --- | --- | --- |
+| `selection/document/change-events.ts` | [onselectionchange-on-document.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/selection/onselectionchange-on-document.html) | Asynchronous delivery, coalescing, delivery after subsequent changes, and listener-triggered scheduling. Adds event fields and retained-range mutation checks. |
+| `selection/document/mutation.ts` | [selectAllChildren.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/selection/selectAllChildren.html), [removeRange.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/selection/removeRange.html), [deleteFromDocument.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/selection/deleteFromDocument.html) (Aryeh Gregor) | Range identity, invalid arguments, disconnected targets, deletion, and no-op behavior. Uses small explicit fixtures in place of the complete generated endpoint matrix. |
+| `selection/range/mutation.ts` | [Range-insertNode.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/dom/ranges/Range-insertNode.html), [Range-surroundContents.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/dom/ranges/Range-surroundContents.html) (Aryeh Gregor) | Fragment insertion, adoption, invalid boundaries, wrapper replacement, exception ordering, and resulting tree/range identity. |
+| `traversal/tree-walker.ts` | [TreeWalker-acceptNode-filter.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/dom/traversal/TreeWalker-acceptNode-filter.html), whose upstream header credits WebKit `LayoutTests/fast/dom/TreeWalker/script-tests/acceptNode-filter.js` | Callback objects, getter lookup, callable filters, exception identity, and recovery. Adds recursive filtering and unsigned-short result conversion checks from the DOM filtering algorithm. |
+| `parsing/recovery.ts` | [adoption01.dat](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/syntax/parsing/resources/adoption01.dat), [tables01.dat](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/syntax/parsing/resources/tables01.dat), originally the html5lib tree-construction corpus | Eight input/tree pairs are expressed as serialization assertions through both fragment and document parsing. Adds repaired-tree round trips and two template interactions. |
+| `document/xml-serialization.ts` | [XMLSerializer-serializeToString.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/domparsing/XMLSerializer-serializeToString.html) | Default namespace resets, prefix reuse/conflicts, declaration fixup, element end-tag consistency, fragments, escaping, and non-mutating serialization. |
+
+The suite supplies fresh fixtures and the consumer's assertion runner. Browser
+iframes and WPT harness helpers are replaced by those fixture boundaries. Event
+listeners are removed during cleanup; selection notifications use task turns
+rather than unbounded waits for an event that might never arrive.
+
+This is a selected adaptation, not the complete WPT range/selection endpoint
+matrix, traversal corpus, parser tree-construction corpus, or XML serializer
+suite. Parser checker error counts, streaming `document.write`, scripting modes,
+and XML serializer cases explicitly marked tentative upstream remain outside
+these adaptations. NodeIterator filtering and mutation matrices need separate
+expansion. Selection movement across visual lines, bidi layout, coordinate-based
+drag selection, and selection painting require renderer integration; these
+modules assert tree structure, API state, and event semantics only.
+
+The XML cases expect redundant empty and nonempty default `xmlns` declarations
+to be omitted and standalone `Attr` nodes to serialize as empty strings. These follow the
+[XML serialization algorithm](https://w3c.github.io/DOM-Parsing/#xml-serialization-algorithm):
+the `Attr` branch returns the empty string, and element serialization step 11
+suppresses the default namespace declaration when the inherited and element
+namespaces are equal. The pinned WPT file contains both expectations. The local
+attribute case additionally uses a nonempty value so it can distinguish the
+required result from serialization of the attribute's value. Known Chromium
+disagreements are documented in the [README](../README.md#known-chromium-disagreements). Namespace
+variants and character escaping are independent cases so a disagreement cannot
+prevent those other assertions from running.
diff --git a/packages/dom/docs/wpt-shadow-input.md b/packages/dom/docs/wpt-shadow-input.md
new file mode 100644
index 0000000..4fef61a
--- /dev/null
+++ b/packages/dom/docs/wpt-shadow-input.md
@@ -0,0 +1,36 @@
+# Shadow DOM and input WPT references
+
+The shadow and input modules adapt selected Web Platform Tests at commit
+[`b719612f963a05ece9c89a7dd7598c1c59618874`](https://github.com/web-platform-tests/wpt/tree/b719612f963a05ece9c89a7dd7598c1c59618874).
+Copyright © web-platform-tests contributors. The upstream
+[BSD-3-Clause license](../test/wpt/LICENSE.txt) applies to these adaptations.
+
+| Shared module | Pinned upstream source | Attribution and assertions |
+| --- | --- | --- |
+| `shadow/events/composed-path.ts` | [event-composed-path.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/event-composed-path.html) | Hayato Ito. Nested open/closed roots and nested slots expose different paths to inside/outside listeners while preserving or retargeting the event target. |
+| `shadow/slots/observer-ordering.ts` | [slotchange-event.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/slotchange-event.html) | Ryosuke Niwa and Hayato Ito. Observer delivery precedes slot signals; observer mutations enqueue another signal. Covers connected/detached open/closed roots, pending signals after slot removal, and first-change ordering of distinct slots. |
+| `input/keyboard/multiline-editing.ts` | [input-events-typing.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/input-events/input-events-typing.html) | WPT contributors; no individual author is declared in the file. Textarea/contenteditable typing, event order, paragraph/line-break input types. Additional cases assert cancellation and multiline selection replacement. |
+| `input/keyboard/number-stepping.ts` | [input-events-arrow-key-on-number-input.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/input-events/input-events-arrow-key-on-number-input.html), [input-events-arrow-key-on-number-input-prevent-default.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/input-events/input-events-arrow-key-on-number-input-prevent-default.html) | WPT contributors; no individual authors are declared in the files. Native arrow stepping delivers beforeinput/input/change; cancellation prevents mutation and subsequent editing events. |
+| `input/keyboard/shadow.ts` | [focus-navigation-slot-fallback-default-tabindex.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/focus-navigation/focus-navigation-slot-fallback-default-tabindex.html), [event-composed-path.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/event-composed-path.html) | WPT contributors; event-path author Hayato Ito. Slot-scope navigation, assigned versus fallback content, and native key event retargeting. Additional cases cover cancellation across the boundary and keyup after a focus change. |
+| `input/mouse/shadow.ts` | [event-composed-path.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/event-composed-path.html), [event-with-related-target.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/event-with-related-target.html), [mouseover-out.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/uievents/order-of-events/mouse-events/mouseover-out.html) | WPT contributors; shadow test authors Hayato Ito and Ryosuke Niwa. Native mouse event paths, internal hover transition trimming, and related-target retargeting on exit. Additional activation case cancels a host click and checks rollback of the shadow checkbox. |
+
+Each fixture uses the consumer's document and input session. Input cases exercise
+key down/up, host text insertion, pointer movement, and mouse down/up. Focus and selection APIs establish the test
+preconditions. The assertions examine event targets, order, cancellation, DOM
+state, focus, and selection without asserting pixel values or a particular
+contenteditable paragraph element name.
+
+`input.keyboard.multilineEditing` explicitly selects Enter for a paragraph in
+contenteditable, Enter for a line break in textarea, and Shift+Enter for a line
+break in contenteditable. `input.keyboard.numberStepping` selects ArrowUp and
+ArrowDown stepping with immediate change events. Neither policy is inferred from
+the environment. Trusted-event checks remain separate feature leaves. Shadow keyboard navigation and editing cases are conditional on their respective input policies; shadow mouse hover and checkbox activation cases likewise declare their own prerequisites.
+
+These adaptations cover selected assertions, not whole upstream files. Remaining
+gaps include the full positive/negative tabindex scope matrix, editing history,
+clipboard and IME composition, deletion across editing hosts, and the complete
+slot redistribution matrix. `InputEvent.getTargetRanges()` lifetime and changes
+to selection during dispatch remain tracked separately: the relevant WPT file
+[is tentative](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/input-events/input-events-get-target-ranges-during-and-after-dispatch.tentative.html),
+and its expectations are not part of these policy flags. Visual-line navigation,
+caret geometry, and geometric hit-testing still require renderer integration.
diff --git a/packages/dom/docs/wpt-state-styles.md b/packages/dom/docs/wpt-state-styles.md
new file mode 100644
index 0000000..078c2ed
--- /dev/null
+++ b/packages/dom/docs/wpt-state-styles.md
@@ -0,0 +1,31 @@
+# State, selectors, and style references
+
+These cases adapt selected Web Platform Tests at revision
+[`b719612f963a05ece9c89a7dd7598c1c59618874`](https://github.com/web-platform-tests/wpt/tree/b719612f963a05ece9c89a7dd7598c1c59618874).
+Copyright © web-platform-tests contributors. The upstream
+[BSD 3-Clause license](../test/wpt/LICENSE.txt) applies to adapted code.
+
+| Local coverage | Pinned source and authors | Adaptation |
+| --- | --- | --- |
+| `style.focusState`, `input.keyboard.focusStyles` | [focus-within-011.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/focus-within-011.html), Ethan Lin and Mozilla | Focus movement between siblings and ancestors. The visual reference is replaced by selector and computed-color assertions. Additional cases cover blur, removal, retained styles, focus listeners, shadow hosts, navigation cancellation, and inert subtrees. |
+| `style.relationalSelectors`, `input.mouse.stateStyles` | [user-action-pseudo-classes-in-has.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/invalidation/user-action-pseudo-classes-in-has.html), Byungwoo Lee | Ancestor and sibling-descendant invalidation during hover, press/release, focus, and removal. Additional cases cover relative selectors, snapshots, errors, and checkbox cancellation rollback. |
+| `input.keyboard.focusVisible` | [focus-visible-001.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/focus-visible-001.html), Rob Dodson | Tab focus matches `:focus-visible` on buttons, tabindex elements, and checkboxes. |
+| `input.mouse.focusVisible` | [focus-visible-002.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/focus-visible-002.html), [focus-visible-003.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/focus-visible-003.html), Alice Boxhall | Mouse focus indicates text-entry controls and does not indicate non-text controls. Cases use text/email/password inputs, textarea, button, checkbox, and tabindex span. |
+| `style.controlSelectors` | [checked.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/selectors/pseudo-classes/checked.html), [valid-invalid.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/selectors/pseudo-classes/valid-invalid.html), [disabled.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/selectors/pseudo-classes/disabled.html), Denis Ah-Kang | Live checkedness, selectedness/reset, input-type changes, value/pattern/custom validity, aggregate form/fieldset validity, disabled fieldsets and first-legend exemption. |
+| `focus.inert` | [inert-node-is-unfocusable.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/inert/inert-node-is-unfocusable.html), [inert-in-shadow-dom.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/inert/inert-in-shadow-dom.html), Alice Boxhall; [inert-does-not-match-disabled-selector.html](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/inert/inert-does-not-match-disabled-selector.html) | Explicit versus inherited inertness, dynamic enabling, nested restrictions, open/closed shadow and slotted focus, independence from disabled state. |
+
+The two `focusVisible` flags select focus-indication policies independently of
+ordinary focus selectors, keyboard/mouse delivery, and trusted events. Text-entry mouse cases can be selected independently of the non-text mouse-focus
+policy; non-text cases additionally require `input.mouse.focus`. They do
+not infer user preferences or silently detect selector support. Native input goes
+through the supplied driver. Programmatic focus and property writes establish
+fixtures or test the corresponding public API; they never substitute for the
+input actions under test.
+
+These are selected adaptations and additional assertions, not complete upstream
+file runs. Remaining matrices include focus-visible script transfer and user
+preferences, date/time and picker controls, `:user-valid`/`:user-invalid`,
+`:indeterminate`, deeper slotted focus styling, and inert modal exceptions.
+Color assertions validate computed state without pixel snapshots or geometry
+expectations. Visual outlines, occlusion, and geometric hit-testing require
+renderer integration.
diff --git a/packages/dom/knip.json b/packages/dom/knip.json
new file mode 100644
index 0000000..d323bc7
--- /dev/null
+++ b/packages/dom/knip.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://unpkg.com/knip@6/schema.json",
+ "entry": ["src/index.ts", "test/*.test.ts", "test/browser/compatibility.mjs", "test/browser/run-compatibility.mjs", "test/browser/compatibility-types.mts"],
+ "project": ["src/**/*.ts", "test/**/*.{ts,mts,mjs}"]
+}
diff --git a/packages/dom/package.json b/packages/dom/package.json
new file mode 100644
index 0000000..9618459
--- /dev/null
+++ b/packages/dom/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@bomb.sh/dom",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Reusable DOM compatibility assertions and development browser validation",
+ "type": "module",
+ "license": "MIT",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.mts",
+ "import": "./dist/index.mjs"
+ },
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "build": "bsh build src/index.ts --dts",
+ "test": "bsh test",
+ "typecheck": "tsc -p tsconfig.json && tsc -p test/browser/tsconfig.json",
+ "test:browser:build": "vite build --config test/browser/vite.config.mjs",
+ "test:browser:run": "node test/browser/run-compatibility.mjs",
+ "test:browser:input": "node test/browser/run-compatibility.mjs --input-only",
+ "test:browser": "vite --config test/browser/vite.config.mjs --host 127.0.0.1",
+ "format": "bsh format",
+ "lint": "bsh lint"
+ },
+ "dependencies": {
+ "typebox": "^1.4.3"
+ },
+ "devDependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@vitest/expect": "5.0.5",
+ "chai": "^6.2.2",
+ "playwright": "^1.64.1",
+ "typescript": "^6.0.2",
+ "vite": "^8.1.0"
+ }
+}
diff --git a/packages/dom/src/compatibility/character-data/data.ts b/packages/dom/src/compatibility/character-data/data.ts
new file mode 100644
index 0000000..bc13028
--- /dev/null
+++ b/packages/dom/src/compatibility/character-data/data.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-characterdata';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'keeps data identity and coerces standard writable string properties',
+ ({ document }) => {
+ const text = document.createTextNode('A😀B');
+ const parent = document.createElement('div');
+ parent.appendChild(text);
+ expect(text.length).toBe(4);
+ // @ts-expect-error -- Verify the standard DOMString conversion at runtime.
+ text.data = 123;
+ expect(text.data).toBe('123');
+ expect(parent.firstChild).toBe(text);
+ expect(text.nodeValue).toBe('123');
+ text.nodeValue = null;
+ expect(text.data).toBe('');
+ text.textContent = 'next';
+ expect(text.data).toBe('next');
+ },
+ );
+
+ context.test('keeps text and comment nodeValue and textContent synchronized', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ for (const t of [document.createTextNode('x'), document.createComment('x')]) {
+ expect(t.nodeValue).toBe('x');
+ t.nodeValue = 'y';
+ expect(t.data).toBe('y');
+ t.textContent = 'z';
+ expect(t.nodeValue).toBe('z');
+ expect(t.length).toBe(1);
+ }
+ });
+
+ context.test('converts null nodeValue to empty data', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('x');
+ t.nodeValue = null;
+ expect(t.data).toBe('');
+ expect(t.length).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/character-data/editing.ts b/packages/dom/src/compatibility/character-data/editing.ts
new file mode 100644
index 0000000..c612d6c
--- /dev/null
+++ b/packages/dom/src/compatibility/character-data/editing.ts
@@ -0,0 +1,48 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#concept-cd-replace';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('edits UTF-16 units and rejects out-of-range offsets', ({ document }) => {
+ const text = document.createTextNode('A😀B');
+ expect(text.substringData(1, 2)).toBe('😀');
+ text.replaceData(1, 2, 'X');
+ expect(text.data).toBe('AXB');
+ text.insertData(1, 'Y');
+ text.appendData('!');
+ expect(text.data).toBe('AYXB!');
+ text.deleteData(1, 2);
+ expect(text.data).toBe('AB!');
+ expect(() => text.insertData(9, 'bad')).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ expect(text.data).toBe('AB!');
+ });
+
+ context.test('counts UTF-16 code units and clamps oversized counts', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('A😀B');
+ expect(t.length).toBe(4);
+ expect(t.substringData(1, 2)).toBe('😀');
+ expect(t.substringData(4, 100)).toBe('');
+ t.replaceData(1, 100, 'x');
+ expect(t.data).toBe('Ax');
+ t.insertData(t.length, '!');
+ expect(t.data).toBe('Ax!');
+ });
+
+ context.test('rejects invalid offsets without partial changes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('abc');
+ for (const op of [
+ () => t.substringData(4, 1),
+ () => t.insertData(4, 'x'),
+ () => t.deleteData(4, 1),
+ () => t.replaceData(4, 1, 'x'),
+ ]) {
+ expect(op).toThrow(expect.objectContaining({ name: 'IndexSizeError' }));
+ expect(t.data).toBe('abc');
+ }
+ });
+}
diff --git a/packages/dom/src/compatibility/character-data/split-text.ts b/packages/dom/src/compatibility/character-data/split-text.ts
new file mode 100644
index 0000000..89e78d7
--- /dev/null
+++ b/packages/dom/src/compatibility/character-data/split-text.ts
@@ -0,0 +1,42 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-text-splittext';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'splits into an adjacent text node without replacing the original',
+ ({ document }) => {
+ const parent = document.createElement('div');
+ const text = document.createTextNode('A😀B');
+ parent.appendChild(text);
+ const split = text.splitText(3);
+ expect(text.data).toBe('A😀');
+ expect(split.data).toBe('B');
+ expect(parent.firstChild).toBe(text);
+ expect(text.nextSibling).toBe(split);
+ expect(split.ownerDocument).toBe(document);
+ expect(() => text.splitText(4)).toThrow(expect.objectContaining({ name: 'IndexSizeError' }));
+ },
+ );
+
+ context.test('splits detached text at zero and length', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createTextNode('abc'),
+ b = a.splitText(0);
+ expect(a.data).toBe('');
+ expect(b.data).toBe('abc');
+ expect(b.parentNode).toBeNull();
+ const c = b.splitText(3);
+ expect(c.data).toBe('');
+ expect(b.data).toBe('abc');
+ expect(c.ownerDocument).toBe(document);
+ });
+
+ context.test('rejects offsets beyond the text length atomically', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('abc');
+ expect(() => t.splitText(4)).toThrow(expect.objectContaining({ name: 'IndexSizeError' }));
+ expect(t.data).toBe('abc');
+ });
+}
diff --git a/packages/dom/src/compatibility/character-data/string-storage.ts b/packages/dom/src/compatibility/character-data/string-storage.ts
new file mode 100644
index 0000000..d84fec2
--- /dev/null
+++ b/packages/dom/src/compatibility/character-data/string-storage.ts
@@ -0,0 +1,27 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-characterdata-data';
+export const requires = ['node.basicAppend'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reads and replaces text data including empty and Unicode strings', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('first');
+ for (const value of ['', '😀', 'a\nb', 'last']) {
+ t.data = value;
+ expect(t.data).toBe(value);
+ }
+ });
+ context.test('retains identity and independent sibling values on data writes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createTextNode('a'),
+ b = document.createTextNode('b');
+ const parent = document.createElement('div');
+ parent.appendChild(a);
+ parent.appendChild(b);
+ a.data = 'next';
+ expect(parent.childNodes[0]).toBe(a);
+ expect(parent.childNodes[1]).toBe(b);
+ expect(b.data).toBe('b');
+ });
+}
diff --git a/packages/dom/src/compatibility/context.ts b/packages/dom/src/compatibility/context.ts
new file mode 100644
index 0000000..633a47f
--- /dev/null
+++ b/packages/dom/src/compatibility/context.ts
@@ -0,0 +1,60 @@
+import type { DOMFeaturePath } from './features.ts';
+import type { DOMDocument, DOMElement } from './contracts/nodes.ts';
+import type { DOMGlobals } from './globals.ts';
+import type { DOMInputCapability, DOMTestInput } from './input-driver.ts';
+
+/** The matcher surface used by the suite, independent of runner internals. */
+export interface CompatibilityMatchers {
+ readonly not: CompatibilityMatchers;
+ readonly rejects: { toMatchObject(expected: unknown): PromiseLike };
+ toBe(expected: unknown): unknown;
+ toEqual(expected: unknown): unknown;
+ toBeNull(): unknown;
+ toBeUndefined(): unknown;
+ toBeGreaterThan(expected: number): unknown;
+ toBeInstanceOf(expected: unknown): unknown;
+ toContain(expected: unknown): unknown;
+ toHaveLength(expected: number): unknown;
+ toMatchObject(expected: unknown): unknown;
+ toThrow(expected?: unknown): unknown;
+}
+
+/** Vitest-compatible assertions supplied by the consumer; no runner is imported. */
+export interface CompatibilityExpect {
+ (actual: unknown, message?: string): CompatibilityMatchers;
+ objectContaining(expected: unknown): unknown;
+ fail(message?: string): never;
+ unreachable(message?: string): never;
+}
+
+export interface CompatibilityRunner {
+ describe(name: string, register: () => void): unknown;
+ it(name: string, run: () => void | Promise): unknown;
+ expect: CompatibilityExpect;
+}
+
+export interface DOMFixture {
+ readonly document: DOMDocument;
+ readonly input: DOMTestInput;
+ globals(name: K): DOMGlobals[K];
+ cleanup(callback: () => void | Promise): void;
+ connect(element: T): T;
+}
+
+export interface CompatibilityContext {
+ readonly runner: CompatibilityRunner;
+ test(
+ name: string,
+ run: (fixture: DOMFixture) => void | Promise,
+ requires?: readonly DOMFeaturePath[],
+ ): void;
+}
+
+export interface FeatureModule {
+ readonly spec: string;
+ readonly wpt?: string;
+ readonly globals?: readonly string[];
+ readonly requires?: readonly DOMFeaturePath[];
+ readonly input?: readonly DOMInputCapability[];
+ register(context: CompatibilityContext): void;
+}
diff --git a/packages/dom/src/compatibility/contracts/collections.ts b/packages/dom/src/compatibility/contracts/collections.ts
new file mode 100644
index 0000000..d16290c
--- /dev/null
+++ b/packages/dom/src/compatibility/contracts/collections.ts
@@ -0,0 +1,64 @@
+import type { DOMAttr, DOMElement, DOMNode } from './nodes.ts';
+import type { DOMOptGroupElement, DOMOptionElement } from './elements.ts';
+
+/** https://dom.spec.whatwg.org/#interface-nodelist */
+export interface DOMNodeList extends Iterable {
+ readonly length: number;
+ readonly [index: number]: T;
+ item(index: number): T | null;
+ entries(): IterableIterator<[number, T]>;
+ keys(): IterableIterator;
+ values(): IterableIterator;
+ forEach(
+ callback: (value: T, index: number, list: DOMNodeList) => void,
+ thisArg?: unknown,
+ ): void;
+}
+
+/** https://dom.spec.whatwg.org/#interface-htmlcollection */
+export interface DOMHTMLCollection extends Iterable {
+ readonly length: number;
+ readonly [index: number]: T;
+ item(index: number): T | null;
+ namedItem(name: string): T | null;
+}
+
+/** https://dom.spec.whatwg.org/#interface-namednodemap */
+export interface DOMNamedNodeMap extends Iterable {
+ readonly length: number;
+ readonly [index: number]: DOMAttr;
+ item(index: number): DOMAttr | null;
+ getNamedItem(qualifiedName: string): DOMAttr | null;
+ getNamedItemNS(namespace: string | null, localName: string): DOMAttr | null;
+ setNamedItem(attr: DOMAttr): DOMAttr | null;
+ setNamedItemNS(attr: DOMAttr): DOMAttr | null;
+ removeNamedItem(qualifiedName: string): DOMAttr;
+ removeNamedItemNS(namespace: string | null, localName: string): DOMAttr;
+}
+
+/** https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#radionodelist */
+export interface DOMRadioNodeList extends DOMNodeList {
+ value: string;
+}
+
+/** https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#htmlformcontrolscollection */
+export interface DOMHTMLFormControlsCollection extends Iterable {
+ readonly length: number;
+ readonly [index: number]: DOMElement;
+ item(index: number): DOMElement | null;
+ namedItem(name: string): DOMElement | DOMRadioNodeList | null;
+}
+
+/** https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#htmloptionscollection */
+export interface DOMHTMLOptionsCollection extends Iterable {
+ length: number;
+ selectedIndex: number;
+ // TypeScript cannot distinguish indexed getter and setter types. Null is
+ // accepted by the standard indexed setter to remove an option; iteration and
+ // item() retain their precise getter result types.
+ [index: number]: DOMOptionElement | null;
+ item(index: number): DOMOptionElement | null;
+ namedItem(name: string): DOMOptionElement | null;
+ add(element: DOMOptionElement | DOMOptGroupElement, before?: DOMElement | number | null): void;
+ remove(index: number): void;
+}
diff --git a/packages/dom/src/compatibility/contracts/elements.ts b/packages/dom/src/compatibility/contracts/elements.ts
new file mode 100644
index 0000000..d205ea6
--- /dev/null
+++ b/packages/dom/src/compatibility/contracts/elements.ts
@@ -0,0 +1,214 @@
+import type { DOMElement, DOMHTMLElement, DOMTokenList } from './nodes.ts';
+import type { DOMSlotElement, DOMTemplateElement } from './shadow.ts';
+import type {
+ DOMHTMLCollection,
+ DOMHTMLFormControlsCollection,
+ DOMHTMLOptionsCollection,
+ DOMNodeList,
+} from './collections.ts';
+
+export interface DOMValidityState {
+ readonly valueMissing: boolean;
+ readonly typeMismatch: boolean;
+ readonly patternMismatch: boolean;
+ readonly tooLong: boolean;
+ readonly tooShort: boolean;
+ readonly rangeUnderflow: boolean;
+ readonly rangeOverflow: boolean;
+ readonly stepMismatch: boolean;
+ readonly badInput: boolean;
+ readonly customError: boolean;
+ readonly valid: boolean;
+}
+
+export interface DOMFormControl extends DOMHTMLElement {
+ name: string;
+ readonly form: DOMFormElement | null;
+ readonly labels: DOMNodeList | null;
+ readonly willValidate: boolean;
+ readonly validity: DOMValidityState;
+ readonly validationMessage: string;
+ checkValidity(): boolean;
+ reportValidity(): boolean;
+ setCustomValidity(message: string): void;
+}
+
+export interface DOMTextControl extends DOMFormControl {
+ value: string;
+ defaultValue: string;
+ disabled: boolean;
+ required: boolean;
+ readOnly: boolean;
+ selectionStart: number | null;
+ selectionEnd: number | null;
+ selectionDirection: string | null;
+ select(): void;
+ setSelectionRange(start: number, end: number, direction?: string): void;
+ setRangeText(replacement: string): void;
+ setRangeText(
+ replacement: string,
+ start: number,
+ end: number,
+ mode?: 'select' | 'start' | 'end' | 'preserve',
+ ): void;
+}
+
+export interface DOMInputElement extends DOMTextControl {
+ type: string;
+ checked: boolean;
+ defaultChecked: boolean;
+ indeterminate: boolean;
+ multiple: boolean;
+ min: string;
+ max: string;
+ step: string;
+ pattern: string;
+ valueAsNumber: number;
+ valueAsDate: Date | null;
+ stepUp(count?: number): void;
+ stepDown(count?: number): void;
+}
+
+export interface DOMTextAreaElement extends DOMTextControl {
+ selectionStart: number;
+ selectionEnd: number;
+ selectionDirection: string;
+ readonly labels: DOMNodeList;
+}
+
+export interface DOMButtonElement extends DOMFormControl {
+ readonly labels: DOMNodeList;
+ type: string;
+ disabled: boolean;
+ value: string;
+ formNoValidate: boolean;
+}
+
+export interface DOMFormElement extends DOMHTMLElement {
+ readonly elements: DOMHTMLFormControlsCollection;
+ readonly length: number;
+ noValidate: boolean;
+ method: string;
+ reset(): void;
+ checkValidity(): boolean;
+ reportValidity(): boolean;
+ requestSubmit(submitter?: DOMHTMLElement | null): void;
+}
+
+export interface DOMLabelElement extends DOMHTMLElement {
+ htmlFor: string;
+ readonly control: DOMElement | null;
+ readonly form: DOMFormElement | null;
+}
+
+export interface DOMFieldSetElement extends DOMHTMLElement {
+ disabled: boolean;
+ readonly elements: DOMHTMLCollection;
+ readonly form: DOMFormElement | null;
+ readonly willValidate: boolean;
+}
+
+export interface DOMLegendElement extends DOMHTMLElement {
+ readonly form: DOMFormElement | null;
+}
+
+export interface DOMSelectElement extends DOMFormControl {
+ readonly labels: DOMNodeList;
+ disabled: boolean;
+ multiple: boolean;
+ required: boolean;
+ size: number;
+ readonly type: string;
+ readonly options: DOMHTMLOptionsCollection;
+ readonly selectedOptions: DOMHTMLCollection;
+ length: number;
+ selectedIndex: number;
+ value: string;
+ item(index: number): DOMOptionElement | null;
+ namedItem(name: string): DOMOptionElement | null;
+ add(element: DOMOptionElement | DOMOptGroupElement, before?: DOMElement | number | null): void;
+ remove(index?: number): void;
+}
+
+export interface DOMOptionElement extends DOMHTMLElement {
+ disabled: boolean;
+ defaultSelected: boolean;
+ selected: boolean;
+ value: string;
+ label: string;
+ text: string;
+ readonly index: number;
+ readonly form: DOMFormElement | null;
+}
+
+export interface DOMOptGroupElement extends DOMHTMLElement {
+ disabled: boolean;
+ label: string;
+}
+
+export interface DOMDataListElement extends DOMHTMLElement {
+ readonly options: DOMHTMLCollection;
+}
+
+export interface DOMOutputElement extends DOMFormControl {
+ readonly labels: DOMNodeList;
+ readonly type: string;
+ value: string;
+ defaultValue: string;
+ get htmlFor(): DOMTokenList;
+ set htmlFor(value: string);
+}
+
+export interface DOMProgressElement extends DOMHTMLElement {
+ readonly labels: DOMNodeList;
+ value: number;
+ max: number;
+ readonly position: number;
+}
+
+export interface DOMMeterElement extends DOMHTMLElement {
+ readonly labels: DOMNodeList;
+ value: number;
+ min: number;
+ max: number;
+ low: number;
+ high: number;
+ optimum: number;
+}
+
+export interface DOMDetailsElement extends DOMHTMLElement {
+ open: boolean;
+ name: string;
+}
+
+export interface DOMDialogElement extends DOMHTMLElement {
+ open: boolean;
+ returnValue: string;
+ show(): void;
+ showModal(): void;
+ close(returnValue?: string): void;
+ requestClose(returnValue?: string): void;
+}
+
+export interface DOMElements {
+ template: DOMTemplateElement;
+ slot: DOMSlotElement;
+ input: DOMInputElement;
+ textarea: DOMTextAreaElement;
+ button: DOMButtonElement;
+ form: DOMFormElement;
+ label: DOMLabelElement;
+ fieldset: DOMFieldSetElement;
+ legend: DOMLegendElement;
+ select: DOMSelectElement;
+ option: DOMOptionElement;
+ optgroup: DOMOptGroupElement;
+ datalist: DOMDataListElement;
+ selectedcontent: DOMHTMLElement;
+ output: DOMOutputElement;
+ progress: DOMProgressElement;
+ meter: DOMMeterElement;
+ details: DOMDetailsElement;
+ summary: DOMHTMLElement;
+ dialog: DOMDialogElement;
+}
diff --git a/packages/dom/src/compatibility/contracts/events.ts b/packages/dom/src/compatibility/contracts/events.ts
new file mode 100644
index 0000000..b463af0
--- /dev/null
+++ b/packages/dom/src/compatibility/contracts/events.ts
@@ -0,0 +1,174 @@
+/** Standard event interfaces; feature selection never changes their required members. */
+import type { DOMHTMLElement } from './nodes.ts';
+
+export interface DOMEventInit {
+ bubbles?: boolean;
+ cancelable?: boolean;
+ composed?: boolean;
+}
+
+export interface DOMEvent {
+ readonly type: string;
+ readonly bubbles: boolean;
+ readonly cancelable: boolean;
+ readonly composed: boolean;
+ readonly target: DOMEventTarget | null;
+ readonly currentTarget: DOMEventTarget | null;
+ readonly eventPhase: number;
+ readonly defaultPrevented: boolean;
+ readonly isTrusted: boolean;
+ readonly timeStamp: number;
+ readonly NONE: 0;
+ readonly CAPTURING_PHASE: 1;
+ readonly AT_TARGET: 2;
+ readonly BUBBLING_PHASE: 3;
+ cancelBubble: boolean;
+ returnValue: boolean;
+ stopPropagation(): void;
+ stopImmediatePropagation(): void;
+ preventDefault(): void;
+ composedPath(): DOMEventTarget[];
+ initEvent(type: string, bubbles?: boolean, cancelable?: boolean): void;
+}
+
+export interface DOMEventListener {
+ (this: DOMEventTarget, event: DOMEvent): void;
+}
+
+export interface DOMEventListenerObject {
+ handleEvent(event: DOMEvent): void;
+}
+
+export interface DOMListenerOptions {
+ capture?: boolean;
+ once?: boolean;
+ passive?: boolean;
+ signal?: AbortSignal;
+}
+
+export interface DOMEventTarget {
+ addEventListener(
+ type: string,
+ callback: DOMEventListener | DOMEventListenerObject | null,
+ options?: boolean | DOMListenerOptions,
+ ): void;
+ removeEventListener(
+ type: string,
+ callback: DOMEventListener | DOMEventListenerObject | null,
+ options?: boolean | { capture?: boolean },
+ ): void;
+ dispatchEvent(event: DOMEvent): boolean;
+}
+
+export interface DOMEventConstructor {
+ new (type: string, options?: DOMEventInit): DOMEvent;
+}
+
+export interface DOMEventTargetConstructor {
+ new (): DOMEventTarget;
+}
+
+export interface DOMCustomEvent extends DOMEvent {
+ readonly detail: unknown;
+}
+
+export interface DOMCustomEventConstructor {
+ new (type: string, options?: DOMEventInit & { detail?: unknown }): DOMCustomEvent;
+}
+
+export interface DOMKeyboardEvent extends DOMEvent {
+ readonly key: string;
+ readonly code: string;
+ readonly location: number;
+ readonly repeat: boolean;
+ readonly isComposing: boolean;
+ readonly ctrlKey: boolean;
+ readonly altKey: boolean;
+ readonly shiftKey: boolean;
+ readonly metaKey: boolean;
+ getModifierState(key: string): boolean;
+}
+
+export interface DOMKeyboardEventConstructor {
+ new (
+ type: string,
+ options?: DOMEventInit & {
+ key?: string;
+ code?: string;
+ location?: number;
+ repeat?: boolean;
+ isComposing?: boolean;
+ ctrlKey?: boolean;
+ altKey?: boolean;
+ shiftKey?: boolean;
+ metaKey?: boolean;
+ },
+ ): DOMKeyboardEvent;
+}
+
+export interface DOMMouseEvent extends DOMEvent {
+ readonly button: number;
+ readonly buttons: number;
+ readonly clientX: number;
+ readonly clientY: number;
+ readonly screenX: number;
+ readonly screenY: number;
+ readonly ctrlKey: boolean;
+ readonly altKey: boolean;
+ readonly shiftKey: boolean;
+ readonly metaKey: boolean;
+ readonly relatedTarget: DOMEventTarget | null;
+ getModifierState(key: string): boolean;
+}
+
+export interface DOMMouseEventConstructor {
+ new (
+ type: string,
+ options?: DOMEventInit & {
+ button?: number;
+ buttons?: number;
+ clientX?: number;
+ clientY?: number;
+ screenX?: number;
+ screenY?: number;
+ ctrlKey?: boolean;
+ altKey?: boolean;
+ shiftKey?: boolean;
+ metaKey?: boolean;
+ relatedTarget?: DOMEventTarget | null;
+ },
+ ): DOMMouseEvent;
+}
+
+export interface DOMFocusEvent extends DOMEvent {
+ readonly relatedTarget: DOMEventTarget | null;
+}
+
+export interface DOMToggleEvent extends DOMEvent {
+ readonly oldState: string;
+ readonly newState: string;
+}
+
+export interface DOMSubmitEvent extends DOMEvent {
+ readonly submitter: DOMHTMLElement | null;
+}
+
+export interface DOMFocusEventConstructor {
+ new (
+ type: string,
+ options?: DOMEventInit & { relatedTarget?: DOMEventTarget | null },
+ ): DOMFocusEvent;
+}
+
+export interface DOMInputEvent extends DOMEvent {
+ readonly data: string | null;
+ readonly inputType: string;
+ readonly isComposing: boolean;
+}
+
+export interface DOMInputEventConstructor {
+ new (
+ type: string,
+ options?: DOMEventInit & { data?: string | null; inputType?: string; isComposing?: boolean },
+ ): DOMInputEvent;
+}
diff --git a/packages/dom/src/compatibility/contracts/nodes.ts b/packages/dom/src/compatibility/contracts/nodes.ts
new file mode 100644
index 0000000..51d2985
--- /dev/null
+++ b/packages/dom/src/compatibility/contracts/nodes.ts
@@ -0,0 +1,198 @@
+/** Standard interfaces used by the compatibility tests, independent of implementation support. */
+import type { DOMEventTarget } from './events.ts';
+import type { DOMElements } from './elements.ts';
+import type { DOMRange, DOMSelection } from './selection.ts';
+import type { DOMShadowRoot, DOMSlotElement } from './shadow.ts';
+import type { DOMHTMLCollection, DOMNamedNodeMap, DOMNodeList } from './collections.ts';
+
+export interface DOMNode extends DOMEventTarget {
+ readonly nodeType: number;
+ readonly nodeName: string;
+ readonly ownerDocument: DOMDocument | null;
+ readonly parentNode: DOMNode | null;
+ readonly parentElement: DOMElement | null;
+ readonly childNodes: DOMNodeList;
+ readonly firstChild: DOMNode | null;
+ readonly lastChild: DOMNode | null;
+ readonly previousSibling: DOMNode | null;
+ readonly nextSibling: DOMNode | null;
+ readonly isConnected: boolean;
+ readonly baseURI: string;
+ nodeValue: string | null;
+ textContent: string | null;
+ appendChild(node: DOMNode): DOMNode;
+ insertBefore(node: DOMNode, anchor: DOMNode | null): DOMNode;
+ removeChild(node: DOMNode): DOMNode;
+ replaceChild(node: DOMNode, child: DOMNode): DOMNode;
+ cloneNode(deep?: boolean): DOMNode;
+ contains(node: DOMNode | null): boolean;
+ hasChildNodes(): boolean;
+ isSameNode(node: DOMNode | null): boolean;
+ isEqualNode(node: DOMNode | null): boolean;
+ normalize(): void;
+ compareDocumentPosition(other: DOMNode): number;
+ lookupPrefix(namespace: string | null): string | null;
+ lookupNamespaceURI(prefix: string | null): string | null;
+ isDefaultNamespace(namespace: string | null): boolean;
+ getRootNode(options?: { composed?: boolean }): DOMNode;
+}
+
+export interface DOMParentNode {
+ readonly children: DOMHTMLCollection;
+ readonly firstElementChild: DOMElement | null;
+ readonly lastElementChild: DOMElement | null;
+ readonly childElementCount: number;
+ append(...nodes: (DOMNode | string)[]): void;
+ prepend(...nodes: (DOMNode | string)[]): void;
+ replaceChildren(...nodes: (DOMNode | string)[]): void;
+}
+
+export interface DOMChildNode {
+ remove(): void;
+ before(...nodes: (DOMNode | string)[]): void;
+ after(...nodes: (DOMNode | string)[]): void;
+ replaceWith(...nodes: (DOMNode | string)[]): void;
+}
+
+export interface DOMElement extends DOMNode, DOMParentNode, DOMChildNode {
+ readonly lastElementChild: DOMElement | null;
+ readonly childElementCount: number;
+ readonly localName: string;
+ readonly namespaceURI: string | null;
+ readonly prefix: string | null;
+ readonly tagName: string;
+ readonly attributes: DOMNamedNodeMap;
+ readonly shadowRoot: DOMShadowRoot | null;
+ readonly assignedSlot: DOMSlotElement | null;
+ id: string;
+ className: string;
+ get classList(): DOMTokenList;
+ set classList(value: string);
+ slot: string;
+ innerHTML: string;
+ getAttribute(name: string): string | null;
+ getAttributeNS(namespace: string | null, name: string): string | null;
+ getAttributeNames(): string[];
+ hasAttribute(name: string): boolean;
+ hasAttributeNS(namespace: string | null, name: string): boolean;
+ hasAttributes(): boolean;
+ setAttribute(name: string, value: string): void;
+ setAttributeNS(namespace: string | null, name: string, value: string): void;
+ removeAttribute(name: string): void;
+ removeAttributeNS(namespace: string | null, name: string): void;
+ toggleAttribute(name: string, force?: boolean): boolean;
+ getAttributeNode(name: string): DOMAttr | null;
+ getAttributeNodeNS(namespace: string | null, name: string): DOMAttr | null;
+ setAttributeNode(attr: DOMAttr): DOMAttr | null;
+ setAttributeNodeNS(attr: DOMAttr): DOMAttr | null;
+ removeAttributeNode(attr: DOMAttr): DOMAttr;
+ attachShadow(init: {
+ mode: 'open' | 'closed';
+ delegatesFocus?: boolean;
+ slotAssignment?: 'named' | 'manual';
+ clonable?: boolean;
+ serializable?: boolean;
+ }): DOMShadowRoot;
+ getHTML(options?: {
+ serializableShadowRoots?: boolean;
+ shadowRoots?: readonly DOMShadowRoot[];
+ }): string;
+ setHTMLUnsafe(html: string): void;
+}
+
+export interface DOMHTMLElement extends DOMElement {
+ tabIndex: number;
+ hidden: boolean | string;
+ inert: boolean;
+ contentEditable: string;
+ readonly isContentEditable: boolean;
+ focus(options?: { preventScroll?: boolean; focusVisible?: boolean }): void;
+ blur(): void;
+ click(): void;
+}
+
+export interface DOMAttr extends DOMNode {
+ readonly name: string;
+ readonly localName: string;
+ readonly namespaceURI: string | null;
+ readonly prefix: string | null;
+ readonly ownerElement: DOMElement | null;
+ readonly specified: boolean;
+ value: string;
+}
+
+export interface DOMCharacterData extends DOMNode, DOMChildNode {
+ data: string;
+ readonly length: number;
+ substringData(offset: number, count: number): string;
+ appendData(data: string): void;
+ insertData(offset: number, data: string): void;
+ deleteData(offset: number, count: number): void;
+ replaceData(offset: number, count: number, data: string): void;
+}
+
+export interface DOMText extends DOMCharacterData {
+ readonly wholeText: string;
+ splitText(offset: number): DOMText;
+}
+
+export interface DOMDocumentFragment extends DOMNode, DOMParentNode {}
+
+export interface DOMDocument extends DOMNode, DOMParentNode {
+ readonly documentElement: DOMElement | null;
+ body: DOMHTMLElement | null;
+ readonly activeElement: DOMElement | null;
+ createElement(name: K): DOMElements[K];
+ createElement(name: string): DOMHTMLElement;
+ createElementNS(namespace: string | null, name: string): DOMElement;
+ createTextNode(data: string): DOMText;
+ createComment(data: string): DOMCharacterData;
+ createDocumentFragment(): DOMDocumentFragment;
+ createAttribute(name: string): DOMAttr;
+ createAttributeNS(namespace: string | null, name: string): DOMAttr;
+ getElementById(id: string): DOMElement | null;
+ adoptNode(node: DOMNode): DOMNode;
+ importNode(node: DOMNode, deep?: boolean): DOMNode;
+ createRange(): DOMRange;
+ getSelection(): DOMSelection | null;
+ createTreeWalker(
+ root: DOMNode,
+ whatToShow?: number,
+ filter?: { acceptNode(node: DOMNode): number } | ((node: DOMNode) => number) | null,
+ ): DOMTreeWalker;
+}
+
+export interface DOMTreeWalker {
+ readonly root: DOMNode;
+ readonly whatToShow: number;
+ readonly filter: { acceptNode(node: DOMNode): number } | ((node: DOMNode) => number) | null;
+ currentNode: DOMNode;
+ nextNode(): DOMNode | null;
+ parentNode(): DOMNode | null;
+ firstChild(): DOMNode | null;
+ lastChild(): DOMNode | null;
+ previousSibling(): DOMNode | null;
+ nextSibling(): DOMNode | null;
+ previousNode(): DOMNode | null;
+}
+
+export interface DOMTokenList extends Iterable {
+ value: string;
+ readonly length: number;
+ readonly [index: number]: string;
+ item(index: number): string | null;
+ contains(token: string): boolean;
+ add(...tokens: string[]): void;
+ remove(...tokens: string[]): void;
+ toggle(token: string, force?: boolean): boolean;
+ replace(token: string, replacement: string): boolean;
+ supports(token: string): boolean;
+ entries(): IterableIterator<[number, string]>;
+ keys(): IterableIterator;
+ values(): IterableIterator;
+ forEach(
+ callback: (value: string, index: number, list: DOMTokenList) => void,
+ thisArg?: unknown,
+ ): void;
+ toString(): string;
+}
diff --git a/packages/dom/src/compatibility/contracts/observation.ts b/packages/dom/src/compatibility/contracts/observation.ts
new file mode 100644
index 0000000..b21414a
--- /dev/null
+++ b/packages/dom/src/compatibility/contracts/observation.ts
@@ -0,0 +1,41 @@
+import type { DOMNode } from './nodes.ts';
+import type { DOMNodeList } from './collections.ts';
+
+export interface DOMMutationObserverInit {
+ childList?: boolean;
+ attributes?: boolean;
+ characterData?: boolean;
+ subtree?: boolean;
+ attributeOldValue?: boolean;
+ characterDataOldValue?: boolean;
+ attributeFilter?: string[];
+}
+
+/** https://dom.spec.whatwg.org/#interface-mutationrecord */
+export interface DOMMutationRecord {
+ readonly type: string;
+ readonly target: DOMNode;
+ readonly addedNodes: DOMNodeList;
+ readonly removedNodes: DOMNodeList;
+ readonly previousSibling: DOMNode | null;
+ readonly nextSibling: DOMNode | null;
+ readonly attributeName: string | null;
+ readonly attributeNamespace: string | null;
+ readonly oldValue: string | null;
+}
+
+export interface DOMMutationObserver {
+ observe(target: DOMNode, options?: DOMMutationObserverInit): void;
+ takeRecords(): DOMMutationRecord[];
+ disconnect(): void;
+}
+
+export interface DOMMutationObserverConstructor {
+ new (
+ callback: (
+ this: DOMMutationObserver,
+ records: DOMMutationRecord[],
+ observer: DOMMutationObserver,
+ ) => void,
+ ): DOMMutationObserver;
+}
diff --git a/packages/dom/src/compatibility/contracts/selection.ts b/packages/dom/src/compatibility/contracts/selection.ts
new file mode 100644
index 0000000..143d7e3
--- /dev/null
+++ b/packages/dom/src/compatibility/contracts/selection.ts
@@ -0,0 +1,75 @@
+import type { DOMDocumentFragment, DOMNode } from './nodes.ts';
+import type { DOMShadowRoot } from './shadow.ts';
+
+/** https://dom.spec.whatwg.org/#interface-staticrange */
+export interface DOMStaticRange {
+ readonly startContainer: DOMNode;
+ readonly startOffset: number;
+ readonly endContainer: DOMNode;
+ readonly endOffset: number;
+ readonly collapsed: boolean;
+}
+
+/** https://dom.spec.whatwg.org/#interface-range */
+export interface DOMRange extends DOMStaticRange {
+ readonly commonAncestorContainer: DOMNode;
+ readonly START_TO_START: 0;
+ readonly START_TO_END: 1;
+ readonly END_TO_END: 2;
+ readonly END_TO_START: 3;
+ setStart(node: DOMNode, offset: number): void;
+ setEnd(node: DOMNode, offset: number): void;
+ setStartBefore(node: DOMNode): void;
+ setStartAfter(node: DOMNode): void;
+ setEndBefore(node: DOMNode): void;
+ setEndAfter(node: DOMNode): void;
+ selectNode(node: DOMNode): void;
+ selectNodeContents(node: DOMNode): void;
+ collapse(toStart?: boolean): void;
+ compareBoundaryPoints(how: number, sourceRange: DOMRange): number;
+ comparePoint(node: DOMNode, offset: number): number;
+ isPointInRange(node: DOMNode, offset: number): boolean;
+ intersectsNode(node: DOMNode): boolean;
+ cloneRange(): DOMRange;
+ cloneContents(): DOMDocumentFragment;
+ extractContents(): DOMDocumentFragment;
+ deleteContents(): void;
+ insertNode(node: DOMNode): void;
+ surroundContents(newParent: DOMNode): void;
+ detach(): void;
+ toString(): string;
+}
+
+/** https://w3c.github.io/selection-api/#selection-interface */
+export interface DOMSelection {
+ readonly anchorNode: DOMNode | null;
+ readonly anchorOffset: number;
+ readonly focusNode: DOMNode | null;
+ readonly focusOffset: number;
+ readonly isCollapsed: boolean;
+ readonly rangeCount: number;
+ readonly type: string;
+ readonly direction: string;
+ addRange(range: DOMRange): void;
+ removeRange(range: DOMRange): void;
+ removeAllRanges(): void;
+ empty(): void;
+ getRangeAt(index: number): DOMRange;
+ getComposedRanges(options?: { shadowRoots?: readonly DOMShadowRoot[] }): DOMStaticRange[];
+ collapse(node: DOMNode | null, offset?: number): void;
+ setPosition(node: DOMNode | null, offset?: number): void;
+ collapseToStart(): void;
+ collapseToEnd(): void;
+ extend(node: DOMNode, offset?: number): void;
+ setBaseAndExtent(
+ anchorNode: DOMNode,
+ anchorOffset: number,
+ focusNode: DOMNode,
+ focusOffset: number,
+ ): void;
+ selectAllChildren(node: DOMNode): void;
+ modify(alter?: string, direction?: string, granularity?: string): void;
+ deleteFromDocument(): void;
+ containsNode(node: DOMNode, allowPartialContainment?: boolean): boolean;
+ toString(): string;
+}
diff --git a/packages/dom/src/compatibility/contracts/shadow.ts b/packages/dom/src/compatibility/contracts/shadow.ts
new file mode 100644
index 0000000..53de4a8
--- /dev/null
+++ b/packages/dom/src/compatibility/contracts/shadow.ts
@@ -0,0 +1,47 @@
+import type { DOMDocumentFragment, DOMElement, DOMHTMLElement, DOMNode, DOMText } from './nodes.ts';
+
+export interface DOMShadowRootInit {
+ mode: 'open' | 'closed';
+ delegatesFocus?: boolean;
+ slotAssignment?: 'named' | 'manual';
+ clonable?: boolean;
+ serializable?: boolean;
+}
+export interface DOMHTMLSerializationOptions {
+ serializableShadowRoots?: boolean;
+ shadowRoots?: readonly DOMShadowRoot[];
+}
+export interface DOMShadowRoot extends DOMDocumentFragment {
+ readonly host: DOMElement;
+ readonly mode: 'open' | 'closed';
+ readonly delegatesFocus: boolean;
+ readonly slotAssignment: 'named' | 'manual';
+ readonly clonable: boolean;
+ readonly serializable: boolean;
+ readonly activeElement: DOMElement | null;
+ innerHTML: string;
+ getHTML(options?: DOMHTMLSerializationOptions): string;
+ setHTMLUnsafe(html: string): void;
+}
+export interface DOMSlotElement extends DOMHTMLElement {
+ name: string;
+ assignedNodes(options?: { flatten?: boolean }): DOMNode[];
+ assignedElements(options?: { flatten?: boolean }): DOMElement[];
+ assign(...nodes: (DOMElement | DOMText)[]): void;
+}
+export interface DOMTemplateElement extends DOMHTMLElement {
+ readonly content: DOMDocumentFragment;
+}
+export interface DOMCustomElementConstructor {
+ new (): DOMHTMLElement;
+}
+export interface DOMCustomElementRegistry {
+ define(
+ name: string,
+ constructor: DOMCustomElementConstructor,
+ options?: { extends?: string },
+ ): void;
+ get(name: string): DOMCustomElementConstructor | undefined;
+ whenDefined(name: string): Promise;
+ upgrade(root: DOMNode): void;
+}
diff --git a/packages/dom/src/compatibility/custom-elements/adoption.ts b/packages/dom/src/compatibility/custom-elements/adoption.ts
new file mode 100644
index 0000000..83c9d17
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/adoption.ts
@@ -0,0 +1,48 @@
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions';
+const requiredGlobals = ['HTMLElement', 'customElements', 'Document'] as const;
+export { requiredGlobals as globals };
+export const requires = ['customElements.registration'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('delivers adoptedCallback with old and new documents', ({ document, globals }) => {
+ const HTMLElement = globals('HTMLElement');
+ const Document = globals('Document');
+ const other = new Document();
+ const registry = globals('customElements');
+ const name = uniqueName();
+ const calls: unknown[][] = [];
+ class Item extends HTMLElement {
+ adoptedCallback(oldDocument: unknown, newDocument: unknown): void {
+ calls.push([oldDocument, newDocument]);
+ }
+ }
+ registry.define(name, Item);
+ const item = document.createElement(name);
+ other.adoptNode(item);
+ expect(item.ownerDocument).toBe(other);
+ expect(calls).toEqual([[document, other]]);
+ });
+
+ context.test('adoption callback receives exact old and new documents', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement,
+ registry = fixture.globals('customElements') as unknown as CustomElementRegistry,
+ name = uniqueName();
+ const calls: unknown[] = [];
+ class Item extends Base {
+ adoptedCallback(old: Document, next: Document): void {
+ calls.push([old, next]);
+ }
+ }
+ registry.define(name, Item);
+ const e = document.createElement(name),
+ other = document.implementation.createHTMLDocument('');
+ other.adoptNode(e);
+ expect(calls).toEqual([[document, other]]);
+ expect(e.ownerDocument).toBe(other);
+ });
+}
diff --git a/packages/dom/src/compatibility/custom-elements/connection.ts b/packages/dom/src/compatibility/custom-elements/connection.ts
new file mode 100644
index 0000000..b712de2
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/connection.ts
@@ -0,0 +1,64 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions';
+const requiredGlobals = ['HTMLElement', 'customElements'] as const;
+export { requiredGlobals as globals };
+export const requires = ['customElements.registration'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'delivers connection and disconnection when a defined element moves',
+ ({ document, globals, connect }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ const calls: string[] = [];
+ class Item extends HTMLElement {
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ disconnectedCallback(): void {
+ calls.push('disconnected');
+ }
+ }
+ registry.define(name, Item);
+ const root = document.createElement('div');
+ connect(root);
+ const item = document.createElement(name);
+ expect(calls).toEqual([]);
+ root.append(item);
+ item.remove();
+ root.append(item);
+ expect(calls).toEqual(['connected', 'disconnected', 'connected']);
+ },
+ );
+
+ context.test('moving connected elements invokes disconnect then reconnect', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement,
+ registry = fixture.globals('customElements') as unknown as CustomElementRegistry,
+ name = uniqueName();
+ const seen: string[] = [];
+ class Item extends Base {
+ connectedCallback(): void {
+ seen.push('connected');
+ }
+ disconnectedCallback(): void {
+ seen.push('disconnected');
+ }
+ }
+ registry.define(name, Item);
+ const p = document.createElement('div'),
+ q = document.createElement('div');
+ fixture.connect(p as unknown as DOMElementContract);
+ fixture.connect(q as unknown as DOMElementContract);
+ const e = document.createElement(name);
+ p.append(e);
+ q.append(e);
+ e.remove();
+ expect(seen).toEqual(['connected', 'disconnected', 'connected', 'disconnected']);
+ });
+}
diff --git a/packages/dom/src/compatibility/custom-elements/construction.ts b/packages/dom/src/compatibility/custom-elements/construction.ts
new file mode 100644
index 0000000..a852eae
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/construction.ts
@@ -0,0 +1,52 @@
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/dom.html#htmlconstructor';
+const requiredGlobals = ['HTMLElement', 'customElements'] as const;
+export { requiredGlobals as globals };
+export const requires = ['customElements.registration'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'constructs defined autonomous elements directly and through createElement',
+ ({ document, globals }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ let constructions = 0;
+ class Item extends HTMLElement {
+ constructor() {
+ super();
+ constructions++;
+ }
+ }
+ registry.define(name, Item);
+ const direct = new Item();
+ const created = document.createElement(name);
+ expect(direct.localName).toBe(name);
+ expect(direct.ownerDocument).toBe(document);
+ expect(created).toBeInstanceOf(Item);
+ expect(created.ownerDocument).toBe(document);
+ expect(constructions).toBe(2);
+ },
+ );
+
+ context.test(
+ 'creates distinct registered instances owned by the requesting document',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement,
+ registry = fixture.globals('customElements') as unknown as CustomElementRegistry,
+ name = uniqueName();
+ class Item extends Base {}
+ registry.define(name, Item);
+ const a = document.createElement(name),
+ b = document.createElement(name);
+ expect(a).toBeInstanceOf(Item);
+ expect(b).toBeInstanceOf(Item);
+ expect(a).not.toBe(b);
+ expect(a.ownerDocument).toBe(document);
+ expect(a.localName).toBe(name);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/custom-elements/exception-reporting.ts b/packages/dom/src/compatibility/custom-elements/exception-reporting.ts
new file mode 100644
index 0000000..dd58133
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/exception-reporting.ts
@@ -0,0 +1,43 @@
+import type { CompatibilityContext } from '../context.ts';
+import type { DOMEvent } from '../contracts/events.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#invoke-custom-element-reactions';
+const requiredGlobals = ['HTMLElement', 'customElements', 'window', 'ErrorEvent'] as const;
+export { requiredGlobals as globals };
+export const requires = ['customElements.observedAttributes'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'reports callback failures as error events without throwing from the mutation',
+ ({ document, globals, cleanup }) => {
+ const HTMLElement = globals('HTMLElement');
+ const ErrorEvent = globals('ErrorEvent');
+ const window = globals('window');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ const failure = new TypeError('custom element callback failure');
+ const errors: unknown[] = [];
+ const listener = (event: DOMEvent): void => {
+ if (event instanceof ErrorEvent) {
+ if (event.error !== failure) return;
+ errors.push(event.error);
+ event.preventDefault();
+ }
+ };
+ window.addEventListener('error', listener);
+ cleanup(() => window.removeEventListener('error', listener));
+ class Item extends HTMLElement {
+ static observedAttributes = ['observed'];
+ attributeChangedCallback(): void {
+ throw failure;
+ }
+ }
+ registry.define(name, Item);
+ const item = document.createElement(name);
+ expect(() => item.setAttribute('observed', 'value')).not.toThrow();
+ expect(errors).toEqual([failure]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/custom-elements/form-association.ts b/packages/dom/src/compatibility/custom-elements/form-association.ts
new file mode 100644
index 0000000..85a2442
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/form-association.ts
@@ -0,0 +1,265 @@
+import type { CompatibilityContext, DOMFixture } from '../context.ts';
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import { uniqueName } from '../unique-name.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-face-example';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/custom-elements/form-associated/form-associated-callback.html';
+export const globals = ['HTMLElement', 'customElements', 'FormData'] as const;
+export const requires = [
+ 'customElements.registration',
+ 'customElements.construction',
+ 'elements.form.formData',
+ 'elements.form.reset',
+ 'elements.form.validation',
+ 'events.dispatch.capture',
+] as const;
+
+// Adapted and extended from custom-elements/form-associated WPT cases at
+// b719612f963a05ece9c89a7dd7598c1c59618874. Copyright © web-platform-tests
+// contributors. BSD-3-Clause; see ../../../test/wpt/LICENSE.txt and the
+// complete file inventory in ../../../docs/wpt-css-forms.md.
+
+interface FormControl extends HTMLElement {
+ readonly internals: ElementInternals;
+ readonly associations: (HTMLFormElement | null)[];
+ readonly disabledChanges: boolean[];
+ readonly resets: string[];
+ observeReset: HTMLInputElement | undefined;
+}
+
+function defineControl(fixture: DOMFixture): FormControl {
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement;
+ const registry = fixture.globals('customElements') as unknown as CustomElementRegistry;
+ class Control extends Base {
+ static formAssociated = true;
+ readonly internals = this.attachInternals();
+ readonly associations: (HTMLFormElement | null)[] = [];
+ readonly disabledChanges: boolean[] = [];
+ readonly resets: string[] = [];
+ observeReset: HTMLInputElement | undefined;
+ formAssociatedCallback(form: HTMLFormElement | null): void {
+ this.associations.push(form);
+ }
+ formDisabledCallback(disabled: boolean): void {
+ this.disabledChanges.push(disabled);
+ }
+ formResetCallback(): void {
+ this.resets.push(this.observeReset?.value ?? 'reset');
+ }
+ }
+ registry.define(uniqueName(), Control);
+ return new Control();
+}
+
+/** Registers custom element form ownership, entry-list and lifecycle interactions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'updates ownership, form collections and callbacks on association changes',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const control = defineControl(fixture),
+ first = document.createElement('form'),
+ second = document.createElement('form');
+ second.id = uniqueName();
+ fixture.connect(first as unknown as DOMElementContract);
+ fixture.connect(second as unknown as DOMElementContract);
+ expect(control.internals.form).toBeNull();
+ expect(control.associations).toEqual([]);
+ first.append(control);
+ expect(control.internals.form).toBe(first);
+ expect(Array.from(first.elements)).toEqual([control]);
+ control.setAttribute('form', second.id);
+ expect(control.internals.form).toBe(second);
+ expect(first.elements.length).toBe(0);
+ expect(Array.from(second.elements)).toEqual([control]);
+ control.setAttribute('form', 'missing-form');
+ expect(control.internals.form).toBeNull();
+ control.removeAttribute('form');
+ expect(control.internals.form).toBe(first);
+ control.remove();
+ expect(control.internals.form).toBeNull();
+ expect(control.associations).toEqual([first, second, null, first, null]);
+ },
+ ['elements.form.controls'],
+ );
+ context.test(
+ 'contributes string values in tree order and null removes the contribution',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Data = fixture.globals('FormData') as unknown as typeof FormData;
+ const control = defineControl(fixture),
+ form = document.createElement('form'),
+ before = document.createElement('input'),
+ after = document.createElement('input');
+ before.name = after.name = 'entry';
+ before.value = 'before';
+ after.value = 'after';
+ control.setAttribute('name', 'entry');
+ form.append(before, control, after);
+ control.internals.setFormValue('custom');
+ expect(new Data(form).getAll('entry')).toEqual(['before', 'custom', 'after']);
+ control.internals.setFormValue(null);
+ expect(new Data(form).getAll('entry')).toEqual(['before', 'after']);
+ control.internals.setFormValue('');
+ expect(new Data(form).getAll('entry')).toEqual(['before', '', 'after']);
+ },
+ );
+ context.test(
+ 'copies FormData entries at setFormValue time and preserves their own names',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Data = fixture.globals('FormData') as unknown as typeof FormData;
+ const control = defineControl(fixture),
+ form = document.createElement('form'),
+ entries = new Data();
+ control.setAttribute('name', 'ignored');
+ form.append(control);
+ entries.append('first', 'one');
+ entries.append('first', 'two');
+ entries.append('second', 'three');
+ control.internals.setFormValue(entries);
+ entries.set('first', 'changed');
+ expect([...new Data(form)]).toEqual([
+ ['first', 'one'],
+ ['first', 'two'],
+ ['second', 'three'],
+ ]);
+ control.internals.setFormValue(new Data());
+ expect([...new Data(form)]).toEqual([]);
+ },
+ );
+ context.test(
+ 'keeps validity live and reports invalid events through the owning form',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const control = defineControl(fixture),
+ form = document.createElement('form');
+ form.append(control);
+ const internals = control.internals,
+ validity = internals.validity;
+ expect(validity.valid).toBe(true);
+ expect(internals.willValidate).toBe(true);
+ expect(internals.validationMessage).toBe('');
+ const seen: unknown[] = [];
+ form.addEventListener(
+ 'invalid',
+ (event) => {
+ seen.push([event.target, event.bubbles, event.cancelable]);
+ event.preventDefault();
+ },
+ true,
+ );
+ internals.setValidity({ valueMissing: true }, 'Required');
+ expect(internals.validity).toBe(validity);
+ expect(validity.valueMissing).toBe(true);
+ expect(validity.valid).toBe(false);
+ expect(form.checkValidity()).toBe(false);
+ expect(seen).toEqual([[control, false, true]]);
+ internals.setValidity({ customError: true }, 'Custom');
+ expect(validity.valueMissing).toBe(false);
+ expect(validity.customError).toBe(true);
+ expect(internals.validationMessage).toBe('Custom');
+ internals.setValidity({});
+ expect(validity.valid).toBe(true);
+ expect(form.checkValidity()).toBe(true);
+ expect(internals.validationMessage).toBe('');
+ expect(() => internals.setValidity({ valueMissing: true })).toThrow(
+ expect.objectContaining({ name: 'TypeError' }),
+ );
+ expect(validity.valid).toBe(true);
+ expect(internals.validationMessage).toBe('');
+ expect(seen.length).toBe(1);
+ },
+ );
+ context.test(
+ 'disabled inheritance updates callbacks, validation and submitted entries',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Data = fixture.globals('FormData') as unknown as typeof FormData;
+ const control = defineControl(fixture),
+ form = document.createElement('form'),
+ fieldset = document.createElement('fieldset');
+ control.setAttribute('name', 'custom');
+ control.internals.setFormValue('value');
+ fieldset.append(control);
+ form.append(fieldset);
+ fieldset.disabled = true;
+ expect(control.internals.willValidate).toBe(false);
+ expect(new Data(form).get('custom')).toBeNull();
+ control.setAttribute('disabled', '');
+ control.removeAttribute('disabled');
+ expect(control.disabledChanges).toEqual([true]);
+ fieldset.disabled = false;
+ expect(control.internals.willValidate).toBe(true);
+ expect(new Data(form).get('custom')).toBe('value');
+ expect(control.disabledChanges).toEqual([true, false]);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ context.test(
+ 'runs reset reactions after built-in control reset and respects cancellation',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const control = defineControl(fixture),
+ form = document.createElement('form'),
+ input = document.createElement('input');
+ input.defaultValue = 'default';
+ input.value = 'dirty';
+ control.observeReset = input;
+ form.append(control, input);
+ form.reset();
+ expect(control.resets).toEqual(['default']);
+ input.value = 'dirty again';
+ form.addEventListener('reset', (event) => event.preventDefault());
+ form.reset();
+ expect(control.resets).toEqual(['default']);
+ expect(input.value).toBe('dirty again');
+ },
+ );
+ context.test(
+ 'associates explicit and wrapping labels in tree order',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const control = defineControl(fixture),
+ parent = document.createElement('div'),
+ explicit = document.createElement('label'),
+ wrapper = document.createElement('label');
+ control.id = uniqueName();
+ explicit.htmlFor = control.id;
+ wrapper.append(control);
+ parent.append(explicit, wrapper);
+ fixture.connect(parent as unknown as DOMElementContract);
+ expect(explicit.control).toBe(control);
+ expect(wrapper.control).toBe(control);
+ expect(Array.from(control.internals.labels)).toEqual([explicit, wrapper]);
+ explicit.htmlFor = 'missing';
+ expect(Array.from(control.internals.labels)).toEqual([wrapper]);
+ },
+ ['elements.label.association'],
+ );
+ context.test(
+ 'rejects form APIs on internals of non-form-associated custom elements',
+ (fixture) => {
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement;
+ const registry = fixture.globals('customElements') as unknown as CustomElementRegistry;
+ class Plain extends Base {}
+ registry.define(uniqueName(), Plain);
+ const internals = new Plain().attachInternals();
+ for (const operation of [
+ () => internals.form,
+ () => internals.labels,
+ () => internals.willValidate,
+ () => internals.validity,
+ () => internals.validationMessage,
+ () => internals.setFormValue('value'),
+ () => internals.setValidity({}),
+ () => internals.checkValidity(),
+ ]) {
+ expect(operation).toThrow(expect.objectContaining({ name: 'NotSupportedError' }));
+ }
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/custom-elements/observed-attributes.ts b/packages/dom/src/compatibility/custom-elements/observed-attributes.ts
new file mode 100644
index 0000000..2c73ad0
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/observed-attributes.ts
@@ -0,0 +1,69 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions';
+const requiredGlobals = ['HTMLElement', 'customElements'] as const;
+export { requiredGlobals as globals };
+export const requires = ['customElements.registration'] as const;
+// Coverage reference; this leaf is independently authored, not an imported WPT file.
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/custom-elements/reactions/Element.html';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'observes requested attributes including equal-value writes and removal',
+ ({ document, globals }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ const calls: unknown[][] = [];
+ class Item extends HTMLElement {
+ static observedAttributes = ['watched'];
+ attributeChangedCallback(
+ attributeName: string,
+ old: string | null,
+ value: string | null,
+ namespace: string | null,
+ ): void {
+ calls.push([attributeName, old, value, namespace]);
+ }
+ }
+ registry.define(name, Item);
+ const item = document.createElement(name);
+ item.setAttribute('other', 'ignored');
+ item.setAttribute('watched', 'one');
+ item.setAttribute('watched', 'one');
+ item.removeAttribute('watched');
+ expect(calls).toEqual([
+ ['watched', null, 'one', null],
+ ['watched', 'one', 'one', null],
+ ['watched', 'one', null, null],
+ ]);
+ },
+ );
+
+ context.test('initial upgrade reactions follow attribute order before connection', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement,
+ registry = fixture.globals('customElements') as unknown as CustomElementRegistry,
+ name = uniqueName();
+ const e = document.createElement(name);
+ e.setAttribute('b', '2');
+ e.setAttribute('a', '1');
+ fixture.connect(e as unknown as DOMElementContract);
+ const calls: unknown[] = [];
+ class Item extends Base {
+ static observedAttributes = ['a', 'b'];
+ attributeChangedCallback(attribute: string, old: string | null, value: string | null): void {
+ calls.push([attribute, old, value]);
+ }
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ }
+ registry.define(name, Item);
+ expect(calls).toEqual([['b', null, '2'], ['a', null, '1'], 'connected']);
+ });
+}
diff --git a/packages/dom/src/compatibility/custom-elements/reaction-ordering.ts b/packages/dom/src/compatibility/custom-elements/reaction-ordering.ts
new file mode 100644
index 0000000..1ef4e12
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/reaction-ordering.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#enqueue-a-custom-element-upgrade-reaction';
+const requiredGlobals = ['HTMLElement', 'customElements'] as const;
+export { requiredGlobals as globals };
+export const requires = [
+ 'customElements.upgrading',
+ 'customElements.observedAttributes',
+ 'customElements.connection',
+] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'upgrades connected candidates with existing attributes before connection callbacks',
+ ({ document, globals, connect }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ const root = document.createElement('div');
+ const item = document.createElement(name);
+ item.setAttribute('observed', 'value');
+ root.append(item);
+ connect(root);
+ const calls: string[] = [];
+ class Item extends HTMLElement {
+ static observedAttributes = ['observed'];
+ constructor() {
+ super();
+ calls.push('constructor');
+ }
+ attributeChangedCallback(): void {
+ calls.push('attribute');
+ }
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ }
+ registry.define(name, Item);
+ expect(calls).toEqual(['constructor', 'attribute', 'connected']);
+ expect(root.firstChild).toBe(item);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/custom-elements/reentrancy.ts b/packages/dom/src/compatibility/custom-elements/reentrancy.ts
new file mode 100644
index 0000000..1ac0071
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/reentrancy.ts
@@ -0,0 +1,33 @@
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/custom-elements.html#cereactions';
+const requiredGlobals = ['HTMLElement', 'customElements'] as const;
+export { requiredGlobals as globals };
+export const requires = ['customElements.observedAttributes'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'delivers nested attribute reactions before the nested mutation returns',
+ ({ document, globals }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ const calls: string[] = [];
+ class Item extends HTMLElement {
+ static observedAttributes = ['first', 'second'];
+ attributeChangedCallback(attributeName: string): void {
+ calls.push(attributeName + ':start');
+ if (attributeName === 'first') {
+ this.setAttribute('second', 'value');
+ }
+ calls.push(attributeName + ':end');
+ }
+ }
+ registry.define(name, Item);
+ const item = document.createElement(name);
+ item.setAttribute('first', 'value');
+ expect(calls).toEqual(['first:start', 'second:start', 'second:end', 'first:end']);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/custom-elements/registration.ts b/packages/dom/src/compatibility/custom-elements/registration.ts
new file mode 100644
index 0000000..3a19831
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/registration.ts
@@ -0,0 +1,57 @@
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#dom-customelementregistry-define';
+const requiredGlobals = ['HTMLElement', 'customElements'] as const;
+export { requiredGlobals as globals };
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('registers a constructor and resolves pending definitions', async ({ globals }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ class Item extends HTMLElement {}
+ const pending = registry.whenDefined(name);
+ expect(registry.get(name)).toBeUndefined();
+ registry.define(name, Item);
+ expect(registry.get(name)).toBe(Item);
+ expect(await pending).toBe(Item);
+ expect(await registry.whenDefined(name)).toBe(Item);
+ expect(() => registry.define(name, class extends HTMLElement {})).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ });
+ context.test('rejects names without a hyphen', ({ globals }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ expect(() => registry.define('invalidname', class extends HTMLElement {})).toThrow(
+ expect.objectContaining({ name: 'SyntaxError' }),
+ );
+ });
+
+ context.test('rejects reserved names and reuse of a constructor', (fixture) => {
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement;
+ const registry = fixture.globals('customElements') as unknown as CustomElementRegistry;
+ const name = uniqueName();
+ class Item extends Base {}
+ registry.define(name, Item);
+ expect(() => registry.define(uniqueName(), Item)).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ for (const invalid of ['annotation-xml', 'font-face', 'Upper-case'])
+ expect(() => registry.define(invalid, class extends Base {})).toThrow(
+ expect.objectContaining({ name: 'SyntaxError' }),
+ );
+ });
+
+ context.test(
+ 'rejects whenDefined for invalid names and reuses pending promises',
+ async (fixture) => {
+ const registry = fixture.globals('customElements') as unknown as CustomElementRegistry;
+ const name = uniqueName();
+ expect(registry.whenDefined(name)).toBe(registry.whenDefined(name));
+ await expect(registry.whenDefined('invalid')).rejects.toMatchObject({ name: 'SyntaxError' });
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/custom-elements/upgrading.ts b/packages/dom/src/compatibility/custom-elements/upgrading.ts
new file mode 100644
index 0000000..209f675
--- /dev/null
+++ b/packages/dom/src/compatibility/custom-elements/upgrading.ts
@@ -0,0 +1,65 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+import { uniqueName } from '../unique-name.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/custom-elements.html#dom-customelementregistry-upgrade';
+const requiredGlobals = ['HTMLElement', 'customElements'] as const;
+export { requiredGlobals as globals };
+export const requires = ['customElements.registration'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'upgrades a detached element in place, preserving its descendants',
+ ({ document, globals }) => {
+ const HTMLElement = globals('HTMLElement');
+ const registry = globals('customElements');
+ const name = uniqueName();
+ const root = document.createElement('div');
+ const element = document.createElement(name);
+ const text = document.createTextNode('preserved');
+ element.append(text);
+ root.append(element);
+ let constructed = 0;
+ class Item extends HTMLElement {
+ constructor() {
+ super();
+ constructed++;
+ }
+ }
+ registry.define(name, Item);
+ expect(constructed).toBe(0);
+ registry.upgrade(root);
+ expect(root.firstChild).toBe(element);
+ expect(element).toBeInstanceOf(Item);
+ expect(element.firstChild).toBe(text);
+ expect(constructed).toBe(1);
+ registry.upgrade(root);
+ expect(constructed).toBe(1);
+ },
+ );
+
+ context.test('upgrades connected candidates automatically in tree order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Base = fixture.globals('HTMLElement') as unknown as typeof HTMLElement,
+ registry = fixture.globals('customElements') as unknown as CustomElementRegistry,
+ name = uniqueName();
+ const p = document.createElement('div'),
+ a = document.createElement(name),
+ b = document.createElement(name);
+ p.append(a, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ const seen: unknown[] = [];
+ class Item extends Base {
+ constructor() {
+ super();
+ seen.push(this);
+ }
+ }
+ registry.define(name, Item);
+ expect(seen).toEqual([a, b]);
+ expect(p.firstChild).toBe(a);
+ registry.upgrade(p);
+ expect(seen).toEqual([a, b]);
+ });
+}
diff --git a/packages/dom/src/compatibility/document/basic-creation.ts b/packages/dom/src/compatibility/document/basic-creation.ts
new file mode 100644
index 0000000..96c0ba5
--- /dev/null
+++ b/packages/dom/src/compatibility/document/basic-creation.ts
@@ -0,0 +1,25 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-document-createelement';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('creates independent elements and text with stable names', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createElement('div'),
+ b = document.createElement('div');
+ expect(a).not.toBe(b);
+ expect(a.nodeName).toBe('DIV');
+ const t = document.createTextNode('abc');
+ expect(t.nodeName).toBe('#text');
+ expect(t.data).toBe('abc');
+ expect(document.createTextNode('')).not.toBe(t);
+ });
+ context.test('stores Unicode and empty text without sharing state', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createTextNode('😀\n'),
+ b = document.createTextNode('');
+ a.data = 'changed';
+ expect(b.data).toBe('');
+ expect(a.data).toBe('changed');
+ });
+}
diff --git a/packages/dom/src/compatibility/document/creation.ts b/packages/dom/src/compatibility/document/creation.ts
new file mode 100644
index 0000000..cf852cf
--- /dev/null
+++ b/packages/dom/src/compatibility/document/creation.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-document';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('creates distinct standard node kinds owned by the document', ({ document }) => {
+ expect(document.nodeType).toBe(9);
+ expect(document.ownerDocument).toBeNull();
+ const element = document.createElement('section');
+ const text = document.createTextNode('hello');
+ const comment = document.createComment('note');
+ const fragment = document.createDocumentFragment();
+ const attr = document.createAttribute('title');
+ for (const [node, type, name] of [
+ [element, 1, element.tagName],
+ [text, 3, '#text'],
+ [comment, 8, '#comment'],
+ [fragment, 11, '#document-fragment'],
+ [attr, 2, 'title'],
+ ] as const) {
+ expect(node.nodeType).toBe(type);
+ expect(node.nodeName).toBe(name);
+ expect(node.ownerDocument).toBe(document);
+ expect(node.parentNode).toBeNull();
+ }
+ expect(document.createElement('section')).not.toBe(element);
+ });
+
+ context.test('validates names before creating nodes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ for (const name of ['', 'a b', '', '1bad'])
+ expect(() => document.createElement(name)).toThrow(
+ expect.objectContaining({ name: 'InvalidCharacterError' }),
+ );
+ });
+
+ context.test('normalizes HTML tag and attribute names', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ expect(document.createElement('DiV').localName).toBe('div');
+ expect(document.createAttribute('TiTle').name).toBe('title');
+ expect(document.createComment('x').data).toBe('x');
+ expect(document.createDocumentFragment().childNodes.length).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/document/import-node.ts b/packages/dom/src/compatibility/document/import-node.ts
new file mode 100644
index 0000000..42cd3ab
--- /dev/null
+++ b/packages/dom/src/compatibility/document/import-node.ts
@@ -0,0 +1,41 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-document-importnode';
+const requiredGlobals = ['Document'] as const;
+export { requiredGlobals as globals };
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('imports a deep copy while retaining the source tree', ({ document, globals }) => {
+ const other = new (globals('Document'))();
+ const source = document.createElement('div');
+ source.appendChild(document.createTextNode('kept'));
+ const copy = other.importNode(source, true);
+ expect(copy).not.toBe(source);
+ expect(copy.ownerDocument).toBe(other);
+ expect(copy.firstChild?.ownerDocument).toBe(other);
+ expect(copy.firstChild).not.toBe(source.firstChild);
+ expect(copy.textContent).toBe('kept');
+ expect(source.firstChild?.parentNode).toBe(source);
+ });
+
+ context.test('imports shallow elements without moving originals or listeners', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const other = new (fixture.globals('Document'))();
+ const source = other.createElement('div');
+ source.setAttribute('id', 'x');
+ source.appendChild(other.createTextNode('text'));
+ const copy = document.importNode(source as unknown as Node, false) as Element;
+ expect(copy.ownerDocument).toBe(document);
+ expect(copy.childNodes.length).toBe(0);
+ expect(copy.getAttribute('id')).toBe('x');
+ expect(source.childNodes.length).toBe(1);
+ });
+
+ context.test('rejects importing a document', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ expect(() => document.importNode(document, true)).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ });
+}
diff --git a/packages/dom/src/compatibility/document/lookup.ts b/packages/dom/src/compatibility/document/lookup.ts
new file mode 100644
index 0000000..3ed073f
--- /dev/null
+++ b/packages/dom/src/compatibility/document/lookup.ts
@@ -0,0 +1,47 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-nonelementparentnode-getelementbyid';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'finds connected IDs in tree order and reflects removals',
+ ({ document, connect }) => {
+ const parent = document.createElement('div');
+ connect(parent);
+ const first = document.createElement('span');
+ const second = document.createElement('span');
+ first.id = 'compat-lookup';
+ second.id = first.id;
+ parent.appendChild(first);
+ parent.appendChild(second);
+ expect(document.getElementById(first.id)).toBe(first);
+ first.remove();
+ expect(document.getElementById(second.id)).toBe(second);
+ second.remove();
+ expect(document.getElementById('compat-lookup')).toBeNull();
+ },
+ );
+
+ context.test(
+ 'returns first duplicate id in current tree order and excludes detached nodes',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createElement('b');
+ a.id = b.id = 'duplicate';
+ p.append(a, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ expect(document.getElementById('duplicate')).toBe(a);
+ p.insertBefore(b, a);
+ expect(document.getElementById('duplicate')).toBe(b);
+ b.remove();
+ expect(document.getElementById('duplicate')).toBe(a);
+ a.remove();
+ expect(document.getElementById('duplicate')).toBeNull();
+ expect(document.getElementById('')).toBeNull();
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/document/parsing.ts b/packages/dom/src/compatibility/document/parsing.ts
new file mode 100644
index 0000000..92ccde3
--- /dev/null
+++ b/packages/dom/src/compatibility/document/parsing.ts
@@ -0,0 +1,51 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-parsing-and-serialization';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'parses independent HTML documents with inert script and decoded entities',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const parsed = new DOMParser().parseFromString(
+ 'A & Bx
',
+ 'text/html',
+ );
+ expect(parsed).not.toBe(document);
+ expect(parsed.title).toBe('A & B');
+ expect(parsed.body.querySelector('p')!.textContent).toBe('x');
+ expect(parsed.contentType).toBe('text/html');
+ },
+ );
+ context.test('parses XML with namespaces and preserves element name case', (fixture) => {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const parsed = new DOMParser().parseFromString(
+ '',
+ 'application/xml',
+ );
+ expect(parsed.documentElement.localName).toBe('Root');
+ expect(parsed.documentElement.namespaceURI).toBe('urn:p');
+ expect(parsed.documentElement.firstElementChild!.localName).toBe('Child');
+ expect(parsed.querySelector('parsererror')).toBeNull();
+ });
+ context.test('returns an XML error document for malformed input', (fixture) => {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const parsed = new DOMParser().parseFromString('', 'application/xml');
+ expect(parsed.getElementsByTagName('parsererror').length).toBeGreaterThan(0);
+ });
+ context.test('serializes XML namespaces and reparses equivalent trees', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const XMLSerializer = fixture.globals('XMLSerializer') as typeof globalThis.XMLSerializer,
+ DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const xml = document.implementation.createDocument('urn:p', 'p:Root');
+ xml.documentElement.setAttribute('title', 'a&<');
+ const text = new XMLSerializer().serializeToString(xml);
+ expect(text).toContain('xmlns:p="urn:p"');
+ expect(text).toContain('title="a&<"');
+ const parsed = new DOMParser().parseFromString(text, 'application/xml');
+ expect(parsed.documentElement.namespaceURI).toBe('urn:p');
+ expect(parsed.documentElement.getAttribute('title')).toBe('a&<');
+ });
+}
diff --git a/packages/dom/src/compatibility/document/xml-serialization.ts b/packages/dom/src/compatibility/document/xml-serialization.ts
new file mode 100644
index 0000000..0eb86b2
--- /dev/null
+++ b/packages/dom/src/compatibility/document/xml-serialization.ts
@@ -0,0 +1,162 @@
+import type { CompatibilityContext, DOMFixture } from '../context.ts';
+
+export const spec = 'https://w3c.github.io/DOM-Parsing/#dfn-xml-serialization';
+export const requires = ['document.xml', 'document.parsing'] as const;
+const requiredGlobals = ['DOMParser', 'XMLSerializer'] as const;
+export { requiredGlobals as globals };
+// Adapted from WPT domparsing/XMLSerializer-serializeToString.html.
+// Copyright © web-platform-tests contributors. BSD-3-Clause.
+// See ../../../test/wpt/LICENSE.txt and docs/wpt-ranges-parsing.md.
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/domparsing/XMLSerializer-serializeToString.html';
+
+const xmlns = 'http://www.w3.org/2000/xmlns/';
+function setup(fixture: DOMFixture): {
+ parse(source: string): Element;
+ serialize(node: Node): string;
+} {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const XMLSerializer = fixture.globals('XMLSerializer') as typeof globalThis.XMLSerializer;
+ const parser = new DOMParser(),
+ serializer = new XMLSerializer();
+ return {
+ parse: (source: string) => parser.parseFromString(source, 'application/xml').documentElement,
+ serialize: (node: Node) => serializer.serializeToString(node),
+ };
+}
+
+/** Registers XML namespace fixup, escaping, and serialization identity cases. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'resets the default namespace when a no-namespace child is moved below it',
+ (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('value');
+ const child = root.firstChild!;
+ const parent = root.ownerDocument.createElementNS('urn:outer', 'parent');
+ root.replaceChild(parent, child);
+ parent.appendChild(child);
+ expect(serialize(root)).toBe(
+ 'value',
+ );
+ expect((child as Element).hasAttribute('xmlns')).toBe(false);
+ expect(child.parentNode).toBe(parent);
+ },
+ );
+ for (const { name, source, expected } of [
+ {
+ name: 'omits a redundant empty default namespace declaration',
+ source: '',
+ expected: '',
+ },
+ {
+ name: 'omits a redundant nonempty default namespace declaration',
+ source: '',
+ expected: '',
+ },
+ {
+ name: 'preserves a necessary default namespace reset without repeating it on descendants',
+ source: '',
+ expected: '',
+ },
+ ]) {
+ context.test(name, (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ expect(serialize(parse(source))).toBe(expected);
+ });
+ }
+ context.test(
+ 'uses the nearest declaration for an attribute with a namespace but no prefix',
+ (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('');
+ const child = root.firstElementChild!;
+ child.setAttributeNS('urn:a', 'name', 'value');
+ expect(serialize(root)).toBe(
+ '',
+ );
+ expect(child.getAttributeNodeNS('urn:a', 'name')!.prefix).toBeNull();
+ },
+ );
+ context.test('reuses an existing attribute prefix for the same namespace', (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('');
+ root.firstElementChild!.setAttributeNS('urn:a', 'different:name', 'value');
+ expect(serialize(root)).toBe(
+ '',
+ );
+ expect(root.firstElementChild!.getAttributeNodeNS('urn:a', 'name')!.prefix).toBe('different');
+ });
+ context.test(
+ 'generates a prefix when an attribute prefix conflicts with an inherited declaration',
+ (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('');
+ root.firstElementChild!.setAttributeNS('urn:second', 'p:name', 'value');
+ expect(serialize(root)).toBe(
+ '',
+ );
+ const reparsed = parse(serialize(root));
+ expect(reparsed.firstElementChild!.getAttributeNS('urn:second', 'name')).toBe('value');
+ },
+ );
+ context.test(
+ 'accounts for conflicting xmlns attributes on the element being serialized',
+ (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('');
+ root.setAttributeNS('urn:first', 'p:name', 'value');
+ root.setAttributeNS(xmlns, 'xmlns:p', 'urn:second');
+ expect(serialize(root)).toBe(
+ '',
+ );
+ expect(root.getAttributeNodeNS('urn:first', 'name')!.prefix).toBe('p');
+ },
+ );
+ context.test('fixes element prefixes consistently in opening and closing tags', (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('');
+ const node = root.ownerDocument.createElementNS('urn:a', 'nested');
+ node.appendChild(root.ownerDocument.createElementNS('urn:a', 'inner'));
+ root.firstElementChild!.appendChild(node);
+ expect(serialize(root)).toBe(
+ '',
+ );
+ });
+ context.test(
+ 'serializes document fragments and preserves children of HTML void elements in XML',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const { serialize } = setup(fixture);
+ const fragment = document.createDocumentFragment();
+ const image = document.createElement('img');
+ image.append(document.createElement('style'));
+ fragment.append(image, document.createElement('span'));
+ expect(serialize(fragment)).toBe(
+ '
',
+ );
+ expect(fragment.firstChild).toBe(image);
+ expect(fragment.childNodes.length).toBe(2);
+ },
+ );
+ context.test(
+ 'escapes XML attribute characters and round trips their original values',
+ (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('');
+ root.setAttribute('value', '<>&"\'');
+ expect(serialize(root)).toBe('');
+ expect(parse(serialize(root)).getAttribute('value')).toBe('<>&"\'');
+ },
+ );
+ context.test(
+ 'emits attribute nodes as empty strings even when their values are nonempty',
+ (fixture) => {
+ const { parse, serialize } = setup(fixture);
+ const root = parse('');
+ root.setAttribute('value', '<>&"\'');
+ expect(serialize(root.getAttributeNode('value')!)).toBe('');
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/document/xml.ts b/packages/dom/src/compatibility/document/xml.ts
new file mode 100644
index 0000000..018f8e5
--- /dev/null
+++ b/packages/dom/src/compatibility/document/xml.ts
@@ -0,0 +1,43 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#interface-domimplementation';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('creates XML documents with namespaced roots and doctypes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const impl = document.implementation;
+ const dt = impl.createDocumentType('p:root', 'public', 'system');
+ const xml = impl.createDocument('urn:test', 'p:root', dt);
+ expect(xml.contentType).toBe('application/xml');
+ expect(xml.documentElement.namespaceURI).toBe('urn:test');
+ expect(xml.documentElement.prefix).toBe('p');
+ expect(xml.doctype).toBe(dt);
+ expect(dt.ownerDocument).toBe(xml);
+ expect(dt.publicId).toBe('public');
+ expect(xml.createElement('MiXeD').tagName).toBe('MiXeD');
+ });
+ context.test('validates qualified names and namespace constraints', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ for (const operation of [
+ () => document.createElement('a b'),
+ () => document.createAttribute('a b'),
+ ])
+ expect(operation).toThrow(expect.objectContaining({ name: 'InvalidCharacterError' }));
+ expect(() => document.createElementNS(null, 'p:x')).toThrow(
+ expect.objectContaining({ name: 'NamespaceError' }),
+ );
+ expect(() => document.createElementNS('urn:x', 'xml:x')).toThrow(
+ expect.objectContaining({ name: 'NamespaceError' }),
+ );
+ });
+ context.test('creates independent HTML skeletons with title text', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const other = document.implementation.createHTMLDocument('');
+ expect(other).not.toBe(document);
+ expect(other.doctype!.name).toBe('html');
+ expect(other.head).not.toBeNull();
+ expect(other.body).not.toBeNull();
+ expect(other.title).toBe('');
+ expect(other.head.querySelector('title')!.textContent).toBe('');
+ });
+}
diff --git a/packages/dom/src/compatibility/element/attribute-nodes.ts b/packages/dom/src/compatibility/element/attribute-nodes.ts
new file mode 100644
index 0000000..f8413f1
--- /dev/null
+++ b/packages/dom/src/compatibility/element/attribute-nodes.ts
@@ -0,0 +1,59 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-attr';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'moves ownership only after validating attribute node attachment',
+ ({ document }) => {
+ const first = document.createElement('div');
+ const second = document.createElement('div');
+ const attr = document.createAttribute('title');
+ attr.value = 'one';
+ first.setAttributeNode(attr);
+ expect(() => second.setAttributeNode(attr)).toThrow(
+ expect.objectContaining({ name: 'InUseAttributeError' }),
+ );
+ expect(attr.ownerElement).toBe(first);
+ expect(second.attributes.length).toBe(0);
+ const replacement = document.createAttribute('title');
+ replacement.value = 'two';
+ expect(first.setAttributeNode(replacement)).toBe(attr);
+ expect(attr.ownerElement).toBeNull();
+ expect(replacement.ownerElement).toBe(first);
+ expect(first.removeAttributeNode(replacement)).toBe(replacement);
+ expect(replacement.ownerElement).toBeNull();
+ },
+ );
+
+ context.test('retains Attr identity on value changes and detaches replaced Attr', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.setAttribute('title', 'a');
+ const a = e.getAttributeNode('title')!;
+ e.setAttribute('title', 'b');
+ expect(e.getAttributeNode('title')).toBe(a);
+ expect(a.value).toBe('b');
+ const replacement = document.createAttribute('title');
+ replacement.value = 'c';
+ expect(e.setAttributeNode(replacement)).toBe(a);
+ expect(a.ownerElement).toBeNull();
+ expect(replacement.ownerElement).toBe(e);
+ });
+
+ context.test('rejects attributes in use and removing absent attributes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div'),
+ other = document.createElement('div'),
+ a = document.createAttribute('x');
+ e.setAttributeNode(a);
+ expect(() => other.setAttributeNode(a)).toThrow(
+ expect.objectContaining({ name: 'InUseAttributeError' }),
+ );
+ expect(() => other.removeAttributeNode(a)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ expect(a.ownerElement).toBe(e);
+ });
+}
diff --git a/packages/dom/src/compatibility/element/attributes.ts b/packages/dom/src/compatibility/element/attributes.ts
new file mode 100644
index 0000000..2d984aa
--- /dev/null
+++ b/packages/dom/src/compatibility/element/attributes.ts
@@ -0,0 +1,62 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-element';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'updates attributes coherently with presence and string coercion',
+ ({ document }) => {
+ const element = document.createElement('div');
+ expect(element.getAttribute('title')).toBeNull();
+ element.setAttribute('title', 'one');
+ const attr = element.getAttributeNode('title');
+ element.setAttribute('title', 'two');
+ expect(element.getAttributeNode('title')).toBe(attr);
+ expect(attr?.value).toBe('two');
+ const node = document.createAttribute('count');
+ // @ts-expect-error -- Verify the standard DOMString conversion at runtime.
+ node.value = 42;
+ element.setAttributeNode(node);
+ expect(element.getAttribute('count')).toBe('42');
+ expect(element.hasAttributes()).toBe(true);
+ element.toggleAttribute('hidden', true);
+ expect(element.hasAttribute('hidden')).toBe(true);
+ element.toggleAttribute('hidden', false);
+ expect(element.hasAttribute('hidden')).toBe(false);
+ element.removeAttribute('title');
+ expect(attr?.ownerElement).toBeNull();
+ },
+ );
+
+ context.test(
+ 'distinguishes absent attributes from empty values and preserves order',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ expect(e.getAttribute('title')).toBeNull();
+ e.setAttribute('A', '1');
+ e.setAttribute('b', '2');
+ e.setAttribute('a', '3');
+ expect(e.getAttributeNames()).toEqual(['a', 'b']);
+ expect(e.getAttribute('A')).toBe('3');
+ e.removeAttribute('a');
+ e.setAttribute('a', '');
+ expect(e.getAttributeNames()).toEqual(['b', 'a']);
+ expect(e.hasAttribute('a')).toBe(true);
+ },
+ );
+
+ context.test('toggles presence with force and validates names', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ expect(e.toggleAttribute('x')).toBe(true);
+ expect(e.getAttribute('x')).toBe('');
+ expect(e.toggleAttribute('x', true)).toBe(true);
+ expect(e.toggleAttribute('x', false)).toBe(false);
+ expect(e.toggleAttribute('x', false)).toBe(false);
+ expect(() => e.setAttribute('bad name', 'x')).toThrow(
+ expect.objectContaining({ name: 'InvalidCharacterError' }),
+ );
+ });
+}
diff --git a/packages/dom/src/compatibility/element/child-elements.ts b/packages/dom/src/compatibility/element/child-elements.ts
new file mode 100644
index 0000000..8c2c5cd
--- /dev/null
+++ b/packages/dom/src/compatibility/element/child-elements.ts
@@ -0,0 +1,28 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-parentnode-children';
+export const requires = ['node.basicAppend'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('filters text nodes from children in tree order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('span'),
+ b = document.createElement('b');
+ expect(p.children.length).toBe(0);
+ p.appendChild(document.createTextNode('before'));
+ p.appendChild(a);
+ p.appendChild(document.createTextNode('between'));
+ p.appendChild(b);
+ expect(Array.from(p.children)).toEqual([a, b]);
+ });
+ context.test('includes only immediate element children', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('span'),
+ b = document.createElement('b');
+ a.appendChild(b);
+ p.appendChild(a);
+ expect(Array.from(p.children)).toEqual([a]);
+ });
+}
diff --git a/packages/dom/src/compatibility/element/class-list.ts b/packages/dom/src/compatibility/element/class-list.ts
new file mode 100644
index 0000000..2980a70
--- /dev/null
+++ b/packages/dom/src/compatibility/element/class-list.ts
@@ -0,0 +1,56 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-domtokenlist';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'maintains an ordered unique token set reflected in the class attribute',
+ ({ document }) => {
+ const element = document.createElement('div');
+ element.className = 'one two one';
+ const list = element.classList;
+ expect(Array.from(list)).toEqual(['one', 'two']);
+ list.add('three', 'two');
+ expect(element.getAttribute('class')).toBe('one two three');
+ list.replace('one', 'three');
+ expect(Array.from(list)).toEqual(['three', 'two']);
+ expect(list.toggle('two', false)).toBe(false);
+ expect(list.contains('two')).toBe(false);
+ element.setAttribute('class', 'fresh');
+ expect(list.item(0)).toBe('fresh');
+ expect(() => list.add('bad token')).toThrow(
+ expect.objectContaining({ name: 'InvalidCharacterError' }),
+ );
+ },
+ );
+
+ context.test('validates every token before multi-token mutation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.className = 'a';
+ for (const [token, name] of [
+ ['', 'SyntaxError'],
+ ['a b', 'InvalidCharacterError'],
+ ]) {
+ expect(() => e.classList.add('b', token!)).toThrow(expect.objectContaining({ name }));
+ expect(e.className).toBe('a');
+ }
+ expect(e.classList.item(99)).toBeNull();
+ });
+
+ context.test('preserves ordered uniqueness and replace semantics', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.className = 'a b a';
+ const list = e.classList;
+ expect(Array.from(list)).toEqual(['a', 'b']);
+ expect(list.replace('missing', 'c')).toBe(false);
+ expect(list.replace('a', 'b')).toBe(true);
+ expect(Array.from(list)).toEqual(['b']);
+ expect(list.toggle('b', true)).toBe(true);
+ expect(list.toggle('b', false)).toBe(false);
+ expect(list.length).toBe(0);
+ expect(e.classList).toBe(list);
+ });
+}
diff --git a/packages/dom/src/compatibility/element/dataset.ts b/packages/dom/src/compatibility/element/dataset.ts
new file mode 100644
index 0000000..a58a4e9
--- /dev/null
+++ b/packages/dom/src/compatibility/element/dataset.ts
@@ -0,0 +1,30 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/dom.html#dom-dataset';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('maps names and synchronizes attributes in both directions', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const data = e.dataset;
+ e.setAttribute('data-long-name', 'one');
+ expect(data.longName).toBe('one');
+ data.longName = 'two';
+ expect(e.getAttribute('data-long-name')).toBe('two');
+ delete data.longName;
+ expect(e.hasAttribute('data-long-name')).toBe(false);
+ expect(e.dataset).toBe(data);
+ });
+ context.test('coerces values and rejects ambiguous dashed property names', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ Reflect.set(e.dataset, 'x', null);
+ expect(e.dataset.x).toBe('null');
+ expect(() => {
+ e.dataset['bad-name'] = 'x';
+ }).toThrow(expect.objectContaining({ name: 'SyntaxError' }));
+ e.setAttribute('data-a-1', 'n');
+ expect(e.dataset['a-1']).toBe('n');
+ expect(Object.keys(e.dataset)).toEqual(['x', 'a-1']);
+ });
+}
diff --git a/packages/dom/src/compatibility/element/namespaces.ts b/packages/dom/src/compatibility/element/namespaces.ts
new file mode 100644
index 0000000..c8de3b1
--- /dev/null
+++ b/packages/dom/src/compatibility/element/namespaces.ts
@@ -0,0 +1,43 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#namespaces';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('preserves namespace local names prefixes and attribute lookup', ({ document }) => {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg:linearGradient');
+ expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg');
+ expect(svg.prefix).toBe('svg');
+ expect(svg.localName).toBe('linearGradient');
+ svg.setAttributeNS('urn:test', 'p:name', 'value');
+ expect(svg.getAttributeNS('urn:test', 'name')).toBe('value');
+ const attr = svg.getAttributeNodeNS('urn:test', 'name');
+ expect(attr?.prefix).toBe('p');
+ expect(attr?.localName).toBe('name');
+ expect(() => document.createElementNS(null, 'p:name')).toThrow(
+ expect.objectContaining({ name: 'NamespaceError' }),
+ );
+ });
+
+ context.test('distinguishes equal local attribute names in different namespaces', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElementNS('urn:root', 'p:root');
+ e.setAttributeNS('urn:a', 'a:id', 'first');
+ e.setAttributeNS('urn:b', 'b:id', 'second');
+ expect(e.getAttributeNS('urn:a', 'id')).toBe('first');
+ expect(e.getAttributeNS('urn:b', 'id')).toBe('second');
+ e.removeAttributeNS('urn:a', 'id');
+ expect(e.hasAttributeNS('urn:a', 'id')).toBe(false);
+ expect(e.getAttributeNS('urn:b', 'id')).toBe('second');
+ });
+
+ context.test('resolves inherited namespace declarations', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElementNS('urn:root', 'p:root'),
+ child = document.createElementNS('urn:child', 'c:child');
+ e.appendChild(child);
+ expect(child.lookupNamespaceURI('p')).toBe('urn:root');
+ expect(e.lookupPrefix('urn:root')).toBe('p');
+ expect(e.isDefaultNamespace('urn:root')).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/element/reflection.ts b/packages/dom/src/compatibility/element/reflection.ts
new file mode 100644
index 0000000..5c58ddd
--- /dev/null
+++ b/packages/dom/src/compatibility/element/reflection.ts
@@ -0,0 +1,41 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#reflect';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reflects string properties and attribute removal', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ expect(e.id).toBe('');
+ expect(e.title).toBe('');
+ e.id = 'a';
+ expect(e.getAttribute('id')).toBe('a');
+ e.setAttribute('title', 'hello');
+ expect(e.title).toBe('hello');
+ e.removeAttribute('title');
+ expect(e.title).toBe('');
+ e.lang = 'en';
+ expect(e.getAttribute('lang')).toBe('en');
+ });
+ context.test('reflects boolean presence independently of attribute spelling', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ expect(e.hidden).toBe(false);
+ e.setAttribute('hidden', 'false');
+ expect(e.hidden).toBe(true);
+ e.hidden = false;
+ expect(e.hasAttribute('hidden')).toBe(false);
+ e.hidden = true;
+ expect(e.getAttribute('hidden')).toBe('');
+ });
+ context.test('reflects tabIndex integer defaults and invalid input', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ expect(e.tabIndex).toBe(-1);
+ e.tabIndex = 3;
+ expect(e.getAttribute('tabindex')).toBe('3');
+ e.setAttribute('tabindex', 'bad');
+ expect(e.tabIndex).toBe(-1);
+ expect(document.createElement('button').tabIndex).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/element/selectors.ts b/packages/dom/src/compatibility/element/selectors.ts
new file mode 100644
index 0000000..5450e12
--- /dev/null
+++ b/packages/dom/src/compatibility/element/selectors.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-parentnode-queryselector';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('returns tree-ordered static snapshots and first match', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = '';
+ const found = p.querySelectorAll('.x');
+ expect(found.length).toBe(2);
+ expect(p.querySelector('.x')).toBe(found[0]);
+ p.appendChild(document.createElement('i')).className = 'x';
+ expect(found.length).toBe(2);
+ expect(p.querySelectorAll('.x').length).toBe(3);
+ expect(p.querySelector('.missing')).toBeNull();
+ });
+ context.test('handles combinators, attributes, scope, matches and closest', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = '';
+ const i = p.querySelector('i')!;
+ expect(i.matches('section > i[data-x="A"]:first-child')).toBe(true);
+ expect(i.closest('section')).toBe(p.firstChild);
+ expect(i.closest('.x')).toBe(i);
+ expect(p.querySelector(':scope > section > i + b')).toBe(i.nextSibling);
+ expect(i.closest('aside')).toBeNull();
+ });
+ context.test('rejects invalid selectors consistently', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ for (const operation of [
+ () => p.querySelector('['),
+ () => p.querySelectorAll('['),
+ () => p.matches('['),
+ () => p.closest('['),
+ ])
+ expect(operation).toThrow(expect.objectContaining({ name: 'SyntaxError' }));
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/button/activation.ts b/packages/dom/src/compatibility/elements/button/activation.ts
new file mode 100644
index 0000000..1eebfcf
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/button/activation.ts
@@ -0,0 +1,43 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element';
+export const requires = ['elements.form.submission', 'events.dispatch.cancellation'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('click submits the owning form and disabled suppresses activation', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const button = document.createElement('button');
+ form.appendChild(button);
+ fixture.connect(form);
+ let submissions = 0;
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submissions++;
+ });
+ button.click();
+ expect(submissions).toBe(1);
+ button.disabled = true;
+ button.click();
+ expect(submissions).toBe(1);
+ });
+
+ context.test('canceled click prevents submit activation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ button = document.createElement('button');
+ form.append(button);
+ fixture.connect(form as unknown as DOMElementContract);
+ let submits = 0;
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submits++;
+ });
+ button.addEventListener('click', (event) => event.preventDefault());
+ button.click();
+ expect(submits).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/button/attributes.ts b/packages/dom/src/compatibility/elements/button/attributes.ts
new file mode 100644
index 0000000..24ffc28
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/button/attributes.ts
@@ -0,0 +1,37 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reflects value, name, and disabled attributes', (fixture) => {
+ const document = fixture.document;
+ const button = document.createElement('button');
+ expect(button.type).toBe('submit');
+ button.type = 'reset';
+ button.name = 'action';
+ button.value = 'save';
+ button.disabled = true;
+ expect(button.getAttribute('type')).toBe('reset');
+ expect(button.getAttribute('name')).toBe('action');
+ expect(button.getAttribute('value')).toBe('save');
+ expect(button.hasAttribute('disabled')).toBe(true);
+ button.removeAttribute('disabled');
+ expect(button.disabled).toBe(false);
+ });
+
+ context.test('normalizes invalid type and reflects disabled presence', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('button');
+ expect(e.type).toBe('submit');
+ e.setAttribute('type', 'invalid');
+ expect(e.type).toBe('submit');
+ e.type = 'reset';
+ expect(e.getAttribute('type')).toBe('reset');
+ e.setAttribute('disabled', 'false');
+ expect(e.disabled).toBe(true);
+ e.disabled = false;
+ expect(e.hasAttribute('disabled')).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/button/form-association.ts b/packages/dom/src/compatibility/elements/button/form-association.ts
new file mode 100644
index 0000000..8d54169
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/button/form-association.ts
@@ -0,0 +1,54 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#association-of-controls-and-forms';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'resolves an explicit form owner and falls back after attribute removal',
+ (fixture) => {
+ const document = fixture.document;
+ const root = document.createElement('div');
+ const first = document.createElement('form');
+ const second = document.createElement('form');
+ first.id = 'first-form';
+ const button = document.createElement('button');
+ button.setAttribute('form', first.id);
+ second.appendChild(button);
+ root.append(first, second);
+ fixture.connect(root);
+ expect(button.form).toBe(first);
+ button.removeAttribute('form');
+ expect(button.form).toBe(second);
+ },
+ );
+
+ context.test('updates form ownership on moving between forms', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createElement('form'),
+ b = document.createElement('form'),
+ e = document.createElement('button');
+ expect(e.form).toBeNull();
+ a.append(e);
+ expect(e.form).toBe(a);
+ b.append(e);
+ expect(e.form).toBe(b);
+ e.remove();
+ expect(e.form).toBeNull();
+ });
+
+ context.test('an empty explicit form attribute prevents ancestor ownership', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('button');
+ form.append(e);
+ fixture.connect(form as unknown as DOMElementContract);
+ e.setAttribute('form', '');
+ expect(e.form).toBeNull();
+ e.removeAttribute('form');
+ expect(e.form).toBe(form);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/datalist.ts b/packages/dom/src/compatibility/elements/datalist.ts
new file mode 100644
index 0000000..ad74de4
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/datalist.ts
@@ -0,0 +1,38 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#the-datalist-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('exposes a live collection of descendant options', (fixture) => {
+ const document = fixture.document;
+ const datalist = document.createElement('datalist');
+ const options = datalist.options;
+ const option = document.createElement('option');
+ const wrapper = document.createElement('div');
+ wrapper.appendChild(option);
+ datalist.appendChild(wrapper);
+ expect(datalist.options).toBe(options);
+ expect(Array.from(options)).toEqual([option]);
+ option.remove();
+ expect(options.length).toBe(0);
+ });
+
+ context.test('keeps option collection live and excludes non-option descendants', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('datalist'),
+ options = e.options,
+ a = document.createElement('option'),
+ div = document.createElement('div');
+ div.append(a);
+ e.append(div);
+ expect(options.length).toBe(1);
+ expect(options.item(0)).toBe(a);
+ e.append(document.createElement('span'));
+ expect(options.length).toBe(1);
+ a.remove();
+ expect(options.length).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/details/exclusive-groups.ts b/packages/dom/src/compatibility/elements/details/exclusive-groups.ts
new file mode 100644
index 0000000..d24e515
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/details/exclusive-groups.ts
@@ -0,0 +1,45 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/interactive-elements.html#details-name';
+export const requires = ['elements.details.open'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'keeps at most one details element open per nonempty named tree group',
+ (fixture) => {
+ const document = fixture.document;
+ const root = document.createElement('div');
+ const first = document.createElement('details');
+ const second = document.createElement('details');
+ first.name = second.name = 'group';
+ root.append(first, second);
+ first.open = true;
+ second.open = true;
+ expect(first.open).toBe(false);
+ expect(second.open).toBe(true);
+ first.name = 'different';
+ first.open = true;
+ expect(second.open).toBe(true);
+ },
+ );
+
+ context.test('keeps differently named groups and empty names independent', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('details'),
+ b = document.createElement('details');
+ a.name = 'a';
+ b.name = 'b';
+ p.append(a, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ a.open = b.open = true;
+ expect(a.open).toBe(true);
+ expect(b.open).toBe(true);
+ a.name = b.name = '';
+ expect(a.open).toBe(true);
+ expect(b.open).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/details/open.ts b/packages/dom/src/compatibility/elements/details/open.ts
new file mode 100644
index 0000000..7bc5af1
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/details/open.ts
@@ -0,0 +1,32 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#the-details-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reflects open as a boolean attribute', (fixture) => {
+ const document = fixture.document;
+ const details = document.createElement('details');
+ expect(details.open).toBe(false);
+ details.open = true;
+ expect(details.hasAttribute('open')).toBe(true);
+ details.setAttribute('open', 'false');
+ expect(details.open).toBe(true);
+ details.open = false;
+ expect(details.hasAttribute('open')).toBe(false);
+ });
+
+ context.test('reflects open presence rather than attribute spelling', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('details');
+ expect(e.open).toBe(false);
+ e.setAttribute('open', 'false');
+ expect(e.open).toBe(true);
+ e.open = false;
+ expect(e.hasAttribute('open')).toBe(false);
+ e.open = true;
+ expect(e.getAttribute('open')).toBe('');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/details/toggle-events.ts b/packages/dom/src/compatibility/elements/details/toggle-events.ts
new file mode 100644
index 0000000..76557b8
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/details/toggle-events.ts
@@ -0,0 +1,51 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { DOMEvent, DOMToggleEvent } from '../../contracts/events.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#details-toggle-task-tracker';
+export const requires = ['elements.details.open'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('queues and coalesces toggle events', async (fixture) => {
+ const document = fixture.document;
+ const details = document.createElement('details');
+ let calls = 0;
+ const delivered = new Promise((resolve) => {
+ details.addEventListener('toggle', (event) => {
+ calls++;
+ resolve(event);
+ });
+ });
+ details.open = true;
+ details.open = false;
+ expect(calls).toBe(0);
+ const event = (await delivered) as DOMToggleEvent;
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ // The first uncoalesced event would end in "open". Checking the merged
+ // states catches that error without guessing how long the task queue takes.
+ expect(event.oldState).toBe('closed');
+ expect(event.newState).toBe('closed');
+ expect(calls).toBe(1);
+ });
+
+ context.test('queues and coalesces toggle events across synchronous changes', async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('details');
+ fixture.connect(e as unknown as DOMElementContract);
+ const seen: unknown[] = [];
+ e.addEventListener('toggle', (event) => {
+ const toggle = event as ToggleEvent;
+ seen.push([toggle.oldState, toggle.newState, event.bubbles, event.cancelable]);
+ });
+ e.open = true;
+ e.open = false;
+ e.open = true;
+ expect(seen).toEqual([]);
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ expect(seen).toEqual([['closed', 'open', false, false]]);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/dialog/close.ts b/packages/dom/src/compatibility/elements/dialog/close.ts
new file mode 100644
index 0000000..c82ade6
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/dialog/close.ts
@@ -0,0 +1,58 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { DOMEvent } from '../../contracts/events.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element';
+export const requires = ['elements.dialog.show'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('sets the return value and queues a noncancelable close event', async (fixture) => {
+ const document = fixture.document;
+ const dialog = document.createElement('dialog');
+ fixture.connect(dialog);
+ dialog.show();
+ let closed = false;
+ const delivered = new Promise((resolve) => {
+ dialog.addEventListener('close', (event) => {
+ closed = true;
+ resolve(event);
+ });
+ });
+ dialog.close('accepted');
+ expect(dialog.open).toBe(false);
+ expect(dialog.returnValue).toBe('accepted');
+ expect(closed).toBe(false);
+ const event = await delivered;
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ });
+
+ context.test('close updates returnValue and does nothing when already closed', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('dialog');
+ fixture.connect(e as unknown as DOMElementContract);
+ expect(e.returnValue).toBe('');
+ e.show();
+ e.close('result');
+ expect(e.open).toBe(false);
+ expect(e.returnValue).toBe('result');
+ e.close('ignored');
+ expect(e.returnValue).toBe('result');
+ });
+
+ context.test('close event is asynchronous and noncancelable', async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('dialog');
+ fixture.connect(e as unknown as DOMElementContract);
+ const seen: unknown[] = [];
+ e.addEventListener('close', (event) => seen.push([e.open, event.bubbles, event.cancelable]));
+ e.show();
+ e.close();
+ expect(seen).toEqual([]);
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ expect(seen).toEqual([[false, false, false]]);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/dialog/focus.ts b/packages/dom/src/compatibility/elements/dialog/focus.ts
new file mode 100644
index 0000000..9c84579
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/dialog/focus.ts
@@ -0,0 +1,43 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#dialog-focusing-steps';
+export const requires = ['elements.dialog.modal', 'focus.transitions'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'focuses autofocus content and restores the previously focused control',
+ (fixture) => {
+ const document = fixture.document;
+ const root = document.createElement('div');
+ const outside = document.createElement('button');
+ const dialog = document.createElement('dialog');
+ const preferred = document.createElement('button');
+ preferred.setAttribute('autofocus', '');
+ dialog.appendChild(preferred);
+ root.append(outside, dialog);
+ fixture.connect(root);
+ outside.focus();
+ dialog.showModal();
+ fixture.cleanup(() => dialog.close());
+ expect(document.activeElement).toBe(preferred);
+ dialog.close();
+ expect(document.activeElement).toBe(outside);
+ },
+ );
+
+ context.test('showModal focuses an autofocus control inside the dialog', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('dialog'),
+ input = document.createElement('input');
+ input.autofocus = true;
+ e.append(input);
+ fixture.connect(e as unknown as DOMElementContract);
+ e.showModal();
+ expect(document.activeElement).toBe(input);
+ e.close();
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/dialog/form-submission.ts b/packages/dom/src/compatibility/elements/dialog/form-submission.ts
new file mode 100644
index 0000000..8d96cc4
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/dialog/form-submission.ts
@@ -0,0 +1,49 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm';
+export const requires = [
+ 'elements.dialog.show',
+ 'elements.form.submission',
+ 'elements.button.activation',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'dialog submission closes with the submitter value without navigating',
+ (fixture) => {
+ const document = fixture.document;
+ const dialog = document.createElement('dialog');
+ const form = document.createElement('form');
+ form.method = 'dialog';
+ const button = document.createElement('button');
+ button.value = 'accepted';
+ form.appendChild(button);
+ dialog.appendChild(form);
+ fixture.connect(dialog);
+ dialog.show();
+ button.click();
+ expect(dialog.open).toBe(false);
+ expect(dialog.returnValue).toBe('accepted');
+ },
+ );
+
+ context.test('canceled dialog form submit leaves dialog open', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('dialog'),
+ form = document.createElement('form'),
+ button = document.createElement('button');
+ form.method = 'dialog';
+ form.append(button);
+ e.append(form);
+ fixture.connect(e as unknown as DOMElementContract);
+ form.addEventListener('submit', (event) => event.preventDefault());
+ e.show();
+ button.click();
+ expect(e.open).toBe(true);
+ e.close();
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/dialog/modal.ts b/packages/dom/src/compatibility/elements/dialog/modal.ts
new file mode 100644
index 0000000..45c218f
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/dialog/modal.ts
@@ -0,0 +1,42 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element';
+export const requires = ['elements.dialog.show', 'elements.dialog.close'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('requires connectivity and rejects switching an open nonmodal dialog', (fixture) => {
+ const document = fixture.document;
+ const dialog = document.createElement('dialog');
+ expect(() => dialog.showModal()).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ fixture.connect(dialog);
+ dialog.show();
+ expect(() => dialog.showModal()).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ dialog.close();
+ dialog.showModal();
+ fixture.cleanup(() => dialog.close());
+ dialog.showModal();
+ expect(dialog.open).toBe(true);
+ expect(() => dialog.show()).toThrow(expect.objectContaining({ name: 'InvalidStateError' }));
+ });
+
+ context.test('rejects modal display on disconnected and already nonmodal dialogs', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('dialog');
+ expect(() => e.showModal()).toThrow(expect.objectContaining({ name: 'InvalidStateError' }));
+ fixture.connect(e as unknown as DOMElementContract);
+ e.show();
+ expect(() => e.showModal()).toThrow(expect.objectContaining({ name: 'InvalidStateError' }));
+ e.close();
+ e.showModal();
+ expect(e.open).toBe(true);
+ e.close();
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/dialog/request-close.ts b/packages/dom/src/compatibility/elements/dialog/request-close.ts
new file mode 100644
index 0000000..53b7bc1
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/dialog/request-close.ts
@@ -0,0 +1,46 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { DOMEvent } from '../../contracts/events.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element';
+export const requires = [
+ 'elements.dialog.show',
+ 'elements.dialog.close',
+ 'events.dispatch.cancellation',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('allows cancel listeners to veto a close request', (fixture) => {
+ const document = fixture.document;
+ const dialog = document.createElement('dialog');
+ fixture.connect(dialog);
+ dialog.show();
+ const cancel = (event: DOMEvent): void => {
+ event.preventDefault();
+ };
+ dialog.addEventListener('cancel', cancel);
+ dialog.requestClose('ignored');
+ expect(dialog.open).toBe(true);
+ expect(dialog.returnValue).toBe('');
+ dialog.removeEventListener('cancel', cancel);
+ dialog.requestClose('accepted');
+ expect(dialog.open).toBe(false);
+ expect(dialog.returnValue).toBe('accepted');
+ });
+
+ context.test('requestClose fires cancel before close and accepts a return value', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('dialog');
+ fixture.connect(e as unknown as DOMElementContract);
+ const seen: unknown[] = [];
+ e.addEventListener('cancel', (event) => seen.push([e.open, event.cancelable]));
+ e.show();
+ e.requestClose('result');
+ expect(seen).toEqual([[true, true]]);
+ expect(e.open).toBe(false);
+ expect(e.returnValue).toBe('result');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/dialog/show.ts b/packages/dom/src/compatibility/elements/dialog/show.ts
new file mode 100644
index 0000000..46e67ad
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/dialog/show.ts
@@ -0,0 +1,32 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('show is idempotent and sets open', (fixture) => {
+ const document = fixture.document;
+ const dialog = document.createElement('dialog');
+ fixture.connect(dialog);
+ expect(dialog.open).toBe(false);
+ dialog.show();
+ expect(dialog.open).toBe(true);
+ dialog.show();
+ expect(dialog.open).toBe(true);
+ });
+
+ context.test('show is idempotent for an already nonmodal dialog', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('dialog');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.show();
+ expect(e.open).toBe(true);
+ e.show();
+ expect(e.open).toBe(true);
+ e.close();
+ expect(e.open).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/fieldset/controls.ts b/packages/dom/src/compatibility/elements/fieldset/controls.ts
new file mode 100644
index 0000000..ba9f730
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/fieldset/controls.ts
@@ -0,0 +1,37 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#dom-fieldset-elements';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('collects listed descendants in a live collection', (fixture) => {
+ const document = fixture.document;
+ const fieldset = document.createElement('fieldset');
+ const controls = fieldset.elements;
+ const input = document.createElement('input');
+ const nested = document.createElement('fieldset');
+ const button = document.createElement('button');
+ nested.appendChild(button);
+ fieldset.append(input, nested);
+ expect(fieldset.elements).toBe(controls);
+ expect(Array.from(controls)).toEqual([input, nested, button]);
+ input.remove();
+ expect(Array.from(controls)).toEqual([nested, button]);
+ });
+
+ context.test('exposes descendant controls as a live collection', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('fieldset'),
+ a = document.createElement('input'),
+ b = document.createElement('button'),
+ elements = e.elements;
+ e.append(a);
+ expect(elements.item(0)).toBe(a);
+ e.append(b);
+ expect(Array.from(elements)).toEqual([a, b]);
+ a.remove();
+ expect(elements.length).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/fieldset/disabled.ts b/packages/dom/src/compatibility/elements/fieldset/disabled.ts
new file mode 100644
index 0000000..19bff5e
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/fieldset/disabled.ts
@@ -0,0 +1,46 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute';
+export const requires = ['elements.input.validation'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('exempts descendants of the first legend from disabled inheritance', (fixture) => {
+ const document = fixture.document;
+ const fieldset = document.createElement('fieldset');
+ const legend = document.createElement('legend');
+ const exempt = document.createElement('input');
+ const blocked = document.createElement('input');
+ legend.appendChild(exempt);
+ fieldset.append(legend, blocked);
+ fieldset.disabled = true;
+ expect(exempt.willValidate).toBe(true);
+ expect(blocked.willValidate).toBe(false);
+ expect(blocked.disabled).toBe(false);
+ fieldset.disabled = false;
+ expect(blocked.willValidate).toBe(true);
+ });
+
+ context.test(
+ 'exempts only descendants of the first legend from disabled validation',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const f = document.createElement('fieldset'),
+ a = document.createElement('legend'),
+ b = document.createElement('legend'),
+ x = document.createElement('input'),
+ y = document.createElement('input'),
+ z = document.createElement('input');
+ a.append(x);
+ b.append(y);
+ f.append(a, b, z);
+ f.disabled = true;
+ expect(x.willValidate).toBe(true);
+ expect(y.willValidate).toBe(false);
+ expect(z.willValidate).toBe(false);
+ expect(z.disabled).toBe(false);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/elements/form/controls.ts b/packages/dom/src/compatibility/elements/form/controls.ts
new file mode 100644
index 0000000..0e4455e
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/form/controls.ts
@@ -0,0 +1,36 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/forms.html#dom-form-elements';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps the collection live and in tree order', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const controls = form.elements;
+ const first = document.createElement('input');
+ const second = document.createElement('button');
+ form.append(first, second);
+ expect(form.elements).toBe(controls);
+ expect(Array.from(controls)).toEqual([first, second]);
+ expect(form.length).toBe(2);
+ first.remove();
+ expect(Array.from(controls)).toEqual([second]);
+ });
+
+ context.test('retains live controls across insertion and removal in tree order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ a = document.createElement('input'),
+ b = document.createElement('button'),
+ elements = form.elements;
+ form.append(a, b);
+ expect(Array.from(elements)).toEqual([a, b]);
+ form.insertBefore(b, a);
+ expect(Array.from(elements)).toEqual([b, a]);
+ a.remove();
+ expect(elements.length).toBe(1);
+ expect(elements.item(0)).toBe(b);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/form/data-events.ts b/packages/dom/src/compatibility/elements/form/data-events.ts
new file mode 100644
index 0000000..ee016fe
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/form/data-events.ts
@@ -0,0 +1,121 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-entry-list';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/forms/form-submission-0/constructing-form-data-set.html';
+export const globals = ['FormData'] as const;
+export const requires = [
+ 'elements.form.formData',
+ 'events.dispatch.capture',
+ 'events.dispatch.bubbling',
+ 'events.dispatch.cancellation',
+] as const;
+
+// Adapted and extended from WPT at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause;
+// see ../../../../test/wpt/LICENSE.txt and ../../../../docs/wpt-css-forms.md.
+
+/** Registers form entry-list construction and formdata event interactions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const connected of [true, false]) {
+ context.test(
+ `formdata bubbles synchronously through ${connected ? 'connected' : 'detached'} ancestors without cancellation`,
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Data = fixture.globals('FormData') as unknown as typeof FormData;
+ const parent = document.createElement('div'),
+ form = document.createElement('form');
+ parent.append(form);
+ if (connected) fixture.connect(parent as unknown as DOMElementContract);
+ const seen: unknown[] = [];
+ parent.addEventListener(
+ 'formdata',
+ (event) => {
+ event.preventDefault();
+ seen.push([
+ 'capture',
+ event.target,
+ event.eventPhase,
+ event.bubbles,
+ event.cancelable,
+ event.defaultPrevented,
+ ]);
+ },
+ true,
+ );
+ form.addEventListener('formdata', (event) => seen.push(['target', event.eventPhase]));
+ parent.addEventListener('formdata', (event) => seen.push(['bubble', event.eventPhase]));
+ const data = new Data(form);
+ seen.push('returned');
+ expect([...data]).toEqual([]);
+ expect(seen).toEqual([
+ ['capture', form, 1, true, false, false],
+ ['target', 2],
+ ['bubble', 3],
+ 'returned',
+ ]);
+ },
+ );
+ }
+ context.test('shares listener mutations in order with the constructed data', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Data = fixture.globals('FormData') as unknown as typeof FormData;
+ const form = document.createElement('form'),
+ input = document.createElement('input');
+ input.name = 'first';
+ input.value = 'original';
+ form.append(input);
+ const seen: unknown[] = [];
+ let eventData: FormData | undefined;
+ form.addEventListener('formdata', (event) => {
+ eventData = event.formData;
+ seen.push([...event.formData]);
+ event.formData.append('extra', 'one');
+ event.formData.append('extra', 'two');
+ });
+ form.addEventListener('formdata', (event) => {
+ seen.push([...event.formData]);
+ event.formData.set('first', 'updated');
+ });
+ const data = new Data(form);
+ expect(seen).toEqual([
+ [['first', 'original']],
+ [
+ ['first', 'original'],
+ ['extra', 'one'],
+ ['extra', 'two'],
+ ],
+ ]);
+ expect([...data]).toEqual([
+ ['first', 'updated'],
+ ['extra', 'one'],
+ ['extra', 'two'],
+ ]);
+ expect([...eventData!]).toEqual([...data]);
+ expect(input.value).toBe('original');
+ });
+ context.test('preserves newlines in event-added entry names and values', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Data = fixture.globals('FormData') as unknown as typeof FormData;
+ const form = document.createElement('form');
+ form.addEventListener('formdata', (event) => event.formData.append('a\nb', 'c\rd\r\ne'));
+ expect([...new Data(form)]).toEqual([['a\nb', 'c\rd\r\ne']]);
+ });
+ context.test('constructs entries before event listeners mutate the controls', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Data = fixture.globals('FormData') as unknown as typeof FormData;
+ const form = document.createElement('form'),
+ input = document.createElement('input');
+ input.name = 'value';
+ input.value = 'before';
+ form.append(input);
+ form.addEventListener('formdata', () => {
+ input.value = 'after';
+ });
+ expect(new Data(form).get('value')).toBe('before');
+ expect(new Data(form).get('value')).toBe('after');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/form/form-data.ts b/packages/dom/src/compatibility/elements/form/form-data.ts
new file mode 100644
index 0000000..233e617
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/form/form-data.ts
@@ -0,0 +1,74 @@
+import type { DOMHTMLElement as DOMHTMLElementContract } from '../../contracts/nodes.ts';
+import type { DOMFormElement as DOMFormElementContract } from '../../contracts/elements.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set';
+const requiredGlobals = ['FormData'] as const;
+export { requiredGlobals as globals };
+export const requires = ['elements.input.value', 'elements.input.checkedness'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('collects successful named controls in tree order', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ input.name = 'answer';
+ input.value = '42';
+ const disabled = document.createElement('input');
+ disabled.name = 'excluded';
+ disabled.disabled = true;
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.name = 'agreed';
+ form.append(input, disabled, checkbox);
+ const FormData = fixture.globals('FormData');
+ const initial = new FormData(form);
+ expect(Array.from(initial.entries())).toEqual([['answer', '42']]);
+ checkbox.checked = true;
+ const checked = new FormData(form);
+ expect(checked.get('agreed')).toBe('on');
+ });
+
+ context.test('serializes only successful named controls in tree order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form');
+ form.innerHTML =
+ '';
+ const FormData = fixture.globals('FormData');
+ const data = new FormData(form as unknown as DOMFormElementContract);
+ expect([...data.entries()]).toEqual([
+ ['a', 'one'],
+ ['a', 'two'],
+ ['d', 'ok'],
+ ]);
+ expect(data.getAll('a')).toEqual(['one', 'two']);
+ expect(data.get('missing')).toBeNull();
+ });
+
+ context.test('includes only the selected submitter and rejects invalid submitters', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ button = document.createElement('button'),
+ foreign = document.createElement('button');
+ button.name = 'go';
+ button.value = 'yes';
+ form.append(button);
+ const FormData = fixture.globals('FormData');
+ expect([
+ ...new FormData(
+ form as unknown as DOMFormElementContract,
+ button as unknown as DOMHTMLElementContract,
+ ).entries(),
+ ]).toEqual([['go', 'yes']]);
+ expect(
+ () =>
+ new FormData(
+ form as unknown as DOMFormElementContract,
+ foreign as unknown as DOMHTMLElementContract,
+ ),
+ ).toThrow(expect.objectContaining({ name: 'NotFoundError' }));
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/form/named-controls.ts b/packages/dom/src/compatibility/elements/form/named-controls.ts
new file mode 100644
index 0000000..87dc112
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/form/named-controls.ts
@@ -0,0 +1,53 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#htmlformcontrolscollection';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/tree/master/html/semantics/forms/the-form-element';
+export const requires = ['elements.form.controls'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('looks up controls through item and namedItem', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ input.id = 'control-id';
+ input.name = 'control-name';
+ form.appendChild(input);
+ const controls = form.elements;
+ expect(controls.item(0)).toBe(input);
+ expect(controls.item(1)).toBeNull();
+ expect(controls.namedItem('control-id')).toBe(input);
+ expect(controls.namedItem('control-name')).toBe(input);
+ expect(controls.namedItem('missing')).toBeNull();
+ });
+
+ context.test('resolves id and name changes in a retained controls collection', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('input'),
+ elements = form.elements;
+ form.append(e);
+ e.name = 'first';
+ expect(elements.namedItem('first')).toBe(e);
+ e.name = 'second';
+ expect(elements.namedItem('first')).toBeNull();
+ expect(elements.namedItem('second')).toBe(e);
+ e.id = 'identifier';
+ expect(elements.namedItem('identifier')).toBe(e);
+ });
+
+ context.test('returns a RadioNodeList for repeated named controls', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form');
+ form.innerHTML = '';
+ const named = form.elements.namedItem('x') as RadioNodeList;
+ expect(named.length).toBe(2);
+ expect(named.value).toBe('b');
+ named.value = 'a';
+ expect((form.elements[0] as HTMLInputElement).checked).toBe(true);
+ expect((form.elements[1] as HTMLInputElement).checked).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/form/reset.ts b/packages/dom/src/compatibility/elements/form/reset.ts
new file mode 100644
index 0000000..5a3d5a5
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/form/reset.ts
@@ -0,0 +1,131 @@
+import type { DOMEvent } from '../../contracts/events.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#resetting-a-form';
+export const requires = ['elements.input.value', 'events.dispatch.cancellation'] as const;
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/forms/resetting-a-form/reset-form.html';
+
+// Select/reset-activation cases adapted from WPT at
+// b719612f963a05ece9c89a7dd7598c1c59618874. Copyright © web-platform-tests
+// contributors. Original author: Intel. BSD-3-Clause;
+// see ../../../../test/wpt/LICENSE.txt and ../../../../docs/wpt-css-forms.md.
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('restores control defaults unless the reset event is canceled', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ input.defaultValue = 'original';
+ input.value = 'edited';
+ form.appendChild(input);
+ const cancel = (event: DOMEvent): void => {
+ event.preventDefault();
+ };
+ form.addEventListener('reset', cancel);
+ form.reset();
+ expect(input.value).toBe('edited');
+ form.removeEventListener('reset', cancel);
+ form.reset();
+ expect(input.value).toBe('original');
+ input.defaultValue = 'new default';
+ expect(input.value).toBe('new default');
+ });
+
+ context.test('cancellation leaves dirty control state untouched', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('input');
+ e.defaultValue = 'default';
+ e.value = 'dirty';
+ form.append(e);
+ let target: unknown;
+ form.addEventListener('reset', (event) => {
+ target = event.target;
+ event.preventDefault();
+ });
+ form.reset();
+ expect(target).toBe(form);
+ expect(e.value).toBe('dirty');
+ });
+
+ context.test('reset restores text, checkbox and textarea defaults together', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('input'),
+ c = document.createElement('input'),
+ t = document.createElement('textarea');
+ c.type = 'checkbox';
+ e.defaultValue = 'a';
+ c.defaultChecked = true;
+ t.defaultValue = 'b';
+ form.append(e, c, t);
+ e.value = 'dirty';
+ c.checked = false;
+ t.value = 'dirty';
+ form.reset();
+ expect(e.value).toBe('a');
+ expect(c.checked).toBe(true);
+ expect(t.value).toBe('b');
+ });
+
+ for (const activation of ['method', 'button', 'input'] as const) {
+ context.test(
+ `restores single and multiple select defaults through reset ${activation}`,
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form');
+ const selects = [false, false, true].map((multiple, index) => {
+ const select = document.createElement('select');
+ select.multiple = multiple;
+ for (let i = 0; i < 3; i++) {
+ const option = document.createElement('option');
+ option.value = String(i);
+ option.defaultSelected = index === 1 ? i === 1 : index === 2 && i > 0;
+ select.append(option);
+ }
+ form.append(select);
+ select.value = index === 0 ? '2' : '0';
+ return select;
+ });
+ const dirty = [['2'], ['0'], ['0']];
+ const selected = (): string[][] =>
+ selects.map((select) => Array.from(select.selectedOptions, (option) => option.value));
+ expect(selected()).toEqual(dirty);
+ const statesDuringReset: string[][][] = [];
+ form.addEventListener('reset', () => statesDuringReset.push(selected()));
+ if (activation === 'method') form.reset();
+ else {
+ const trigger = document.createElement(activation);
+ trigger.type = 'reset';
+ form.append(trigger);
+ trigger.disabled = true;
+ trigger.click();
+ expect(statesDuringReset).toEqual([]);
+ expect(selected()).toEqual(dirty);
+ trigger.disabled = false;
+ trigger.addEventListener('click', (event) => event.preventDefault(), { once: true });
+ trigger.click();
+ expect(statesDuringReset).toEqual([]);
+ expect(selected()).toEqual(dirty);
+ trigger.click();
+ }
+ expect(statesDuringReset).toEqual([dirty]);
+ expect(selected()).toEqual([['0'], ['1'], ['1', '2']]);
+ selects[2]!.options[0]!.defaultSelected = true;
+ expect(selected()[2]).toEqual(['0', '1', '2']);
+ },
+ activation === 'method'
+ ? ['elements.select.selection', 'elements.option.selectedness']
+ : [
+ 'elements.select.selection',
+ 'elements.option.selectedness',
+ 'events.listeners.once',
+ activation === 'button' ? 'elements.button.activation' : 'elements.input.activation',
+ ],
+ );
+ }
+}
diff --git a/packages/dom/src/compatibility/elements/form/submission.ts b/packages/dom/src/compatibility/elements/form/submission.ts
new file mode 100644
index 0000000..dd1e1b1
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/form/submission.ts
@@ -0,0 +1,71 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { DOMEvent } from '../../contracts/events.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-form-requestsubmit';
+export const requires = ['events.dispatch.cancellation'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('requestSubmit emits a cancelable submit event', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const button = document.createElement('button');
+ form.appendChild(button);
+ fixture.connect(form);
+ const submitted: DOMEvent[] = [];
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submitted.push(event);
+ });
+ form.requestSubmit(button);
+ expect(submitted.length).toBe(1);
+ const event = submitted[0];
+ if (!event) return expect.fail('Expected a submit event');
+ expect(event.target).toBe(form);
+ expect(event.cancelable).toBe(true);
+ expect(event.bubbles).toBe(true);
+ const outsider = document.createElement('button');
+ expect(() => form.requestSubmit(outsider)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ });
+
+ context.test('requestSubmit validates first and reports submitter after success', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('input'),
+ button = document.createElement('button');
+ e.required = true;
+ form.append(e, button);
+ fixture.connect(form as unknown as DOMElementContract);
+ const seen: unknown[] = [];
+ e.addEventListener('invalid', () => seen.push('invalid'));
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ seen.push((event as SubmitEvent).submitter);
+ });
+ form.requestSubmit(button);
+ expect(seen).toEqual(['invalid']);
+ e.value = 'ok';
+ form.requestSubmit(button);
+ expect(seen).toEqual(['invalid', button]);
+ });
+
+ context.test('rejects invalid requestSubmit arguments before firing events', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ foreign = document.createElement('button'),
+ button = document.createElement('button');
+ button.type = 'button';
+ form.append(button);
+ expect(() => form.requestSubmit(button)).toThrow(
+ expect.objectContaining({ name: 'TypeError' }),
+ );
+ expect(() => form.requestSubmit(foreign)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/form/validation.ts b/packages/dom/src/compatibility/elements/form/validation.ts
new file mode 100644
index 0000000..8032aa4
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/form/validation.ts
@@ -0,0 +1,46 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api';
+export const requires = ['elements.input.validation'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('checks each invalid candidate without stopping at the first', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ first.required = second.required = true;
+ form.append(first, second);
+ const invalid: string[] = [];
+ first.addEventListener('invalid', () => invalid.push('first'));
+ second.addEventListener('invalid', () => invalid.push('second'));
+ expect(form.checkValidity()).toBe(false);
+ expect(invalid).toEqual(['first', 'second']);
+ first.value = second.value = 'ready';
+ expect(form.checkValidity()).toBe(true);
+ });
+
+ context.test('dispatches nonbubbling invalid events on every invalid control', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ a = document.createElement('input'),
+ b = document.createElement('input');
+ a.required = b.required = true;
+ form.append(a, b);
+ const seen: unknown[] = [];
+ form.addEventListener('invalid', () => seen.push('bubble'));
+ form.addEventListener(
+ 'invalid',
+ (event) => {
+ seen.push(event.target);
+ event.preventDefault();
+ },
+ true,
+ );
+ expect(form.checkValidity()).toBe(false);
+ expect(seen).toEqual([a, b]);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/activation.ts b/packages/dom/src/compatibility/elements/input/activation.ts
new file mode 100644
index 0000000..0b65855
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/activation.ts
@@ -0,0 +1,51 @@
+import type { DOMEvent } from '../../contracts/events.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/input.html#checkbox-state-(type=checkbox)';
+export const requires = ['elements.input.checkedness', 'events.dispatch.cancellation'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('runs checkbox preactivation and restores canceled checkedness', (fixture) => {
+ const document = fixture.document;
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.indeterminate = true;
+ fixture.connect(input);
+ const preactivation: boolean[][] = [];
+ const cancel = (event: DOMEvent): void => {
+ preactivation.push([input.checked, input.indeterminate]);
+ event.preventDefault();
+ };
+ input.addEventListener('click', cancel);
+ input.click();
+ expect(preactivation).toEqual([[true, false]]);
+ expect(input.checked).toBe(false);
+ expect(input.indeterminate).toBe(true);
+ input.removeEventListener('click', cancel);
+ input.click();
+ expect(input.checked).toBe(true);
+ expect(input.indeterminate).toBe(false);
+ });
+
+ context.test('checkbox canceled click rolls back checked and indeterminate state', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'checkbox';
+ e.indeterminate = true;
+ let during: unknown;
+ let changes = 0;
+ e.addEventListener('click', (event) => {
+ during = [e.checked, e.indeterminate];
+ event.preventDefault();
+ });
+ e.addEventListener('change', () => changes++);
+ e.click();
+ expect(during).toEqual([true, false]);
+ expect(e.checked).toBe(false);
+ expect(e.indeterminate).toBe(true);
+ expect(changes).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/checkedness.ts b/packages/dom/src/compatibility/elements/input/checkedness.ts
new file mode 100644
index 0000000..b0f892b
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/checkedness.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#dom-input-checked';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps checkedness separate from the default after a property write', (fixture) => {
+ const document = fixture.document;
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ expect(input.checked).toBe(false);
+ input.defaultChecked = true;
+ expect(input.checked).toBe(true);
+ input.checked = false;
+ input.defaultChecked = false;
+ input.defaultChecked = true;
+ expect(input.hasAttribute('checked')).toBe(true);
+ expect(input.checked).toBe(false);
+ input.indeterminate = true;
+ expect(input.checked).toBe(false);
+ });
+
+ context.test('resets dirty checkedness from the checked attribute', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('input');
+ e.type = 'checkbox';
+ form.append(e);
+ e.defaultChecked = true;
+ expect(e.checked).toBe(true);
+ e.checked = false;
+ e.defaultChecked = false;
+ e.defaultChecked = true;
+ expect(e.checked).toBe(false);
+ form.reset();
+ expect(e.checked).toBe(true);
+ expect(e.defaultChecked).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/cloning.ts b/packages/dom/src/compatibility/elements/input/cloning.ts
new file mode 100644
index 0000000..c14dbdf
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/cloning.ts
@@ -0,0 +1,51 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#the-input-element';
+const requiredGlobals = ['HTMLInputElement'] as const;
+export { requiredGlobals as globals };
+export const requires = ['elements.input.value', 'node.cloning'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('copies dirty value independently of the source', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.defaultValue = 'original';
+ element.value = 'edited';
+ const clone = element.cloneNode(true);
+ const Constructor = fixture.globals('HTMLInputElement');
+ if (!(clone instanceof Constructor)) return expect.unreachable('Unexpected clone interface');
+ expect(clone.value).toBe('edited');
+ expect(clone.defaultValue).toBe('original');
+ clone.defaultValue = 'new default';
+ expect(clone.value).toBe('edited');
+ clone.value = 'independent';
+ expect(element.value).toBe('edited');
+ });
+
+ context.test('clones dirty value without sharing subsequent edits', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.defaultValue = 'default';
+ e.value = 'dirty';
+ const copy = e.cloneNode(true) as HTMLInputElement;
+ expect(copy.value).toBe('dirty');
+ expect(copy.defaultValue).toBe('default');
+ copy.value = 'changed';
+ expect(e.value).toBe('dirty');
+ });
+
+ context.test('clones checkedness separately from defaultChecked', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'checkbox';
+ e.defaultChecked = true;
+ e.checked = false;
+ const copy = e.cloneNode() as HTMLInputElement;
+ expect(copy.defaultChecked).toBe(true);
+ expect(copy.checked).toBe(false);
+ copy.checked = true;
+ expect(e.checked).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/color-value.ts b/packages/dom/src/compatibility/elements/input/color-value.ts
new file mode 100644
index 0000000..6c46b3a
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/color-value.ts
@@ -0,0 +1,21 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#color-state-(type=color)';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/tree/master/html/semantics/forms/the-input-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('accepts CSS color values and serializes opaque limited-sRGB colors', (fixture) => {
+ const document = fixture.document;
+ const input = document.createElement('input');
+ input.type = 'color';
+ input.value = 'red';
+ expect(input.value).toBe('#ff0000');
+ input.value = '#AABBCC';
+ expect(input.value).toBe('#aabbcc');
+ input.value = 'not a color';
+ expect(input.value).toBe('#000000');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/date-value.ts b/packages/dom/src/compatibility/elements/input/date-value.ts
new file mode 100644
index 0000000..b9ac90f
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/date-value.ts
@@ -0,0 +1,44 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#dom-input-valueasdate';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reads and writes UTC dates', (fixture) => {
+ const document = fixture.document;
+ const input = document.createElement('input');
+ input.type = 'date';
+ expect(input.valueAsDate).toBeNull();
+ input.value = '2024-02-29';
+ const date = input.valueAsDate;
+ if (!date) return expect.fail('Expected a date for a valid date value');
+ expect(date.getTime()).toBe(Date.UTC(2024, 1, 29));
+ input.valueAsDate = new Date(Date.UTC(2025, 5, 3));
+ expect(input.value).toBe('2025-06-03');
+ input.valueAsDate = null;
+ expect(input.value).toBe('');
+ });
+
+ context.test('sanitizes invalid dates and represents empty dates as null and NaN', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'date';
+ e.value = '2023-02-29';
+ expect(e.value).toBe('');
+ expect(e.valueAsDate).toBeNull();
+ expect(Number.isNaN(e.valueAsNumber)).toBe(true);
+ e.value = '2024-02-29';
+ expect(e.valueAsNumber).toBe(Date.UTC(2024, 1, 29));
+ });
+
+ context.test('writes date values using UTC calendar components', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'date';
+ e.valueAsDate = new Date(Date.UTC(2024, 0, 2, 23));
+ expect(e.value).toBe('2024-01-02');
+ e.valueAsDate = null;
+ expect(e.value).toBe('');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/form-association.ts b/packages/dom/src/compatibility/elements/input/form-association.ts
new file mode 100644
index 0000000..ab357e0
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/form-association.ts
@@ -0,0 +1,42 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#association-of-controls-and-forms';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('follows the nearest ancestor form when moved', (fixture) => {
+ const document = fixture.document;
+ const first = document.createElement('form');
+ const second = document.createElement('form');
+ const element = document.createElement('input');
+ first.appendChild(element);
+ expect(element.form).toBe(first);
+ second.appendChild(element);
+ expect(element.form).toBe(second);
+ element.remove();
+ expect(element.form).toBeNull();
+ });
+
+ context.test('explicit form id overrides ancestors and tracks id changes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('form'),
+ b = document.createElement('form'),
+ e = document.createElement('input');
+ a.id = 'a';
+ b.id = 'b';
+ a.append(e);
+ p.append(a, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ expect(e.form).toBe(a);
+ e.setAttribute('form', 'b');
+ expect(e.form).toBe(b);
+ b.id = 'changed';
+ expect(e.form).toBeNull();
+ e.removeAttribute('form');
+ expect(e.form).toBe(a);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/labels.ts b/packages/dom/src/compatibility/elements/input/labels.ts
new file mode 100644
index 0000000..b251f42
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/labels.ts
@@ -0,0 +1,59 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/forms.html#dom-lfe-labels';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/html/semantics/forms/the-label-element/labels-live-attribute-changes.html';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'keeps retained labels live when a detached control moves under a label',
+ (fixture) => {
+ const document = fixture.document;
+ const root = document.createElement('div');
+ const label = document.createElement('label');
+ const element = document.createElement('input');
+ root.append(label, element);
+ const labels = element.labels;
+ if (!labels) return expect.fail('Expected labels for a text input');
+ expect(labels.length).toBe(0);
+ expect(element.labels).toBe(labels);
+ label.appendChild(element);
+ expect(labels.length).toBe(1);
+ expect(labels[0]).toBe(label);
+ root.appendChild(element);
+ expect(labels.length).toBe(0);
+ },
+ );
+
+ context.test('keeps connected explicit labels in tree order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('input'),
+ a = document.createElement('label'),
+ b = document.createElement('label');
+ e.id = 'control';
+ a.htmlFor = b.htmlFor = 'control';
+ p.append(a, e, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ const labels = e.labels!;
+ expect(Array.from(labels)).toEqual([a, b]);
+ p.insertBefore(b, a);
+ expect(Array.from(labels)).toEqual([b, a]);
+ e.id = 'other';
+ expect(labels.length).toBe(0);
+ });
+
+ context.test('does not associate labels with a different tree', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input'),
+ label = document.createElement('label');
+ e.id = 'control';
+ label.htmlFor = 'control';
+ fixture.connect(e as unknown as DOMElementContract);
+ expect(e.labels!.length).toBe(0);
+ expect(label.control).toBeNull();
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/numeric-value.ts b/packages/dom/src/compatibility/elements/input/numeric-value.ts
new file mode 100644
index 0000000..bf3ffea
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/numeric-value.ts
@@ -0,0 +1,39 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#dom-input-valueasnumber';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('converts numeric values and clears NaN', (fixture) => {
+ const document = fixture.document;
+ const input = document.createElement('input');
+ input.type = 'number';
+ expect(Number.isNaN(input.valueAsNumber)).toBe(true);
+ input.value = '1.5';
+ expect(input.valueAsNumber).toBe(1.5);
+ input.valueAsNumber = 42;
+ expect(input.value).toBe('42');
+ input.valueAsNumber = Number.NaN;
+ expect(input.value).toBe('');
+ input.type = 'text';
+ expect(Number.isNaN(input.valueAsNumber)).toBe(true);
+ expect(() => {
+ input.valueAsNumber = 1;
+ }).toThrow(expect.objectContaining({ name: 'InvalidStateError' }));
+ });
+
+ context.test('represents empty numeric values as NaN and rejects infinity', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'number';
+ expect(Number.isNaN(e.valueAsNumber)).toBe(true);
+ e.valueAsNumber = 3.5;
+ expect(e.value).toBe('3.5');
+ e.valueAsNumber = NaN;
+ expect(e.value).toBe('');
+ expect(() => {
+ e.valueAsNumber = Infinity;
+ }).toThrow(expect.objectContaining({ name: 'TypeError' }));
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/radio-groups.ts b/packages/dom/src/compatibility/elements/input/radio-groups.ts
new file mode 100644
index 0000000..b81dee1
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/radio-groups.ts
@@ -0,0 +1,47 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/input.html#radio-button-state-(type=radio)';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/html/semantics/forms/the-input-element/radio-disconnected-group-owner.html';
+export const requires = ['elements.input.checkedness'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('groups radios by tree and nonempty name', (fixture) => {
+ const document = fixture.document;
+ const root = document.createElement('div');
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ first.type = second.type = 'radio';
+ first.name = second.name = 'choice';
+ root.append(first, second);
+ first.checked = true;
+ second.checked = true;
+ expect(first.checked, 'setting checked in a detached group deselects its peer').toBe(false);
+ expect(second.checked).toBe(true);
+ first.name = 'other';
+ first.checked = true;
+ expect(second.checked).toBe(true);
+ first.name = 'choice';
+ expect(second.checked, 'changing a checked radio name updates its detached group').toBe(false);
+ });
+
+ context.test('keeps empty names independent and scopes named groups to forms', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ a = document.createElement('input'),
+ b = document.createElement('input');
+ a.type = b.type = 'radio';
+ form.append(a, b);
+ a.checked = b.checked = true;
+ expect(a.checked).toBe(true);
+ expect(b.checked).toBe(true);
+ a.name = b.name = 'group';
+ a.checked = true;
+ b.checked = true;
+ expect(a.checked).toBe(false);
+ expect(b.checked).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/range-text.ts b/packages/dom/src/compatibility/elements/input/range-text.ts
new file mode 100644
index 0000000..2a5b15e
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/range-text.ts
@@ -0,0 +1,33 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection';
+export const requires = ['elements.input.selection'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('replaces explicit offsets and selects the replacement', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.value = 'abcdef';
+ element.setRangeText('XYZ', 2, 4, 'select');
+ expect(element.value).toBe('abXYZef');
+ expect(element.selectionStart).toBe(2);
+ expect(element.selectionEnd).toBe(5);
+ expect(() => element.setRangeText('invalid', 5, 1)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ expect(element.value).toBe('abXYZef');
+ });
+ context.test('uses the current selection when offsets are omitted', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.value = 'abcdef';
+ element.setSelectionRange(2, 4);
+ element.setRangeText('X');
+ expect(element.value).toBe('abXef');
+ expect(element.selectionStart).toBe(2);
+ expect(element.selectionEnd).toBe(3);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/sanitization.ts b/packages/dom/src/compatibility/elements/input/sanitization.ts
new file mode 100644
index 0000000..d0af00b
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/sanitization.ts
@@ -0,0 +1,47 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#the-input-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('sanitizes invalid numeric and calendar values', (fixture) => {
+ const document = fixture.document;
+ const input = document.createElement('input');
+ for (const [type, value, expected] of [
+ ['number', ' 3 ', ''],
+ ['number', 'Infinity', ''],
+ ['number', '1e2', '1e2'],
+ ['date', '2025-02-29', ''],
+ ['date', '2024-02-29', '2024-02-29'],
+ ['time', '24:00', ''],
+ ['datetime-local', '2024-03-02 09:30:00', '2024-03-02T09:30'],
+ ] as const) {
+ input.type = type;
+ input.value = value;
+ expect(input.value).toBe(expected);
+ }
+ });
+
+ context.test('sanitizes line endings for text and whitespace for URL values', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.value = 'a\r\nb\nc';
+ expect(e.value).toBe('abc');
+ e.type = 'url';
+ e.value = ' https://example.test/a ';
+ expect(e.value).toBe('https://example.test/a');
+ });
+
+ context.test('normalizes invalid types and invalid numeric values', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'unknown';
+ expect(e.type).toBe('text');
+ e.type = 'number';
+ e.value = 'not a number';
+ expect(e.value).toBe('');
+ e.value = '2.5';
+ expect(e.valueAsNumber).toBe(2.5);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/selection.ts b/packages/dom/src/compatibility/elements/input/selection.ts
new file mode 100644
index 0000000..abb3e0b
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/selection.ts
@@ -0,0 +1,64 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection';
+export const requires = ['elements.input.value'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('uses UTF-16 offsets and clamps selection boundaries', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.value = 'a😀b';
+ element.setSelectionRange(1, 3, 'backward');
+ expect(element.selectionStart).toBe(1);
+ expect(element.selectionEnd).toBe(3);
+ expect(element.selectionDirection).toBe('backward');
+ element.setSelectionRange(99, 99);
+ expect(element.selectionStart).toBe(4);
+ expect(element.selectionEnd).toBe(4);
+ element.setSelectionRange(3, 1);
+ expect(element.selectionStart).toBe(1);
+ expect(element.selectionEnd).toBe(1);
+ element.select();
+ expect(element.selectionStart).toBe(0);
+ expect(element.selectionEnd).toBe(4);
+ });
+ context.test('preserves the selection when assigning the same value', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.value = 'hello';
+ element.setSelectionRange(1, 3);
+ element.value = 'hello';
+ expect(element.selectionStart).toBe(1);
+ expect(element.selectionEnd).toBe(3);
+ element.value = 'new';
+ expect(element.selectionStart).toBe(3);
+ expect(element.selectionEnd).toBe(3);
+ });
+
+ context.test('clamps endpoints and collapses reversed selection', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.value = 'abc';
+ e.setSelectionRange(1, 99, 'backward');
+ expect(e.selectionStart).toBe(1);
+ expect(e.selectionEnd).toBe(3);
+ expect(e.selectionDirection).toBe('backward');
+ e.setSelectionRange(3, 1);
+ expect(e.selectionStart).toBe(1);
+ expect(e.selectionEnd).toBe(1);
+ });
+
+ context.test('rejects selection methods on unsupported input types', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'number';
+ expect(e.selectionStart).toBeNull();
+ expect(e.selectionEnd).toBeNull();
+ expect(() => e.setSelectionRange(0, 1)).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/stepping.ts b/packages/dom/src/compatibility/elements/input/stepping.ts
new file mode 100644
index 0000000..8215964
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/stepping.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#dom-input-stepup';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('steps within numeric constraints and rejects any', (fixture) => {
+ const document = fixture.document;
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.min = '1';
+ input.max = '9';
+ input.step = '2';
+ input.value = '3';
+ input.stepUp(2);
+ expect(input.value).toBe('7');
+ input.stepDown();
+ expect(input.value).toBe('5');
+ input.stepUp(99);
+ expect(input.value).toBe('9');
+ input.step = 'any';
+ expect(() => input.stepUp()).toThrow(expect.objectContaining({ name: 'InvalidStateError' }));
+ });
+
+ context.test('steps relative to min and rejects step any', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'number';
+ e.min = '1';
+ e.step = '2';
+ e.value = '3';
+ e.stepUp(2);
+ expect(e.value).toBe('7');
+ e.stepDown();
+ expect(e.value).toBe('5');
+ e.step = 'any';
+ expect(() => e.stepUp()).toThrow(expect.objectContaining({ name: 'InvalidStateError' }));
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/validation.ts b/packages/dom/src/compatibility/elements/input/validation.ts
new file mode 100644
index 0000000..52802fa
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/validation.ts
@@ -0,0 +1,54 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('exposes required and custom validity independently', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.required = true;
+ expect(element.willValidate).toBe(true);
+ expect(element.validity.valueMissing).toBe(true);
+ expect(element.checkValidity()).toBe(false);
+ element.value = 'present';
+ expect(element.validity.valueMissing).toBe(false);
+ element.setCustomValidity('Try again');
+ expect(element.validity.customError).toBe(true);
+ expect(element.validationMessage).toBe('Try again');
+ element.setCustomValidity('');
+ expect(element.validity.valid).toBe(true);
+ element.disabled = true;
+ expect(element.willValidate).toBe(false);
+ });
+
+ context.test('updates required validity and disabled validation candidacy', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.required = true;
+ expect(e.validity.valueMissing).toBe(true);
+ expect(e.checkValidity()).toBe(false);
+ e.value = 'x';
+ expect(e.validity.valueMissing).toBe(false);
+ expect(e.checkValidity()).toBe(true);
+ e.disabled = true;
+ e.value = '';
+ expect(e.willValidate).toBe(false);
+ expect(e.checkValidity()).toBe(true);
+ });
+
+ context.test('retains custom errors until explicitly cleared', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.setCustomValidity('problem');
+ expect(e.validity.customError).toBe(true);
+ expect(e.validationMessage).toBe('problem');
+ e.value = 'changed';
+ expect(e.checkValidity()).toBe(false);
+ e.setCustomValidity('');
+ expect(e.validity.customError).toBe(false);
+ expect(e.checkValidity()).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/input/value.ts b/packages/dom/src/compatibility/elements/input/value.ts
new file mode 100644
index 0000000..665ac8f
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/input/value.ts
@@ -0,0 +1,52 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#the-input-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps default and dirty values separate', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.defaultValue = 'original';
+ expect(element.value).toBe('original');
+ element.value = 'edited';
+ element.defaultValue = 'replacement';
+ expect(element.value).toBe('edited');
+ expect(element.defaultValue).toBe('replacement');
+ });
+ context.test('normalizes line breaks in the API value', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('input');
+ element.value = 'a\r\nb\rc';
+ expect(element.value).toBe('abc');
+ });
+
+ context.test('keeps dirty value independent from defaults until reset', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('input');
+ form.append(e);
+ expect(e.value).toBe('');
+ e.defaultValue = 'a';
+ expect(e.value).toBe('a');
+ e.value = 'dirty';
+ e.setAttribute('value', 'b');
+ expect(e.value).toBe('dirty');
+ expect(e.defaultValue).toBe('b');
+ form.reset();
+ expect(e.value).toBe('b');
+ e.defaultValue = 'c';
+ expect(e.value).toBe('c');
+ });
+
+ context.test('does not fire input or change for programmatic writes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ const seen: string[] = [];
+ e.addEventListener('input', () => seen.push('input'));
+ e.addEventListener('change', () => seen.push('change'));
+ e.value = 'x';
+ expect(seen).toEqual([]);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/label/activation.ts b/packages/dom/src/compatibility/elements/label/activation.ts
new file mode 100644
index 0000000..af0e187
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/label/activation.ts
@@ -0,0 +1,51 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/forms.html#the-label-element';
+export const requires = ['elements.input.activation'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('does not forward activation from an interactive descendant', (fixture) => {
+ const document = fixture.document;
+ const label = document.createElement('label');
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ const button = document.createElement('button');
+ button.type = 'button';
+ label.append(checkbox, button);
+ fixture.connect(label);
+ button.click();
+ expect(checkbox.checked).toBe(false);
+ checkbox.click();
+ expect(checkbox.checked).toBe(true);
+ });
+
+ context.test('canceling label click prevents forwarded control activation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const label = document.createElement('label'),
+ e = document.createElement('input');
+ e.type = 'checkbox';
+ label.append(e);
+ fixture.connect(label as unknown as DOMElementContract);
+ label.addEventListener('click', (event) => event.preventDefault());
+ label.click();
+ expect(e.checked).toBe(false);
+ });
+
+ context.test('does not forward activation through a disabled associated control', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const label = document.createElement('label'),
+ e = document.createElement('input');
+ e.type = 'checkbox';
+ e.disabled = true;
+ label.append(e);
+ fixture.connect(label as unknown as DOMElementContract);
+ let n = 0;
+ e.addEventListener('click', () => n++);
+ label.click();
+ expect(n).toBe(0);
+ expect(e.checked).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/label/association.ts b/packages/dom/src/compatibility/elements/label/association.ts
new file mode 100644
index 0000000..b0277d4
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/label/association.ts
@@ -0,0 +1,52 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/forms.html#the-label-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('uses for before nested labelable descendants', (fixture) => {
+ const document = fixture.document;
+ const root = document.createElement('div');
+ const label = document.createElement('label');
+ const nested = document.createElement('input');
+ const external = document.createElement('input');
+ external.id = 'external-control';
+ label.appendChild(nested);
+ root.append(label, external);
+ fixture.connect(root);
+ expect(label.control).toBe(nested);
+ label.htmlFor = external.id;
+ expect(label.control).toBe(external);
+ label.htmlFor = 'missing';
+ expect(label.control).toBeNull();
+ });
+
+ context.test('explicit for association overrides nested labelable descendants', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ label = document.createElement('label'),
+ nested = document.createElement('input'),
+ other = document.createElement('input');
+ other.id = 'other';
+ label.append(nested);
+ p.append(label, other);
+ fixture.connect(p as unknown as DOMElementContract);
+ expect(label.control).toBe(nested);
+ label.htmlFor = 'other';
+ expect(label.control).toBe(other);
+ label.htmlFor = 'missing';
+ expect(label.control).toBeNull();
+ });
+
+ context.test('hidden input is not labelable', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const label = document.createElement('label'),
+ e = document.createElement('input');
+ e.type = 'hidden';
+ label.append(e);
+ expect(label.control).toBeNull();
+ expect(e.labels).toBeNull();
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/legend.ts b/packages/dom/src/compatibility/elements/legend.ts
new file mode 100644
index 0000000..4b43a16
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/legend.ts
@@ -0,0 +1,32 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-legend-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('gets its form through its parent fieldset', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const fieldset = document.createElement('fieldset');
+ const legend = document.createElement('legend');
+ form.appendChild(fieldset);
+ fieldset.appendChild(legend);
+ expect(legend.form).toBe(form);
+ form.appendChild(legend);
+ expect(legend.form).toBeNull();
+ });
+
+ context.test('form ownership comes from its parent fieldset', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ fieldset = document.createElement('fieldset'),
+ legend = document.createElement('legend');
+ expect(legend.form).toBeNull();
+ fieldset.append(legend);
+ form.append(fieldset);
+ expect(legend.form).toBe(form);
+ form.append(legend);
+ expect(legend.form).toBeNull();
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/meter.ts b/packages/dom/src/compatibility/elements/meter.ts
new file mode 100644
index 0000000..37110a3
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/meter.ts
@@ -0,0 +1,42 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-meter-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('derives default thresholds and clamps values to the range', (fixture) => {
+ const document = fixture.document;
+ const meter = document.createElement('meter');
+ expect(meter.min).toBe(0);
+ expect(meter.max).toBe(1);
+ expect(meter.optimum).toBe(0.5);
+ meter.min = 10;
+ meter.max = 20;
+ meter.value = 25;
+ expect(meter.value).toBe(20);
+ expect(meter.getAttribute('value')).toBe('25');
+ expect(meter.low).toBe(10);
+ expect(meter.high).toBe(20);
+ expect(meter.optimum).toBe(15);
+ });
+
+ context.test('clamps values to min/max and reflects explicit numeric attributes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('meter');
+ expect(e.min).toBe(0);
+ expect(e.max).toBe(1);
+ e.min = 2;
+ e.max = 10;
+ e.value = 20;
+ expect(e.value).toBe(10);
+ e.value = -1;
+ expect(e.value).toBe(2);
+ e.low = 4;
+ e.high = 8;
+ e.optimum = 6;
+ expect(e.low).toBe(4);
+ expect(e.high).toBe(8);
+ expect(e.optimum).toBe(6);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/optgroup.ts b/packages/dom/src/compatibility/elements/optgroup.ts
new file mode 100644
index 0000000..1fb8b4a
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/optgroup.ts
@@ -0,0 +1,33 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#the-optgroup-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reflects label and disabled without rewriting child attributes', (fixture) => {
+ const document = fixture.document;
+ const group = document.createElement('optgroup');
+ const option = document.createElement('option');
+ group.appendChild(option);
+ group.label = 'Group';
+ group.disabled = true;
+ expect(group.getAttribute('label')).toBe('Group');
+ expect(group.hasAttribute('disabled')).toBe(true);
+ expect(option.disabled).toBe(false);
+ });
+
+ context.test('reflects disabled and label attributes with standard defaults', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('optgroup');
+ expect(e.disabled).toBe(false);
+ expect(e.label).toBe('');
+ e.disabled = true;
+ e.label = 'Group';
+ expect(e.hasAttribute('disabled')).toBe(true);
+ expect(e.getAttribute('label')).toBe('Group');
+ e.removeAttribute('disabled');
+ expect(e.disabled).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/option/attributes.ts b/packages/dom/src/compatibility/elements/option/attributes.ts
new file mode 100644
index 0000000..c9a541c
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/option/attributes.ts
@@ -0,0 +1,36 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-option-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reflects explicit value, label, and disabled', (fixture) => {
+ const document = fixture.document;
+ const option = document.createElement('option');
+ option.text = 'Visible';
+ expect(option.value).toBe('Visible');
+ expect(option.label).toBe('Visible');
+ option.value = '';
+ option.label = 'Label';
+ option.disabled = true;
+ expect(option.value).toBe('');
+ expect(option.label).toBe('Label');
+ expect(option.getAttribute('value')).toBe('');
+ expect(option.hasAttribute('disabled')).toBe(true);
+ });
+
+ context.test('reflects defaultSelected independently of dirty selectedness', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('option');
+ expect(e.disabled).toBe(false);
+ expect(e.defaultSelected).toBe(false);
+ expect(e.label).toBe('');
+ e.disabled = true;
+ expect(e.hasAttribute('disabled')).toBe(true);
+ e.label = 'label';
+ expect(e.getAttribute('label')).toBe('label');
+ e.defaultSelected = true;
+ expect(e.getAttribute('selected')).toBe('');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/option/selectedness.ts b/packages/dom/src/compatibility/elements/option/selectedness.ts
new file mode 100644
index 0000000..0a46143
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/option/selectedness.ts
@@ -0,0 +1,54 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#concept-option-selectedness';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/html/semantics/forms/the-option-element/option-selected.html';
+
+// Adapted from WPT option-selected.html at b4b303c8dca22989c43e2d76fce199c36d4b77a7.
+// Copyright © web-platform-tests contributors. Original author: Corey Farwell.
+// BSD-3-Clause; see ../../../../test/wpt/LICENSE.txt.
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('reflects selected attributes until selectedness becomes dirty', (fixture) => {
+ const document = fixture.document;
+ const option = document.createElement('option');
+ expect(option.selected).toBe(false);
+ option.setAttribute('selected', '');
+ expect(option.selected).toBe(true);
+ option.removeAttribute('selected');
+ expect(option.selected).toBe(false);
+ option.defaultSelected = true;
+ expect(option.selected).toBe(true);
+ option.defaultSelected = false;
+ expect(option.selected).toBe(false);
+ });
+ for (const selected of [true, false]) {
+ context.test(`preserves dirty ${selected} selectedness across default changes`, (fixture) => {
+ const document = fixture.document;
+ const option = document.createElement('option');
+ option.selected = selected;
+ option.setAttribute('selected', '');
+ expect(option.selected).toBe(selected);
+ option.removeAttribute('selected');
+ expect(option.selected).toBe(selected);
+ option.defaultSelected = true;
+ expect(option.selected).toBe(selected);
+ option.defaultSelected = false;
+ expect(option.selected).toBe(selected);
+ });
+ }
+ context.test('separates dirty selectedness from the selected attribute', (fixture) => {
+ const document = fixture.document;
+ const option = document.createElement('option');
+ option.defaultSelected = true;
+ expect(option.selected).toBe(true);
+ option.selected = false;
+ option.defaultSelected = false;
+ option.defaultSelected = true;
+ expect(option.hasAttribute('selected')).toBe(true);
+ expect(option.selected).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/option/text.ts b/packages/dom/src/compatibility/elements/option/text.ts
new file mode 100644
index 0000000..fa3c6fe
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/option/text.ts
@@ -0,0 +1,35 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#dom-option-text';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('collapses ASCII whitespace and excludes script descendants', (fixture) => {
+ const document = fixture.document;
+ const option = document.createElement('option');
+ option.append(' one\n\t');
+ const span = document.createElement('span');
+ span.textContent = ' two\u00a0three ';
+ const script = document.createElement('script');
+ script.textContent = 'ignored';
+ option.append(span, script);
+ expect(option.text).toBe('one two\u00a0three');
+ option.text = 'replacement';
+ expect(option.textContent).toBe('replacement');
+ expect(option.childNodes.length).toBe(1);
+ });
+
+ context.test('collapses ASCII whitespace and uses text as the value fallback', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('option');
+ e.textContent = ' a\n b ';
+ expect(e.text).toBe('a b');
+ expect(e.value).toBe('a b');
+ e.value = 'explicit';
+ expect(e.text).toBe('a b');
+ expect(e.value).toBe('explicit');
+ e.removeAttribute('value');
+ expect(e.value).toBe('a b');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/output/html-for.ts b/packages/dom/src/compatibility/elements/output/html-for.ts
new file mode 100644
index 0000000..18b08cb
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/output/html-for.ts
@@ -0,0 +1,34 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#dom-output-htmlfor';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps the token list and for attribute synchronized', (fixture) => {
+ const document = fixture.document;
+ const output = document.createElement('output');
+ const tokens = output.htmlFor;
+ output.setAttribute('for', 'first second');
+ expect(tokens.contains('first')).toBe(true);
+ expect(tokens.contains('second')).toBe(true);
+ tokens.add('third');
+ expect(output.getAttribute('for')).toBe('first second third');
+ tokens.remove('second');
+ expect(output.getAttribute('for')).toBe('first third');
+ expect(output.htmlFor).toBe(tokens);
+ });
+
+ context.test('htmlFor token list synchronizes for attribute and stays stable', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('output'),
+ list = e.htmlFor;
+ e.setAttribute('for', 'a b a');
+ expect(Array.from(list)).toEqual(['a', 'b']);
+ list.add('c');
+ expect(e.getAttribute('for')).toBe('a b c');
+ e.removeAttribute('for');
+ expect(list.length).toBe(0);
+ expect(e.htmlFor).toBe(list);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/output/reset.ts b/packages/dom/src/compatibility/elements/output/reset.ts
new file mode 100644
index 0000000..47e83a7
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/output/reset.ts
@@ -0,0 +1,34 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-output-element';
+export const requires = ['elements.form.reset', 'elements.output.value'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('form reset restores the default and default-value mode', (fixture) => {
+ const document = fixture.document;
+ const form = document.createElement('form');
+ const output = document.createElement('output');
+ form.appendChild(output);
+ output.defaultValue = 'zero';
+ output.value = 'one';
+ form.reset();
+ expect(output.value).toBe('zero');
+ output.defaultValue = 'two';
+ expect(output.value).toBe('two');
+ });
+
+ context.test('reset applies latest default after dirty output changes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('output');
+ form.append(e);
+ e.defaultValue = 'a';
+ e.value = 'dirty';
+ e.defaultValue = 'b';
+ form.reset();
+ expect(e.value).toBe('b');
+ expect(e.textContent).toBe('b');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/output/value.ts b/packages/dom/src/compatibility/elements/output/value.ts
new file mode 100644
index 0000000..14ca27d
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/output/value.ts
@@ -0,0 +1,34 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-output-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps a default value separately after entering value mode', (fixture) => {
+ const document = fixture.document;
+ const output = document.createElement('output');
+ output.defaultValue = 'original';
+ expect(output.value).toBe('original');
+ expect(output.textContent).toBe('original');
+ output.value = 'calculated';
+ expect(output.textContent).toBe('calculated');
+ expect(output.defaultValue).toBe('original');
+ output.defaultValue = 'replacement';
+ expect(output.value).toBe('calculated');
+ expect(output.type).toBe('output');
+ expect(output.willValidate).toBe(false);
+ });
+
+ context.test('value writes replace text while defaultValue remains independent', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('output');
+ e.defaultValue = 'default';
+ expect(e.value).toBe('default');
+ e.value = 'current';
+ expect(e.textContent).toBe('current');
+ expect(e.defaultValue).toBe('default');
+ e.defaultValue = 'next';
+ expect(e.value).toBe('current');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/progress.ts b/packages/dom/src/compatibility/elements/progress.ts
new file mode 100644
index 0000000..393cc6f
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/progress.ts
@@ -0,0 +1,41 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#the-progress-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('clamps exposed values while retaining reflected attributes', (fixture) => {
+ const document = fixture.document;
+ const progress = document.createElement('progress');
+ expect(progress.max).toBe(1);
+ expect(progress.value).toBe(0);
+ expect(progress.position).toBe(-1);
+ progress.max = 10;
+ progress.value = 4;
+ expect(progress.position).toBe(0.4);
+ progress.value = 20;
+ expect(progress.getAttribute('value')).toBe('20');
+ expect(progress.value).toBe(10);
+ expect(progress.position).toBe(1);
+ progress.removeAttribute('value');
+ expect(progress.position).toBe(-1);
+ });
+
+ context.test('clamps value and computes determinate position without layout', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('progress');
+ expect(e.max).toBe(1);
+ expect(e.value).toBe(0);
+ expect(e.position).toBe(-1);
+ e.max = 10;
+ e.value = 20;
+ expect(e.value).toBe(10);
+ expect(e.position).toBe(1);
+ e.value = -1;
+ expect(e.value).toBe(0);
+ e.removeAttribute('value');
+ expect(e.position).toBe(-1);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/select/cloning.ts b/packages/dom/src/compatibility/elements/select/cloning.ts
new file mode 100644
index 0000000..d0ad61b
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/select/cloning.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#the-option-element';
+const requiredGlobals = ['HTMLSelectElement'] as const;
+export { requiredGlobals as globals };
+export const requires = ['elements.select.selection', 'node.cloning'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'reconstructs selection from cloned attributes rather than dirty selectedness',
+ (fixture) => {
+ const document = fixture.document;
+ const select = document.createElement('select');
+ const first = document.createElement('option');
+ first.value = 'first';
+ const second = document.createElement('option');
+ second.value = 'second';
+ select.append(first, second);
+ select.value = 'second';
+ const copy = select.cloneNode(true);
+ const Constructor = fixture.globals('HTMLSelectElement');
+ if (!(copy instanceof Constructor)) return expect.unreachable('Unexpected clone interface');
+ // HTML has special value-copying steps for input and textarea, not option.
+ expect(copy.value).toBe('first');
+ expect(copy.options[0]).not.toBe(first);
+ expect(copy.options[1]).not.toBe(second);
+ copy.value = 'first';
+ expect(select.value).toBe('second');
+ },
+ );
+
+ context.test('deep clone owns independent options and shallow clone has none', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('select');
+ e.innerHTML = '';
+ const copy = e.cloneNode(true) as HTMLSelectElement;
+ expect(copy.value).toBe('a');
+ expect(copy.options[0]).not.toBe(e.options[0]);
+ copy.value = 'b';
+ expect(e.value).toBe('a');
+ expect((e.cloneNode(false) as HTMLSelectElement).options.length).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/select/indexed-options.ts b/packages/dom/src/compatibility/elements/select/indexed-options.ts
new file mode 100644
index 0000000..57ebcd0
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/select/indexed-options.ts
@@ -0,0 +1,57 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#dom-htmloptionscollection-setter';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/tree/master/html/semantics/forms/the-select-element';
+export const requires = ['elements.select.options'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('supports the standard indexed options setter', (fixture) => {
+ const document = fixture.document;
+ const select = document.createElement('select');
+ const original = document.createElement('option');
+ const replacement = document.createElement('option');
+ select.appendChild(original);
+ select.options[0] = replacement;
+ expect(select.options[0]).toBe(replacement);
+ expect(replacement.parentNode).toBe(select);
+ expect(original.parentNode).toBeNull();
+ expect(select.length).toBe(1);
+ select.options[0] = null;
+ expect(select.length).toBe(0);
+ expect(replacement.parentNode).toBeNull();
+ });
+
+ context.test(
+ 'replaces and deletes indexed options while retaining collection identity',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('select'),
+ a = document.createElement('option'),
+ b = document.createElement('option'),
+ options = e.options;
+ e.add(a);
+ e[0] = b;
+ expect(e.options).toBe(options);
+ expect(options[0]).toBe(b);
+ expect(a.parentNode).toBeNull();
+ Reflect.set(e, 0, null);
+ expect(options.length).toBe(0);
+ },
+ );
+
+ context.test('setting length grows with empty options and truncates children', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('select');
+ e.options.length = 3;
+ expect(e.options.length).toBe(3);
+ expect(e.options[2]!.value).toBe('');
+ const last = e.options[2]!;
+ e.options.length = 1;
+ expect(last.parentNode).toBeNull();
+ expect(e.options.length).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/select/mutation.ts b/packages/dom/src/compatibility/elements/select/mutation.ts
new file mode 100644
index 0000000..9d7fad3
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/select/mutation.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#dom-select-add';
+export const requires = ['elements.select.options'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('adds options at indexed anchors and adjusts length', (fixture) => {
+ const document = fixture.document;
+ const select = document.createElement('select');
+ const first = document.createElement('option');
+ const second = document.createElement('option');
+ select.add(second, null);
+ select.add(first, 0);
+ expect(Array.from(select.options)).toEqual([first, second]);
+ select.remove(0);
+ expect(Array.from(select.options)).toEqual([second]);
+ select.length = 3;
+ expect(select.options.length).toBe(3);
+ const generated = select.options[2];
+ if (!generated) return expect.fail('Expected a generated option');
+ expect(generated.localName).toBe('option');
+ select.length = 0;
+ expect(select.options.length).toBe(0);
+ expect(second.parentNode).toBeNull();
+ });
+
+ context.test(
+ 'updates selectedIndex after insertion and removal before the selected option',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('select');
+ e.innerHTML = '';
+ e.value = 'b';
+ const selected = e.options[1]!;
+ e.insertBefore(document.createElement('option'), e.firstChild);
+ expect(e.selectedIndex).toBe(2);
+ expect(e.options[2]).toBe(selected);
+ e.remove(0);
+ expect(e.selectedIndex).toBe(1);
+ expect(e.value).toBe('b');
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/elements/select/options.ts b/packages/dom/src/compatibility/elements/select/options.ts
new file mode 100644
index 0000000..dd7692d
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/select/options.ts
@@ -0,0 +1,42 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/form-elements.html#dom-select-options';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps options live and excludes nested selects', (fixture) => {
+ const document = fixture.document;
+ const select = document.createElement('select');
+ const options = select.options;
+ const first = document.createElement('option');
+ first.id = 'first-option';
+ const group = document.createElement('optgroup');
+ const second = document.createElement('option');
+ group.appendChild(second);
+ select.append(first, group);
+ expect(select.options).toBe(options);
+ expect(Array.from(options)).toEqual([first, second]);
+ expect(select.item(0)).toBe(first);
+ expect(select.item(99)).toBeNull();
+ expect(select.namedItem('first-option')).toBe(first);
+ expect(second.index).toBe(1);
+ first.remove();
+ expect(Array.from(options)).toEqual([second]);
+ expect(second.index).toBe(0);
+ });
+
+ context.test('adds before a reference and removes by index', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('select'),
+ a = document.createElement('option'),
+ b = document.createElement('option');
+ e.add(a);
+ e.add(b, a);
+ expect(Array.from(e.options)).toEqual([b, a]);
+ e.remove(0);
+ expect(Array.from(e.options)).toEqual([a]);
+ e.remove(99);
+ expect(e.options.length).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/select/selection.ts b/packages/dom/src/compatibility/elements/select/selection.ts
new file mode 100644
index 0000000..4264700
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/select/selection.ts
@@ -0,0 +1,47 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#dom-select-selectedindex';
+export const requires = ['elements.select.options'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('selects by value and permits an explicit empty selection', (fixture) => {
+ const document = fixture.document;
+ const select = document.createElement('select');
+ const first = document.createElement('option');
+ const second = document.createElement('option');
+ first.value = 'first';
+ second.value = 'second';
+ select.append(first, second);
+ expect(select.selectedIndex).toBe(0);
+ const selected = select.selectedOptions;
+ select.value = 'second';
+ expect(select.selectedIndex).toBe(1);
+ expect(Array.from(selected)).toEqual([second]);
+ select.value = 'missing';
+ expect(select.selectedIndex).toBe(-1);
+ expect(selected.length).toBe(0);
+ expect(select.value).toBe('');
+ select.multiple = true;
+ first.selected = second.selected = true;
+ expect(select.type).toBe('select-multiple');
+ expect(Array.from(selected)).toEqual([first, second]);
+ });
+
+ context.test('sets selectedIndex and clears selection for missing values', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('select');
+ e.innerHTML = '';
+ expect(e.selectedIndex).toBe(0);
+ e.value = 'b';
+ expect(e.selectedIndex).toBe(1);
+ expect(e.options[1]!.selected).toBe(true);
+ e.value = 'missing';
+ expect(e.selectedIndex).toBe(-1);
+ expect(e.value).toBe('');
+ e.selectedIndex = 0;
+ expect(e.value).toBe('a');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/select/validation.ts b/packages/dom/src/compatibility/elements/select/validation.ts
new file mode 100644
index 0000000..9dcf1bb
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/select/validation.ts
@@ -0,0 +1,41 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#placeholder-label-option';
+export const requires = ['elements.select.selection'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('treats the first empty option as a required placeholder', (fixture) => {
+ const document = fixture.document;
+ const select = document.createElement('select');
+ select.required = true;
+ const placeholder = document.createElement('option');
+ placeholder.value = '';
+ const choice = document.createElement('option');
+ choice.value = 'chosen';
+ select.append(placeholder, choice);
+ expect(select.validity.valueMissing).toBe(true);
+ select.value = 'chosen';
+ expect(select.validity.valueMissing).toBe(false);
+ select.setCustomValidity('Choose again');
+ expect(select.checkValidity()).toBe(false);
+ expect(select.validationMessage).toBe('Choose again');
+ select.disabled = true;
+ expect(select.willValidate).toBe(false);
+ });
+
+ context.test('required single select rejects the placeholder option', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('select');
+ e.required = true;
+ e.innerHTML = '';
+ expect(e.validity.valueMissing).toBe(true);
+ e.value = 'x';
+ expect(e.checkValidity()).toBe(true);
+ e.disabled = true;
+ e.value = '';
+ expect(e.willValidate).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/selectedcontent.ts b/packages/dom/src/compatibility/elements/selectedcontent.ts
new file mode 100644
index 0000000..2859082
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/selectedcontent.ts
@@ -0,0 +1,29 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#the-selectedcontent-element';
+export const requires = ['elements.select.selection'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('copies selected option content without sharing node identity', (fixture) => {
+ const document = fixture.document;
+ const select = document.createElement('select');
+ const button = document.createElement('button');
+ const selected = document.createElement('selectedcontent');
+ button.appendChild(selected);
+ const first = document.createElement('option');
+ const label = document.createElement('span');
+ label.textContent = 'First';
+ first.appendChild(label);
+ const second = document.createElement('option');
+ second.text = 'Second';
+ select.append(button, first, second);
+ fixture.connect(select);
+ expect(selected.textContent).toBe('First');
+ expect(selected.firstChild).not.toBe(label);
+ select.selectedIndex = 1;
+ expect(selected.textContent).toBe('Second');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/summary/activation.ts b/packages/dom/src/compatibility/elements/summary/activation.ts
new file mode 100644
index 0000000..e07b2de
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/summary/activation.ts
@@ -0,0 +1,56 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element';
+export const requires = ['elements.details.open', 'events.dispatch.cancellation'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'toggles details only through the first summary and honors cancellation',
+ (fixture) => {
+ const document = fixture.document;
+ const details = document.createElement('details');
+ const first = document.createElement('summary');
+ const second = document.createElement('summary');
+ details.append(first, second);
+ second.click();
+ expect(details.open).toBe(false);
+ first.click();
+ expect(details.open).toBe(true);
+ first.addEventListener('click', (event) => {
+ event.preventDefault();
+ });
+ first.click();
+ expect(details.open).toBe(true);
+ },
+ );
+
+ context.test('only the first summary has details activation behavior', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('details'),
+ a = document.createElement('summary'),
+ b = document.createElement('summary');
+ e.append(a, b);
+ fixture.connect(e as unknown as DOMElementContract);
+ b.click();
+ expect(e.open).toBe(false);
+ a.click();
+ expect(e.open).toBe(true);
+ a.click();
+ expect(e.open).toBe(false);
+ });
+
+ context.test('canceled summary click leaves details state unchanged', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('details'),
+ s = document.createElement('summary');
+ e.append(s);
+ fixture.connect(e as unknown as DOMElementContract);
+ s.addEventListener('click', (event) => event.preventDefault());
+ s.click();
+ expect(e.open).toBe(false);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/textarea/cloning.ts b/packages/dom/src/compatibility/elements/textarea/cloning.ts
new file mode 100644
index 0000000..86dfb1c
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/textarea/cloning.ts
@@ -0,0 +1,39 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#the-textarea-element';
+const requiredGlobals = ['HTMLTextAreaElement'] as const;
+export { requiredGlobals as globals };
+export const requires = ['elements.textarea.value', 'node.cloning'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('copies dirty value independently of the source', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.defaultValue = 'original';
+ element.value = 'edited';
+ const clone = element.cloneNode(true);
+ const Constructor = fixture.globals('HTMLTextAreaElement');
+ if (!(clone instanceof Constructor)) return expect.unreachable('Unexpected clone interface');
+ expect(clone.value).toBe('edited');
+ expect(clone.defaultValue).toBe('original');
+ clone.defaultValue = 'new default';
+ expect(clone.value).toBe('edited');
+ clone.value = 'independent';
+ expect(element.value).toBe('edited');
+ });
+
+ context.test('clones dirty value and text defaults independently', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('textarea');
+ e.defaultValue = 'default';
+ e.value = 'dirty';
+ const copy = e.cloneNode(true) as HTMLTextAreaElement;
+ expect(copy.value).toBe('dirty');
+ expect(copy.defaultValue).toBe('default');
+ copy.value = 'changed';
+ expect(e.value).toBe('dirty');
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/textarea/form-association.ts b/packages/dom/src/compatibility/elements/textarea/form-association.ts
new file mode 100644
index 0000000..358bfd0
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/textarea/form-association.ts
@@ -0,0 +1,48 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#association-of-controls-and-forms';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('follows the nearest ancestor form when moved', (fixture) => {
+ const document = fixture.document;
+ const first = document.createElement('form');
+ const second = document.createElement('form');
+ const element = document.createElement('textarea');
+ first.appendChild(element);
+ expect(element.form).toBe(first);
+ second.appendChild(element);
+ expect(element.form).toBe(second);
+ element.remove();
+ expect(element.form).toBeNull();
+ });
+
+ context.test('updates form ownership on moving between forms', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createElement('form'),
+ b = document.createElement('form'),
+ e = document.createElement('textarea');
+ expect(e.form).toBeNull();
+ a.append(e);
+ expect(e.form).toBe(a);
+ b.append(e);
+ expect(e.form).toBe(b);
+ e.remove();
+ expect(e.form).toBeNull();
+ });
+
+ context.test('an empty explicit form attribute prevents ancestor ownership', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const form = document.createElement('form'),
+ e = document.createElement('textarea');
+ form.append(e);
+ fixture.connect(form as unknown as DOMElementContract);
+ e.setAttribute('form', '');
+ expect(e.form).toBeNull();
+ e.removeAttribute('form');
+ expect(e.form).toBe(form);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/textarea/labels.ts b/packages/dom/src/compatibility/elements/textarea/labels.ts
new file mode 100644
index 0000000..69ec30c
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/textarea/labels.ts
@@ -0,0 +1,58 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/forms.html#dom-lfe-labels';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/html/semantics/forms/the-label-element/labels-live-attribute-changes.html';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'keeps retained labels live when a detached control moves under a label',
+ (fixture) => {
+ const document = fixture.document;
+ const root = document.createElement('div');
+ const label = document.createElement('label');
+ const element = document.createElement('textarea');
+ root.append(label, element);
+ const labels = element.labels;
+ expect(labels.length).toBe(0);
+ expect(element.labels).toBe(labels);
+ label.appendChild(element);
+ expect(labels.length).toBe(1);
+ expect(labels[0]).toBe(label);
+ root.appendChild(element);
+ expect(labels.length).toBe(0);
+ },
+ );
+
+ context.test('keeps connected explicit labels in tree order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('textarea'),
+ a = document.createElement('label'),
+ b = document.createElement('label');
+ e.id = 'control';
+ a.htmlFor = b.htmlFor = 'control';
+ p.append(a, e, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ const labels = e.labels!;
+ expect(Array.from(labels)).toEqual([a, b]);
+ p.insertBefore(b, a);
+ expect(Array.from(labels)).toEqual([b, a]);
+ e.id = 'other';
+ expect(labels.length).toBe(0);
+ });
+
+ context.test('does not associate labels with a different tree', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('textarea'),
+ label = document.createElement('label');
+ e.id = 'control';
+ label.htmlFor = 'control';
+ fixture.connect(e as unknown as DOMElementContract);
+ expect(e.labels!.length).toBe(0);
+ expect(label.control).toBeNull();
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/textarea/range-text.ts b/packages/dom/src/compatibility/elements/textarea/range-text.ts
new file mode 100644
index 0000000..d106ec1
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/textarea/range-text.ts
@@ -0,0 +1,50 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection';
+export const requires = ['elements.textarea.selection'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('replaces explicit offsets and selects the replacement', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.value = 'abcdef';
+ element.setRangeText('XYZ', 2, 4, 'select');
+ expect(element.value).toBe('abXYZef');
+ expect(element.selectionStart).toBe(2);
+ expect(element.selectionEnd).toBe(5);
+ expect(() => element.setRangeText('invalid', 5, 1)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ expect(element.value).toBe('abXYZef');
+ });
+ context.test('uses the current selection when offsets are omitted', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.value = 'abcdef';
+ element.setSelectionRange(2, 4);
+ element.setRangeText('X');
+ expect(element.value).toBe('abXef');
+ expect(element.selectionStart).toBe(2);
+ expect(element.selectionEnd).toBe(3);
+ });
+
+ context.test('supports selection replacement modes and rejects reversed offsets', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('textarea');
+ e.value = 'abcdef';
+ e.setSelectionRange(1, 3);
+ e.setRangeText('XY', 1, 3, 'select');
+ expect(e.value).toBe('aXYdef');
+ expect(e.selectionStart).toBe(1);
+ expect(e.selectionEnd).toBe(3);
+ e.setRangeText('Q', 1, 3, 'end');
+ expect(e.selectionStart).toBe(2);
+ expect(e.selectionEnd).toBe(2);
+ expect(() => e.setRangeText('x', 3, 1)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/textarea/selection.ts b/packages/dom/src/compatibility/elements/textarea/selection.ts
new file mode 100644
index 0000000..3eb1d8c
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/textarea/selection.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection';
+export const requires = ['elements.textarea.value'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('uses UTF-16 offsets and clamps selection boundaries', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.value = 'a😀b';
+ element.setSelectionRange(1, 3, 'backward');
+ expect(element.selectionStart).toBe(1);
+ expect(element.selectionEnd).toBe(3);
+ expect(element.selectionDirection).toBe('backward');
+ element.setSelectionRange(99, 99);
+ expect(element.selectionStart).toBe(4);
+ expect(element.selectionEnd).toBe(4);
+ element.setSelectionRange(3, 1);
+ expect(element.selectionStart).toBe(1);
+ expect(element.selectionEnd).toBe(1);
+ element.select();
+ expect(element.selectionStart).toBe(0);
+ expect(element.selectionEnd).toBe(4);
+ });
+ context.test('preserves the selection when assigning the same value', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.value = 'hello';
+ element.setSelectionRange(1, 3);
+ element.value = 'hello';
+ expect(element.selectionStart).toBe(1);
+ expect(element.selectionEnd).toBe(3);
+ element.value = 'new';
+ expect(element.selectionStart).toBe(3);
+ expect(element.selectionEnd).toBe(3);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/textarea/validation.ts b/packages/dom/src/compatibility/elements/textarea/validation.ts
new file mode 100644
index 0000000..706d8cc
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/textarea/validation.ts
@@ -0,0 +1,49 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('exposes required and custom validity independently', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.required = true;
+ expect(element.willValidate).toBe(true);
+ expect(element.validity.valueMissing).toBe(true);
+ expect(element.checkValidity()).toBe(false);
+ element.value = 'present';
+ expect(element.validity.valueMissing).toBe(false);
+ element.setCustomValidity('Try again');
+ expect(element.validity.customError).toBe(true);
+ expect(element.validationMessage).toBe('Try again');
+ element.setCustomValidity('');
+ expect(element.validity.valid).toBe(true);
+ element.disabled = true;
+ expect(element.willValidate).toBe(false);
+ });
+
+ context.test('required textarea validity tracks its current value', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('textarea');
+ e.required = true;
+ expect(e.validity.valueMissing).toBe(true);
+ e.value = 'x';
+ expect(e.checkValidity()).toBe(true);
+ e.value = '';
+ e.readOnly = true;
+ expect(e.willValidate).toBe(false);
+ expect(e.checkValidity()).toBe(true);
+ });
+
+ context.test('reports and clears custom validity messages', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('textarea');
+ e.setCustomValidity('invalid');
+ expect(e.validity.customError).toBe(true);
+ expect(e.validationMessage).toBe('invalid');
+ e.setCustomValidity('');
+ expect(e.validity.valid).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/elements/textarea/value.ts b/packages/dom/src/compatibility/elements/textarea/value.ts
new file mode 100644
index 0000000..994b68a
--- /dev/null
+++ b/packages/dom/src/compatibility/elements/textarea/value.ts
@@ -0,0 +1,38 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-elements.html#the-textarea-element';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps default and dirty values separate', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.defaultValue = 'original';
+ expect(element.value).toBe('original');
+ element.value = 'edited';
+ element.defaultValue = 'replacement';
+ expect(element.value).toBe('edited');
+ expect(element.defaultValue).toBe('replacement');
+ });
+ context.test('normalizes line breaks in the API value', (fixture) => {
+ const document = fixture.document;
+ const element = document.createElement('textarea');
+ element.value = 'a\r\nb\rc';
+ expect(element.value).toBe('a\nb\nc');
+ });
+
+ context.test('normalizes newlines and separates dirty value from text defaults', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('textarea');
+ e.textContent = 'initial';
+ expect(e.defaultValue).toBe('initial');
+ expect(e.value).toBe('initial');
+ e.value = 'a\r\nb\rc';
+ expect(e.value).toBe('a\nb\nc');
+ e.textContent = 'new default';
+ expect(e.value).toBe('a\nb\nc');
+ expect(e.defaultValue).toBe('new default');
+ });
+}
diff --git a/packages/dom/src/compatibility/events/data/custom.ts b/packages/dom/src/compatibility/events/data/custom.ts
new file mode 100644
index 0000000..a6ca4af
--- /dev/null
+++ b/packages/dom/src/compatibility/events/data/custom.ts
@@ -0,0 +1,52 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['CustomEvent'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#interface-customevent';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test('retains detail identity and defaults omitted detail to null', ({ globals }) => {
+ const CustomEvent = globals('CustomEvent');
+ const detail = { answer: 42 };
+ const event = new CustomEvent('MixedCase', {
+ detail,
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ expect(event.type).toBe('MixedCase');
+ expect(event.detail).toBe(detail);
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.composed).toBe(true);
+ const defaults = new CustomEvent('probe');
+ expect(defaults.detail).toBeNull();
+ });
+
+ context.test('uses standard defaults for CustomEvent', (fixture) => {
+ const Constructor = fixture.globals('CustomEvent') as unknown as typeof CustomEvent;
+ const event = new Constructor('x');
+ expect(event.type).toBe('x');
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ expect(event.composed).toBe(false);
+ expect(event.isTrusted).toBe(false);
+ expect(event.detail).toBeNull();
+ });
+
+ context.test('preserves event constructor flags without implicit dispatch', (fixture) => {
+ const Constructor = fixture.globals('CustomEvent') as unknown as typeof CustomEvent;
+ const event = new Constructor('x', { bubbles: true, cancelable: true, composed: true });
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.composed).toBe(true);
+ event.preventDefault();
+ expect(event.defaultPrevented).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/data/focus.ts b/packages/dom/src/compatibility/events/data/focus.ts
new file mode 100644
index 0000000..ec28da9
--- /dev/null
+++ b/packages/dom/src/compatibility/events/data/focus.ts
@@ -0,0 +1,44 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['FocusEvent'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://w3c.github.io/uievents/#interface-focusevent';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test('initializes relatedTarget as synthetic focus data', ({ document, globals }) => {
+ const FocusEvent = globals('FocusEvent');
+ const target = document.createElement('div');
+ const event = new FocusEvent('focus', { relatedTarget: target });
+ expect(event.relatedTarget).toBe(target);
+ const defaults = new FocusEvent('blur');
+ expect(defaults.relatedTarget).toBeNull();
+ expect(event.isTrusted).toBe(false);
+ });
+
+ context.test('uses standard defaults for FocusEvent', (fixture) => {
+ const Constructor = fixture.globals('FocusEvent') as unknown as typeof FocusEvent;
+ const event = new Constructor('x');
+ expect(event.type).toBe('x');
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ expect(event.composed).toBe(false);
+ expect(event.isTrusted).toBe(false);
+ expect(event.relatedTarget).toBeNull();
+ });
+
+ context.test('preserves event constructor flags without implicit dispatch', (fixture) => {
+ const Constructor = fixture.globals('FocusEvent') as unknown as typeof FocusEvent;
+ const event = new Constructor('x', { bubbles: true, cancelable: true, composed: true });
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.composed).toBe(true);
+ event.preventDefault();
+ expect(event.defaultPrevented).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/data/input.ts b/packages/dom/src/compatibility/events/data/input.ts
new file mode 100644
index 0000000..af264e1
--- /dev/null
+++ b/packages/dom/src/compatibility/events/data/input.ts
@@ -0,0 +1,55 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['InputEvent'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://w3c.github.io/uievents/#interface-inputevent';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test('initializes edit data independently of editing behavior', ({ globals }) => {
+ const InputEvent = globals('InputEvent');
+ const event = new InputEvent('beforeinput', {
+ data: 'abc',
+ inputType: 'insertText',
+ isComposing: true,
+ cancelable: true,
+ });
+ expect([event.data, event.inputType, event.isComposing, event.cancelable]).toEqual([
+ 'abc',
+ 'insertText',
+ true,
+ true,
+ ]);
+ const defaults = new InputEvent('input');
+ expect([defaults.data, defaults.inputType, defaults.isComposing]).toEqual([null, '', false]);
+ expect(defaults.isTrusted).toBe(false);
+ });
+
+ context.test('uses standard defaults for InputEvent', (fixture) => {
+ const Constructor = fixture.globals('InputEvent') as unknown as typeof InputEvent;
+ const event = new Constructor('x');
+ expect(event.type).toBe('x');
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ expect(event.composed).toBe(false);
+ expect(event.isTrusted).toBe(false);
+ expect(event.data).toBeNull();
+ expect(event.inputType).toBe('');
+ expect(event.isComposing).toBe(false);
+ });
+
+ context.test('preserves event constructor flags without implicit dispatch', (fixture) => {
+ const Constructor = fixture.globals('InputEvent') as unknown as typeof InputEvent;
+ const event = new Constructor('x', { bubbles: true, cancelable: true, composed: true });
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.composed).toBe(true);
+ event.preventDefault();
+ expect(event.defaultPrevented).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/data/keyboard.ts b/packages/dom/src/compatibility/events/data/keyboard.ts
new file mode 100644
index 0000000..9bdb6e6
--- /dev/null
+++ b/packages/dom/src/compatibility/events/data/keyboard.ts
@@ -0,0 +1,84 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['KeyboardEvent'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://w3c.github.io/uievents/#interface-keyboardevent';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'initializes keyboard data and modifier queries without trusted input',
+ ({ globals }) => {
+ const KeyboardEvent = globals('KeyboardEvent');
+ const event = new KeyboardEvent('keydown', {
+ key: 'A',
+ code: 'KeyA',
+ location: 1,
+ repeat: true,
+ isComposing: true,
+ ctrlKey: true,
+ altKey: false,
+ shiftKey: true,
+ metaKey: false,
+ });
+ expect([event.key, event.code, event.location, event.repeat, event.isComposing]).toEqual([
+ 'A',
+ 'KeyA',
+ 1,
+ true,
+ true,
+ ]);
+ expect([event.ctrlKey, event.altKey, event.shiftKey, event.metaKey]).toEqual([
+ true,
+ false,
+ true,
+ false,
+ ]);
+ expect(
+ ['Control', 'Alt', 'Shift', 'Meta', 'Unrecognized'].map((name) =>
+ event.getModifierState(name),
+ ),
+ ).toEqual([true, false, true, false, false]);
+ expect(event.isTrusted).toBe(false);
+ const defaults = new KeyboardEvent('keyup');
+ expect([
+ defaults.key,
+ defaults.code,
+ defaults.location,
+ defaults.repeat,
+ defaults.isComposing,
+ ]).toEqual(['', '', 0, false, false]);
+ },
+ );
+
+ context.test('uses standard defaults for KeyboardEvent', (fixture) => {
+ const Constructor = fixture.globals('KeyboardEvent') as unknown as typeof KeyboardEvent;
+ const event = new Constructor('x');
+ expect(event.type).toBe('x');
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ expect(event.composed).toBe(false);
+ expect(event.isTrusted).toBe(false);
+ expect(event.key).toBe('');
+ expect(event.code).toBe('');
+ expect(event.location).toBe(0);
+ expect(event.repeat).toBe(false);
+ expect(event.ctrlKey).toBe(false);
+ expect(event.getModifierState('Shift')).toBe(false);
+ });
+
+ context.test('preserves event constructor flags without implicit dispatch', (fixture) => {
+ const Constructor = fixture.globals('KeyboardEvent') as unknown as typeof KeyboardEvent;
+ const event = new Constructor('x', { bubbles: true, cancelable: true, composed: true });
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.composed).toBe(true);
+ event.preventDefault();
+ expect(event.defaultPrevented).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/data/mouse.ts b/packages/dom/src/compatibility/events/data/mouse.ts
new file mode 100644
index 0000000..648d126
--- /dev/null
+++ b/packages/dom/src/compatibility/events/data/mouse.ts
@@ -0,0 +1,75 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['MouseEvent'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://w3c.github.io/uievents/#interface-mouseevent';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'initializes mouse buttons, coordinates, modifiers, and relatedTarget',
+ ({ document, globals }) => {
+ const MouseEvent = globals('MouseEvent');
+ const relatedTarget = document.createElement('div');
+ const event = new MouseEvent('mousedown', {
+ button: 2,
+ buttons: 3,
+ clientX: 12,
+ clientY: 13,
+ screenX: 20,
+ screenY: 21,
+ ctrlKey: true,
+ shiftKey: true,
+ relatedTarget,
+ });
+ expect([
+ event.button,
+ event.buttons,
+ event.clientX,
+ event.clientY,
+ event.screenX,
+ event.screenY,
+ ]).toEqual([2, 3, 12, 13, 20, 21]);
+ expect([event.ctrlKey, event.altKey, event.shiftKey, event.metaKey]).toEqual([
+ true,
+ false,
+ true,
+ false,
+ ]);
+ expect(event.relatedTarget).toBe(relatedTarget);
+ expect(event.isTrusted).toBe(false);
+ const defaults = new MouseEvent('mousemove');
+ expect(defaults.relatedTarget).toBeNull();
+ },
+ );
+
+ context.test('uses standard defaults for MouseEvent', (fixture) => {
+ const Constructor = fixture.globals('MouseEvent') as unknown as typeof MouseEvent;
+ const event = new Constructor('x');
+ expect(event.type).toBe('x');
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ expect(event.composed).toBe(false);
+ expect(event.isTrusted).toBe(false);
+ expect(event.button).toBe(0);
+ expect(event.buttons).toBe(0);
+ expect(event.clientX).toBe(0);
+ expect(event.ctrlKey).toBe(false);
+ expect(event.relatedTarget).toBeNull();
+ });
+
+ context.test('preserves event constructor flags without implicit dispatch', (fixture) => {
+ const Constructor = fixture.globals('MouseEvent') as unknown as typeof MouseEvent;
+ const event = new Constructor('x', { bubbles: true, cancelable: true, composed: true });
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.composed).toBe(true);
+ event.preventDefault();
+ expect(event.defaultPrevented).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/bubbling.ts b/packages/dom/src/compatibility/events/dispatch/bubbling.ts
new file mode 100644
index 0000000..147ee0c
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/bubbling.ts
@@ -0,0 +1,60 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'bubbles from the target through ancestors only when requested',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const root = document.createElement('div');
+ const parent = document.createElement('div');
+ const target = document.createElement('div');
+ root.appendChild(parent);
+ parent.appendChild(target);
+ const calls: unknown[] = [];
+ for (const [node, label] of [
+ [root, 'root'],
+ [parent, 'parent'],
+ [target, 'target'],
+ ] as const) {
+ node.addEventListener('probe', (event) => {
+ calls.push([
+ label,
+ event.eventPhase,
+ event.target === target,
+ event.currentTarget === node,
+ ]);
+ });
+ }
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual([['target', 2, true, true]]);
+ calls.length = 0;
+ target.dispatchEvent(new Event('probe', { bubbles: true }));
+ expect(calls).toEqual([
+ ['target', 2, true, true],
+ ['parent', 3, true, true],
+ ['root', 3, true, true],
+ ]);
+ },
+ );
+
+ context.test('nonbubbling events reach target but not bubble ancestors', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ const seen: string[] = [];
+ p.addEventListener('x', () => seen.push('parent'));
+ e.addEventListener('x', () => seen.push('target'));
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(seen).toEqual(['target']);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/cancellation.ts b/packages/dom/src/compatibility/events/dispatch/cancellation.ts
new file mode 100644
index 0000000..2937938
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/cancellation.ts
@@ -0,0 +1,56 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'returns false exactly when a cancelable event has been canceled',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ target.addEventListener('probe', (event) => {
+ event.preventDefault();
+ });
+ const cancelable = new Event('probe', { cancelable: true });
+ expect(target.dispatchEvent(cancelable)).toBe(false);
+ expect(cancelable.defaultPrevented).toBe(true);
+ const nonCancelable = new Event('probe');
+ expect(target.dispatchEvent(nonCancelable)).toBe(true);
+ expect(nonCancelable.defaultPrevented).toBe(false);
+ },
+ );
+ context.test('retains cancellation when a canceled event is reused', ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const event = new Event('probe', { cancelable: true });
+ event.preventDefault();
+ expect(target.dispatchEvent(event)).toBe(false);
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ context.test('noncancelable events ignore preventDefault and return true', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const Event = fixture.globals('Event'),
+ event = new Event('x');
+ e.addEventListener('x', (ev) => ev.preventDefault());
+ expect(e.dispatchEvent(event as unknown as Event)).toBe(true);
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ context.test('preserves canceled state on redispatch', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const Event = fixture.globals('Event'),
+ event = new Event('x', { cancelable: true });
+ event.preventDefault();
+ expect(e.dispatchEvent(event as unknown as Event)).toBe(false);
+ expect(event.defaultPrevented).toBe(true);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/capture.ts b/packages/dom/src/compatibility/events/dispatch/capture.ts
new file mode 100644
index 0000000..67a71b6
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/capture.ts
@@ -0,0 +1,92 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'captures non-bubbling events down the path, then delivers both target phases',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const root = document.createElement('div');
+ const parent = document.createElement('div');
+ const target = document.createElement('div');
+ root.appendChild(parent);
+ parent.appendChild(target);
+ const calls: unknown[] = [];
+ for (const [node, label] of [
+ [root, 'root'],
+ [parent, 'parent'],
+ [target, 'target'],
+ ] as const) {
+ node.addEventListener(
+ 'probe',
+ (event) => {
+ calls.push([
+ label,
+ 'capture',
+ event.eventPhase,
+ event.target === target,
+ event.currentTarget === node,
+ ]);
+ },
+ true,
+ );
+ node.addEventListener('probe', (event) => {
+ calls.push([
+ label,
+ 'bubble',
+ event.eventPhase,
+ event.target === target,
+ event.currentTarget === node,
+ ]);
+ });
+ }
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual([
+ ['root', 'capture', 1, true, true],
+ ['parent', 'capture', 1, true, true],
+ ['target', 'capture', 2, true, true],
+ ['target', 'bubble', 2, true, true],
+ ]);
+ },
+ );
+ context.test(
+ 'matches removal by capture, whether options are boolean or an object',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ let count = 0;
+ const listener = (): void => {
+ count++;
+ };
+ target.addEventListener('probe', listener, true);
+ target.addEventListener('probe', listener, false);
+ target.removeEventListener('probe', listener, { capture: true });
+ target.dispatchEvent(new Event('probe'));
+ expect(count).toBe(1);
+ target.removeEventListener('probe', listener, false);
+ target.dispatchEvent(new Event('probe'));
+ expect(count).toBe(1);
+ },
+ );
+
+ context.test('orders ancestor capture before target capture and target bubble', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ const seen: string[] = [];
+ p.addEventListener('x', () => seen.push('ancestor'), true);
+ e.addEventListener('x', () => seen.push('target-bubble'));
+ e.addEventListener('x', () => seen.push('target-capture'), true);
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(seen).toEqual(['ancestor', 'target-capture', 'target-bubble']);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/path-snapshot.ts b/packages/dom/src/compatibility/events/dispatch/path-snapshot.ts
new file mode 100644
index 0000000..17a861a
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/path-snapshot.ts
@@ -0,0 +1,66 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.bubbling'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'keeps the original path when the target is moved during dispatch',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const root = document.createElement('div');
+ const oldParent = document.createElement('div');
+ const newParent = document.createElement('div');
+ const target = document.createElement('div');
+ root.appendChild(oldParent);
+ root.appendChild(newParent);
+ oldParent.appendChild(target);
+ const calls: string[] = [];
+ const path: unknown[] = [];
+ target.addEventListener('probe', (event) => {
+ newParent.appendChild(target);
+ path.push(...event.composedPath());
+ });
+ oldParent.addEventListener('probe', () => {
+ calls.push('old');
+ });
+ newParent.addEventListener('probe', () => {
+ calls.push('new');
+ });
+ root.addEventListener('probe', () => {
+ calls.push('root');
+ });
+ target.dispatchEvent(new Event('probe', { bubbles: true }));
+ expect(calls).toEqual(['old', 'root']);
+ expect(path.length).toBe(3);
+ expect(path[0]).toBe(target);
+ expect(path[1]).toBe(oldParent);
+ expect(path[2]).toBe(root);
+ expect(target.parentNode).toBe(newParent);
+ },
+ );
+
+ context.test(
+ 'keeps original ancestor targets after the target moves during dispatch',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ q = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ const seen: unknown[] = [];
+ e.addEventListener('x', () => q.append(e));
+ p.addEventListener('x', (event) => seen.push(event.target));
+ q.addEventListener('x', () => seen.push('new-parent'));
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x', { bubbles: true }) as unknown as Event);
+ expect(seen).toEqual([e]);
+ expect(e.parentNode).toBe(q);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/reentrancy.ts b/packages/dom/src/compatibility/events/dispatch/reentrancy.ts
new file mode 100644
index 0000000..5b9349d
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/reentrancy.ts
@@ -0,0 +1,53 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'rejects redispatch of the active event but permits a distinct nested event',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const other = document.createElement('div');
+ const outer = new Event('outer');
+ const nested = new Event('nested');
+ const calls: string[] = [];
+ target.addEventListener('outer', (event) => {
+ expect(() => other.dispatchEvent(event)).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ other.dispatchEvent(nested);
+ expect(event.currentTarget).toBe(target);
+ expect(event.eventPhase).toBe(2);
+ calls.push('outer');
+ });
+ other.addEventListener('nested', () => {
+ calls.push('nested');
+ });
+ target.dispatchEvent(outer);
+ expect(calls).toEqual(['nested', 'outer']);
+ expect(other.dispatchEvent(outer)).toBe(true);
+ },
+ );
+
+ context.test('permits nested dispatch of a different event with independent state', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const Event = fixture.globals('Event');
+ const seen: string[] = [];
+ e.addEventListener('inner', () => seen.push('inner'));
+ e.addEventListener('outer', () => {
+ seen.push('before');
+ e.dispatchEvent(new Event('inner') as unknown as Event);
+ seen.push('after');
+ });
+ e.dispatchEvent(new Event('outer') as unknown as Event);
+ expect(seen).toEqual(['before', 'inner', 'after']);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/reuse.ts b/packages/dom/src/compatibility/events/dispatch/reuse.ts
new file mode 100644
index 0000000..96ee830
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/reuse.ts
@@ -0,0 +1,46 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'updates target when an event is reused on another element',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const first = document.createElement('div');
+ const second = document.createElement('div');
+ const event = new Event('probe');
+ first.dispatchEvent(event);
+ expect(event.target).toBe(first);
+ second.dispatchEvent(event);
+ expect(event.target).toBe(second);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.composedPath()).toEqual([]);
+ },
+ );
+
+ context.test('clears stopPropagation state for the next dispatch', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ let count = 0;
+ const stop = (event: Event): void => event.stopPropagation();
+ e.addEventListener('x', stop);
+ p.addEventListener('x', () => count++);
+ const Event = fixture.globals('Event'),
+ event = new Event('x', { bubbles: true });
+ e.dispatchEvent(event as unknown as Event);
+ expect(count).toBe(0);
+ e.removeEventListener('x', stop);
+ e.dispatchEvent(event as unknown as Event);
+ expect(count).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/stop-immediate-propagation.ts b/packages/dom/src/compatibility/events/dispatch/stop-immediate-propagation.ts
new file mode 100644
index 0000000..76d1e8f
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/stop-immediate-propagation.ts
@@ -0,0 +1,60 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'stops remaining listeners on the current invocation target',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const calls: string[] = [];
+ target.addEventListener('probe', (event) => {
+ calls.push('first');
+ event.stopImmediatePropagation();
+ });
+ target.addEventListener('probe', () => {
+ calls.push('second');
+ });
+ const event = new Event('probe');
+ target.dispatchEvent(event);
+ target.dispatchEvent(event);
+ expect(calls).toEqual(['first', 'first']);
+ },
+ );
+ context.test(
+ 'stops both the remaining target capture listeners and the target non-capture invocation',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const calls: string[] = [];
+ target.addEventListener(
+ 'probe',
+ (event) => {
+ calls.push('first');
+ event.stopImmediatePropagation();
+ },
+ true,
+ );
+ target.addEventListener(
+ 'probe',
+ () => {
+ calls.push('second');
+ },
+ true,
+ );
+ target.addEventListener('probe', () => {
+ calls.push('non-capture');
+ });
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['first']);
+ },
+ ['events.dispatch.capture'],
+ );
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/stop-propagation.ts b/packages/dom/src/compatibility/events/dispatch/stop-propagation.ts
new file mode 100644
index 0000000..bffe56e
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/stop-propagation.ts
@@ -0,0 +1,127 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'stops subsequent ancestors while preserving remaining listeners on the same invocation target',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const parent = document.createElement('div');
+ const target = document.createElement('div');
+ parent.appendChild(target);
+ const calls: string[] = [];
+ target.addEventListener('probe', (event) => {
+ calls.push('first');
+ event.stopPropagation();
+ });
+ target.addEventListener('probe', () => {
+ calls.push('second');
+ });
+ parent.addEventListener('probe', () => {
+ calls.push('parent');
+ });
+ expect(target.dispatchEvent(new Event('probe', { bubbles: true, cancelable: true }))).toBe(
+ true,
+ );
+ expect(calls).toEqual(['first', 'second']);
+ },
+ ['events.dispatch.bubbling'],
+ );
+ context.test('resets a stop flag at the end of dispatch', ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const event = new Event('probe');
+ let calls = 0;
+ target.addEventListener('probe', (received) => {
+ calls++;
+ received.stopPropagation();
+ });
+ target.dispatchEvent(event);
+ target.dispatchEvent(event);
+ expect(calls).toBe(2);
+ });
+ context.test(
+ 'honors a stop flag set before dispatch and clears it afterward',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const event = new Event('probe');
+ let calls = 0;
+ target.addEventListener('probe', () => {
+ calls++;
+ });
+ event.stopPropagation();
+ target.dispatchEvent(event);
+ expect(calls).toBe(0);
+ target.dispatchEvent(event);
+ expect(calls).toBe(1);
+ },
+ );
+ context.test(
+ 'stops between target capture and target non-capture invocations',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const calls: string[] = [];
+ target.addEventListener(
+ 'probe',
+ (event) => {
+ calls.push('capture first');
+ event.stopPropagation();
+ },
+ true,
+ );
+ target.addEventListener(
+ 'probe',
+ () => {
+ calls.push('capture second');
+ },
+ true,
+ );
+ target.addEventListener('probe', () => {
+ calls.push('non-capture');
+ });
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['capture first', 'capture second']);
+ },
+ ['events.dispatch.capture'],
+ );
+ context.test(
+ 'stops before reaching the target when an ancestor capture listener requests it',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const parent = document.createElement('div');
+ const target = document.createElement('div');
+ parent.appendChild(target);
+ const calls: string[] = [];
+ parent.addEventListener(
+ 'probe',
+ (event) => {
+ calls.push('parent');
+ event.stopPropagation();
+ },
+ true,
+ );
+ target.addEventListener(
+ 'probe',
+ () => {
+ calls.push('target capture');
+ },
+ true,
+ );
+ target.addEventListener('probe', () => {
+ calls.push('target non-capture');
+ });
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['parent']);
+ },
+ ['events.dispatch.capture'],
+ );
+}
diff --git a/packages/dom/src/compatibility/events/dispatch/target.ts b/packages/dom/src/compatibility/events/dispatch/target.ts
new file mode 100644
index 0000000..742c8bc
--- /dev/null
+++ b/packages/dom/src/compatibility/events/dispatch/target.ts
@@ -0,0 +1,71 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'delivers a synthetic event to the target with the correct receiver and cleans up state',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const event = new Event('MixedCase');
+ const seen: unknown[] = [];
+ target.addEventListener('MixedCase', function (received) {
+ seen.push(received, this, received.target, received.currentTarget, received.eventPhase);
+ });
+ expect(event.target).toBeNull();
+ expect(target.dispatchEvent(event)).toBe(true);
+ expect(seen.length).toBe(5);
+ expect(seen[0]).toBe(event);
+ expect(seen[1]).toBe(target);
+ expect(seen[2]).toBe(target);
+ expect(seen[3]).toBe(target);
+ expect(seen[4]).toBe(2);
+ expect(event.target).toBe(target);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.composedPath()).toEqual([]);
+ expect(event.isTrusted).toBe(false);
+ },
+ );
+ context.test('does not lowercase event names', ({ document, globals }) => {
+ const target = document.createElement('div');
+ let count = 0;
+ target.addEventListener('MixedCase', () => {
+ count++;
+ });
+ const Event = globals('Event');
+ target.dispatchEvent(new Event('mixedcase'));
+ expect(count).toBe(0);
+ target.dispatchEvent(new Event('MixedCase'));
+ expect(count).toBe(1);
+ });
+
+ context.test(
+ 'clears currentTarget and phase after dispatch while retaining target',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const Event = fixture.globals('Event'),
+ event = new Event('x');
+ let phase = 0;
+ const receivers: unknown[] = [];
+ e.addEventListener('x', function (this: HTMLElement, ev) {
+ phase = ev.eventPhase;
+ receivers.push(this);
+ });
+ e.dispatchEvent(event as unknown as Event);
+ expect(phase).toBe(2);
+ expect(receivers).toEqual([e]);
+ expect(event.target).toBe(e);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(0);
+ expect(event.composedPath()).toEqual([]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/events/listeners/abort-signal.ts b/packages/dom/src/compatibility/events/listeners/abort-signal.ts
new file mode 100644
index 0000000..e170010
--- /dev/null
+++ b/packages/dom/src/compatibility/events/listeners/abort-signal.ts
@@ -0,0 +1,85 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event', 'AbortController'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#add-an-event-listener';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/dom/events/AddEventListenerOptions-signal.any.js';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'removes a listener when its signal aborts and ignores already-aborted signals',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const AbortController = globals('AbortController');
+ const target = document.createElement('div');
+ const controller = new AbortController();
+ let calls = 0;
+ const listener = (): void => {
+ calls++;
+ };
+ target.addEventListener('probe', listener, { signal: controller.signal });
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toBe(1);
+ controller.abort();
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toBe(1);
+ target.addEventListener('probe', listener, { signal: controller.signal });
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toBe(1);
+ },
+ );
+ context.test(
+ 'aborting a signal removes listeners still waiting in the current invocation',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const AbortController = globals('AbortController');
+ const target = document.createElement('div');
+ const controller = new AbortController();
+ const calls: string[] = [];
+ target.addEventListener('probe', () => {
+ calls.push('first');
+ controller.abort();
+ });
+ target.addEventListener(
+ 'probe',
+ () => {
+ calls.push('aborted');
+ },
+ { signal: controller.signal },
+ );
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['first']);
+ },
+ );
+ context.test('validates a signal even when the callback is null', ({ document }) => {
+ const target = document.createElement('div');
+ expect(() => {
+ // @ts-expect-error -- Verify Web IDL rejects null instead of an AbortSignal.
+ target.addEventListener('probe', null, { signal: null });
+ }).toThrow(expect.objectContaining({ name: 'TypeError' }));
+ });
+
+ context.test(
+ 'ignores already aborted signals and removes multiple listeners synchronously',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const AbortController = fixture.globals('AbortController'),
+ Event = fixture.globals('Event');
+ const controller = new AbortController();
+ let n = 0;
+ e.addEventListener('x', () => n++, { signal: controller.signal });
+ e.addEventListener('y', () => n++, { signal: controller.signal });
+ controller.abort();
+ e.addEventListener('x', () => n++, { signal: controller.signal });
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ e.dispatchEvent(new Event('y') as unknown as Event);
+ expect(n).toBe(0);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/events/listeners/exception-reporting.ts b/packages/dom/src/compatibility/events/listeners/exception-reporting.ts
new file mode 100644
index 0000000..4f70b39
--- /dev/null
+++ b/packages/dom/src/compatibility/events/listeners/exception-reporting.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMEvent } from '../../contracts/events.ts';
+
+const requiredGlobals = ['Event', 'ErrorEvent', 'window'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://html.spec.whatwg.org/multipage/webappapis.html#report-an-exception';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'reports listener exceptions as global error events without interrupting dispatch',
+ ({ document, globals, cleanup }) => {
+ const Event = globals('Event');
+ const ErrorEvent = globals('ErrorEvent');
+ const window = globals('window');
+ const target = document.createElement('div');
+ const failure = new TypeError('compatibility listener failure');
+ const errors: unknown[] = [];
+ const listener = (event: DOMEvent): void => {
+ if (event instanceof ErrorEvent && event.error === failure) {
+ errors.push(event.error);
+ event.preventDefault();
+ }
+ };
+ window.addEventListener('error', listener);
+ cleanup(() => {
+ window.removeEventListener('error', listener);
+ });
+ let following = 0;
+ target.addEventListener('probe', () => {
+ throw failure;
+ });
+ target.addEventListener('probe', () => {
+ following++;
+ });
+ expect(target.dispatchEvent(new Event('probe'))).toBe(true);
+ expect(following).toBe(1);
+ expect(errors.length).toBe(1);
+ expect(errors[0]).toBe(failure);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/events/listeners/objects.ts b/packages/dom/src/compatibility/events/listeners/objects.ts
new file mode 100644
index 0000000..c354c24
--- /dev/null
+++ b/packages/dom/src/compatibility/events/listeners/objects.ts
@@ -0,0 +1,66 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-listener-inner-invoke';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'calls handleEvent with the listener object as its receiver',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const receivers: unknown[] = [];
+ const calls: string[] = [];
+ const listener = {
+ handleEvent() {
+ receivers.push(this);
+ calls.push('initial');
+ },
+ };
+ target.addEventListener('probe', listener);
+ target.dispatchEvent(new Event('probe'));
+ listener.handleEvent = function () {
+ receivers.push(this);
+ calls.push('replacement');
+ };
+ target.dispatchEvent(new Event('probe'));
+ expect(receivers.length).toBe(2);
+ expect(receivers[0]).toBe(listener);
+ expect(receivers[1]).toBe(listener);
+ expect(calls).toEqual(['initial', 'replacement']);
+ target.removeEventListener('probe', listener);
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['initial', 'replacement']);
+ },
+ );
+
+ context.test(
+ 'looks up handleEvent at invocation and preserves the object receiver',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div'),
+ seen: unknown[] = [];
+ const listener = {
+ handleEvent() {
+ seen.push(this);
+ },
+ };
+ e.addEventListener('x', listener);
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ listener.handleEvent = function () {
+ seen.push('changed', this);
+ };
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(seen).toEqual([listener, 'changed', listener]);
+ e.removeEventListener('x', listener);
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(seen.length).toBe(3);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/events/listeners/once.ts b/packages/dom/src/compatibility/events/listeners/once.ts
new file mode 100644
index 0000000..76148d9
--- /dev/null
+++ b/packages/dom/src/compatibility/events/listeners/once.ts
@@ -0,0 +1,66 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target', 'events.listeners.registration'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-listener-inner-invoke';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/dom/events/AddEventListenerOptions-once.any.js';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'removes a once listener before calling it, including nested dispatch',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ let calls = 0;
+ target.addEventListener(
+ 'probe',
+ () => {
+ calls++;
+ target.dispatchEvent(new Event('probe'));
+ },
+ { once: true },
+ );
+ target.dispatchEvent(new Event('probe'));
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toBe(1);
+ },
+ );
+ context.test(
+ 'does not replace an existing registration when once changes',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ let calls = 0;
+ const listener = (): void => {
+ calls++;
+ };
+ target.addEventListener('probe', listener);
+ target.addEventListener('probe', listener, { once: true });
+ target.dispatchEvent(new Event('probe'));
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toBe(2);
+ },
+ );
+
+ context.test('removes once listeners before nested dispatch', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ let n = 0;
+ const Event = fixture.globals('Event');
+ e.addEventListener(
+ 'x',
+ () => {
+ n++;
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ },
+ { once: true },
+ );
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(n).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/listeners/passive.ts b/packages/dom/src/compatibility/events/listeners/passive.ts
new file mode 100644
index 0000000..219d035
--- /dev/null
+++ b/packages/dom/src/compatibility/events/listeners/passive.ts
@@ -0,0 +1,75 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.cancellation'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#set-the-canceled-flag';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/dom/events/AddEventListenerOptions-passive.any.js';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'prevents cancellation only while invoking a passive listener',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const prevented: boolean[] = [];
+ target.addEventListener(
+ 'probe',
+ (event) => {
+ event.preventDefault();
+ prevented.push(event.defaultPrevented);
+ },
+ { passive: true },
+ );
+ target.addEventListener(
+ 'probe',
+ (event) => {
+ event.preventDefault();
+ prevented.push(event.defaultPrevented);
+ },
+ { passive: false },
+ );
+ expect(target.dispatchEvent(new Event('probe', { cancelable: true }))).toBe(false);
+ expect(prevented).toEqual([false, true]);
+ },
+ );
+
+ context.test('passive listeners cannot cancel but ordinary listeners still can', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const Event = fixture.globals('Event'),
+ seen: boolean[] = [];
+ e.addEventListener(
+ 'x',
+ (ev) => {
+ ev.preventDefault();
+ seen.push(ev.defaultPrevented);
+ },
+ { passive: true },
+ );
+ e.addEventListener('x', (ev) => {
+ ev.preventDefault();
+ seen.push(ev.defaultPrevented);
+ });
+ const event = new Event('x', { cancelable: true });
+ expect(e.dispatchEvent(event as unknown as Event)).toBe(false);
+ expect(seen).toEqual([false, true]);
+ });
+
+ context.test('passive listeners can still stop propagation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ let count = 0;
+ e.addEventListener('x', (ev) => ev.stopPropagation(), { passive: true });
+ p.addEventListener('x', () => count++);
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x', { bubbles: true }) as unknown as Event);
+ expect(count).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/listeners/registration.ts b/packages/dom/src/compatibility/events/listeners/registration.ts
new file mode 100644
index 0000000..69edbc3
--- /dev/null
+++ b/packages/dom/src/compatibility/events/listeners/registration.ts
@@ -0,0 +1,65 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-listener';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test('deduplicates by callback identity and event type', ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ let calls = 0;
+ const listener = (): void => {
+ calls++;
+ };
+ target.addEventListener('probe', listener);
+ target.addEventListener('probe', listener);
+ target.addEventListener('other', listener);
+ target.addEventListener('probe', null);
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toBe(1);
+ target.dispatchEvent(new Event('other'));
+ expect(calls).toBe(2);
+ target.removeEventListener('probe', listener);
+ target.removeEventListener('probe', listener);
+ target.removeEventListener('probe', null);
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toBe(2);
+ });
+
+ context.test('deduplicates by type, callback and capture independently of once', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const calls: string[] = [];
+ const callback = (): number => calls.push('x');
+ e.addEventListener('x', callback);
+ e.addEventListener('x', callback, { once: true });
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(calls).toEqual(['x', 'x']);
+ e.removeEventListener('x', callback, { capture: true });
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(calls.length).toBe(3);
+ e.removeEventListener('x', callback);
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(calls.length).toBe(3);
+ });
+
+ context.test('ignores null listeners and matches event type case sensitively', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ let n = 0;
+ Reflect.apply(e.addEventListener, e, ['x', null]);
+ e.addEventListener('X', () => n++);
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(n).toBe(0);
+ e.dispatchEvent(new Event('X') as unknown as Event);
+ expect(n).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/events/listeners/snapshots.ts b/packages/dom/src/compatibility/events/listeners/snapshots.ts
new file mode 100644
index 0000000..3fa47b0
--- /dev/null
+++ b/packages/dom/src/compatibility/events/listeners/snapshots.ts
@@ -0,0 +1,102 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target', 'events.listeners.registration'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-listener-invoke';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'removals take effect immediately and additions do not enter an active invocation',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const calls: string[] = [];
+ let first = true;
+ const removed = (): void => {
+ calls.push('removed');
+ };
+ const added = (): void => {
+ calls.push('added');
+ };
+ target.addEventListener('probe', () => {
+ calls.push('first');
+ if (first) {
+ first = false;
+ target.removeEventListener('probe', removed);
+ target.addEventListener('probe', removed);
+ target.addEventListener('probe', added);
+ }
+ });
+ target.addEventListener('probe', removed);
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['first']);
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['first', 'first', 'removed', 'added']);
+ },
+ );
+ context.test(
+ 'takes a fresh snapshot for the target non-capture invocation',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const target = document.createElement('div');
+ const calls: string[] = [];
+ target.addEventListener(
+ 'probe',
+ () => {
+ calls.push('capture');
+ target.addEventListener('probe', () => {
+ calls.push('added non-capture');
+ });
+ target.addEventListener(
+ 'probe',
+ () => {
+ calls.push('added capture');
+ },
+ true,
+ );
+ },
+ true,
+ );
+ target.dispatchEvent(new Event('probe'));
+ expect(calls).toEqual(['capture', 'added non-capture']);
+ },
+ ['events.dispatch.capture'],
+ );
+ context.test(
+ 'permits listeners added before a later ancestor invocation',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const parent = document.createElement('div');
+ const target = document.createElement('div');
+ parent.appendChild(target);
+ const calls: string[] = [];
+ target.addEventListener('probe', () => {
+ parent.addEventListener('probe', () => {
+ calls.push('new ancestor listener');
+ });
+ });
+ target.dispatchEvent(new Event('probe', { bubbles: true }));
+ expect(calls).toEqual(['new ancestor listener']);
+ },
+ ['events.dispatch.bubbling'],
+ );
+
+ context.test('removal before a listener turn prevents its invocation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ const seen: string[] = [];
+ const second = (): number => seen.push('second');
+ e.addEventListener('x', () => {
+ seen.push('first');
+ e.removeEventListener('x', second);
+ });
+ e.addEventListener('x', second);
+ const Event = fixture.globals('Event');
+ e.dispatchEvent(new Event('x') as unknown as Event);
+ expect(seen).toEqual(['first']);
+ });
+}
diff --git a/packages/dom/src/compatibility/features.ts b/packages/dom/src/compatibility/features.ts
new file mode 100644
index 0000000..dd42c4d
--- /dev/null
+++ b/packages/dom/src/compatibility/features.ts
@@ -0,0 +1,394 @@
+import { Type } from 'typebox';
+import { Check, Errors } from 'typebox/value';
+import type { StandardSchemaV1 } from '@standard-schema/spec';
+import type { TLocalizedValidationError } from 'typebox/error';
+
+/** Core DOM first, then higher-level APIs and host interactions. Values describe the schema. */
+export const featureShape = {
+ document: {
+ basicCreation: true,
+ creation: true,
+ lookup: true,
+ importNode: true,
+ xml: true,
+ parsing: true,
+ xmlSerialization: true,
+ },
+ node: {
+ basicAppend: true,
+ relationships: true,
+ connectivity: true,
+ liveCollections: true,
+ textContent: true,
+ append: true,
+ insertBefore: true,
+ remove: true,
+ replace: true,
+ fragments: true,
+ variadicMutation: true,
+ cloning: true,
+ comparison: true,
+ normalization: true,
+ adoption: true,
+ },
+ characterData: {
+ stringStorage: true,
+ data: true,
+ editing: true,
+ splitText: true,
+ },
+ element: {
+ childElements: true,
+ attributes: true,
+ attributeNodes: true,
+ namespaces: true,
+ classList: true,
+ dataset: true,
+ reflection: true,
+ selectors: true,
+ },
+ traversal: {
+ nodeIterator: true,
+ treeWalker: true,
+ },
+ parsing: {
+ fragments: true,
+ namespaces: true,
+ serialization: true,
+ contextual: true,
+ markupMutation: true,
+ recovery: true,
+ },
+ templates: {
+ content: true,
+ cloning: true,
+ parsing: true,
+ },
+ events: {
+ data: {
+ custom: true,
+ focus: true,
+ keyboard: true,
+ mouse: true,
+ input: true,
+ },
+ dispatch: {
+ target: true,
+ capture: true,
+ bubbling: true,
+ cancellation: true,
+ stopPropagation: true,
+ stopImmediatePropagation: true,
+ pathSnapshot: true,
+ reuse: true,
+ reentrancy: true,
+ },
+ listeners: {
+ registration: true,
+ objects: true,
+ once: true,
+ passive: true,
+ abortSignal: true,
+ snapshots: true,
+ exceptionReporting: true,
+ },
+ },
+ mutationObserver: {
+ registration: true,
+ records: {
+ childList: true,
+ attributes: true,
+ characterData: true,
+ },
+ filtering: true,
+ oldValue: true,
+ subtree: true,
+ takeRecords: true,
+ disconnect: true,
+ microtaskDelivery: true,
+ detached: true,
+ reentrancy: true,
+ },
+ focus: {
+ eligibility: true,
+ transitions: true,
+ removal: true,
+ inert: true,
+ reentrancy: true,
+ },
+ selection: {
+ range: {
+ boundaries: true,
+ comparison: true,
+ stringification: true,
+ cloneContents: true,
+ deleteContents: true,
+ extractContents: true,
+ liveBoundaries: true,
+ mutation: true,
+ },
+ document: {
+ rangeManagement: true,
+ direction: true,
+ connectivity: true,
+ liveUpdates: true,
+ mutation: true,
+ changeEvents: true,
+ },
+ },
+ elements: {
+ progress: true,
+ meter: true,
+ option: {
+ attributes: true,
+ text: true,
+ selectedness: true,
+ },
+ optgroup: true,
+ datalist: true,
+ input: {
+ value: true,
+ sanitization: true,
+ colorValue: true,
+ numericValue: true,
+ dateValue: true,
+ stepping: true,
+ checkedness: true,
+ radioGroups: true,
+ selection: true,
+ rangeText: true,
+ validation: true,
+ formAssociation: true,
+ labels: true,
+ activation: true,
+ cloning: true,
+ },
+ textarea: {
+ value: true,
+ selection: true,
+ rangeText: true,
+ validation: true,
+ formAssociation: true,
+ labels: true,
+ cloning: true,
+ },
+ select: {
+ options: true,
+ indexedOptions: true,
+ selection: true,
+ mutation: true,
+ validation: true,
+ cloning: true,
+ },
+ selectedcontent: true,
+ form: {
+ controls: true,
+ namedControls: true,
+ reset: true,
+ validation: true,
+ submission: true,
+ formData: true,
+ dataEvents: true,
+ },
+ button: {
+ attributes: true,
+ formAssociation: true,
+ activation: true,
+ },
+ label: {
+ association: true,
+ activation: true,
+ },
+ fieldset: {
+ controls: true,
+ disabled: true,
+ },
+ legend: true,
+ output: {
+ htmlFor: true,
+ value: true,
+ reset: true,
+ },
+ details: {
+ open: true,
+ toggleEvents: true,
+ exclusiveGroups: true,
+ },
+ summary: {
+ activation: true,
+ },
+ dialog: {
+ show: true,
+ close: true,
+ modal: true,
+ requestClose: true,
+ focus: true,
+ formSubmission: true,
+ },
+ },
+ customElements: {
+ registration: true,
+ construction: true,
+ upgrading: true,
+ observedAttributes: true,
+ connection: true,
+ adoption: true,
+ reactionOrdering: true,
+ reentrancy: true,
+ exceptionReporting: true,
+ formAssociation: true,
+ },
+ shadow: {
+ roots: {
+ attachment: true,
+ hostEligibility: true,
+ connectivity: true,
+ cloning: true,
+ adoption: true,
+ },
+ slots: {
+ named: true,
+ fallback: true,
+ manual: true,
+ slotchange: true,
+ observerOrdering: true,
+ },
+ events: {
+ retargeting: true,
+ composedPath: true,
+ relatedTarget: true,
+ },
+ focus: {
+ retargeting: true,
+ delegation: true,
+ },
+ declarative: true,
+ serialization: true,
+ },
+ style: {
+ inline: true,
+ parsing: true,
+ attributeSynchronization: true,
+ customProperties: true,
+ stylesheets: true,
+ computed: true,
+ cascade: true,
+ layers: true,
+ constructedStylesheets: true,
+ focusState: true,
+ controlSelectors: true,
+ relationalSelectors: true,
+ mutationObservation: true,
+ },
+ input: {
+ keyboard: {
+ delivery: true,
+ modifiers: true,
+ navigation: true,
+ activation: true,
+ editing: true,
+ multilineEditing: true,
+ numberStepping: true,
+ focusStyles: true,
+ focusVisible: true,
+ shadow: true,
+ implicitSubmission: true,
+ trustedEvents: true,
+ },
+ mouse: {
+ delivery: true,
+ buttons: true,
+ hover: true,
+ focus: true,
+ activation: true,
+ stateStyles: true,
+ focusVisible: true,
+ shadow: true,
+ trustedEvents: true,
+ },
+ },
+} as const;
+
+export type FeatureFlags = {
+ readonly [K in keyof T]: T[K] extends boolean ? boolean : FeatureFlags;
+};
+export type DOMFeatures = FeatureFlags;
+type Paths = {
+ [K in keyof T & string]: T[K] extends boolean ? K : `${K}.${Paths}`;
+}[keyof T & string];
+export type DOMFeaturePath = Paths;
+
+interface FeatureBranch {
+ readonly [key: string]: boolean | FeatureBranch;
+}
+
+function featureSchema(shape: FeatureBranch): Type.TSchema {
+ return Type.Object(
+ Object.fromEntries(
+ Object.entries(shape).map(([key, value]) => [
+ key,
+ typeof value === 'boolean' ? Type.Boolean() : featureSchema(value),
+ ]),
+ ),
+ { additionalProperties: false },
+ );
+}
+
+// Both the schema and DOMFeatures derive from featureShape. Object.entries loses its literal keys.
+const featureType = Type.Unsafe(featureSchema(featureShape));
+
+function featureIssues(error: TLocalizedValidationError): StandardSchemaV1.Issue[] {
+ const path = error.instancePath
+ .split('/')
+ .slice(1)
+ .map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'));
+ const keys =
+ error.keyword === 'required'
+ ? error.params.requiredProperties
+ : error.keyword === 'additionalProperties'
+ ? error.params.additionalProperties
+ : [];
+ return keys.length
+ ? keys.map((key) => ({ message: error.message, path: [...path, key] }))
+ : [{ message: error.message, path }];
+}
+
+/** Validate configuration only; DOM behavior is verified directly by enabled cases. */
+export const domFeaturesSchema: StandardSchemaV1 = {
+ '~standard': {
+ version: 1,
+ vendor: 'typebox',
+ validate(value) {
+ if (Check(featureType, value)) return { value };
+ return { issues: Errors(featureType, value).flatMap(featureIssues) };
+ },
+ },
+};
+
+/** Compatibility enabled support. */
+export function enabled(features: DOMFeatures, path: DOMFeaturePath): boolean {
+ let value: unknown = features;
+ for (const key of path.split('.')) {
+ if (typeof value !== 'object' || value === null) return false;
+ value = Reflect.get(value, key);
+ }
+ return value === true;
+}
+
+/** File paths are a mechanical spelling of the exhaustive schema paths. */
+export function featureFile(path: string): string {
+ return (
+ path
+ .split('.')
+ .map((part) => part.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`))
+ .join('/') + '.ts'
+ );
+}
+
+/** Compatibility featurePaths support. */
+export function featurePaths(shape: FeatureBranch = featureShape, prefix = ''): string[] {
+ return Object.entries(shape).flatMap(([key, value]) => {
+ const path = prefix ? `${prefix}.${key}` : key;
+ return typeof value === 'boolean' ? [path] : featurePaths(value, path);
+ });
+}
diff --git a/packages/dom/src/compatibility/fixture.ts b/packages/dom/src/compatibility/fixture.ts
new file mode 100644
index 0000000..b27472d
--- /dev/null
+++ b/packages/dom/src/compatibility/fixture.ts
@@ -0,0 +1,93 @@
+import type { DOMFixture } from './context.ts';
+import { MissingGlobalError, type DOMGlobalBindings, type DOMGlobals } from './globals.ts';
+import type { DOMDocument } from './contracts/nodes.ts';
+import {
+ MissingInputError,
+ testInput,
+ type DOMInputDriver,
+ type DOMTestInput,
+} from './input-driver.ts';
+
+export interface DOMFixtureOptions {
+ create(): D;
+ globals: DOMGlobalBindings | ((document: D) => DOMGlobalBindings);
+ /** Construct a fresh input session lazily, only when a case uses host input. */
+ input?(document: D): DOMInputDriver;
+ /** Dispose implementation-owned resources after each case, including failures. */
+ dispose?(document: D): void | Promise;
+}
+
+/** Compatibility FixtureFailure support. */
+export class FixtureFailure extends AggregateError {
+ constructor(errors: unknown[]) {
+ super(errors, 'Compatibility case and fixture cleanup reported multiple failures');
+ this.name = 'FixtureFailure';
+ }
+}
+
+/** A case owns its document, bindings and cleanups, including during asynchronous work. */
+export async function withFixture(
+ options: DOMFixtureOptions,
+ run: (fixture: DOMFixture) => void | Promise,
+): Promise {
+ const subject = options.create();
+ const cleanups: (() => void | Promise)[] = [];
+ const failures: unknown[] = [];
+ let driver: DOMInputDriver | undefined;
+ let input: DOMTestInput | undefined;
+ try {
+ const bindings =
+ typeof options.globals === 'function' ? options.globals(subject) : options.globals;
+ // Test contracts describe the expected APIs; enabled cases verify their behavior.
+ const document = subject as DOMDocument;
+ const globals = bindings as DOMGlobals;
+ await run({
+ document,
+ get input() {
+ if (!input) {
+ if (!options.input) throw new MissingInputError();
+ driver = options.input(subject);
+ input = testInput(driver);
+ }
+ return input;
+ },
+ globals(name) {
+ if (!(name in globals)) throw new MissingGlobalError(name);
+ return globals[name];
+ },
+ cleanup(callback) {
+ cleanups.push(callback);
+ },
+ connect(element) {
+ const parent = document.body ?? document.documentElement ?? document;
+ parent.appendChild(element);
+ cleanups.push(() => element.remove());
+ return element;
+ },
+ });
+ } catch (error) {
+ failures.push(error);
+ } finally {
+ // Release input while the document and its targets are still alive.
+ try {
+ await driver?.reset();
+ } catch (error) {
+ failures.push(error);
+ }
+ // One failed cleanup must not retain listeners or prevent disposal of a browser frame.
+ for (const cleanup of cleanups.toReversed()) {
+ try {
+ await cleanup();
+ } catch (error) {
+ failures.push(error);
+ }
+ }
+ try {
+ await options.dispose?.(subject);
+ } catch (error) {
+ failures.push(error);
+ }
+ }
+ if (failures.length === 1) throw failures[0];
+ if (failures.length > 1) throw new FixtureFailure(failures);
+}
diff --git a/packages/dom/src/compatibility/focus/eligibility.ts b/packages/dom/src/compatibility/focus/eligibility.ts
new file mode 100644
index 0000000..ed8cd3f
--- /dev/null
+++ b/packages/dom/src/compatibility/focus/eligibility.ts
@@ -0,0 +1,50 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/interaction.html#focusable-area';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'focuses enabled controls and explicitly focusable HTML elements',
+ ({ document, connect }) => {
+ const root = document.createElement('div');
+ const button = document.createElement('button');
+ const custom = document.createElement('div');
+ custom.tabIndex = -1;
+ root.append(button, custom);
+ connect(root);
+ button.focus();
+ expect(document.activeElement).toBe(button);
+ custom.focus();
+ expect(document.activeElement).toBe(custom);
+ button.setAttribute('disabled', '');
+ button.focus();
+ expect(document.activeElement).toBe(custom);
+ const detached = document.createElement('button');
+ detached.focus();
+ expect(document.activeElement).toBe(custom);
+ },
+ );
+
+ context.test(
+ 'rejects disabled and hidden controls but accepts negative tabindex focus',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ disabled = document.createElement('input'),
+ hidden = document.createElement('input'),
+ target = document.createElement('div');
+ disabled.disabled = true;
+ hidden.type = 'hidden';
+ target.tabIndex = -1;
+ p.append(disabled, hidden, target);
+ fixture.connect(p as unknown as DOMElementContract);
+ target.focus();
+ expect(document.activeElement).toBe(target);
+ disabled.focus();
+ expect(document.activeElement).toBe(target);
+ hidden.focus();
+ expect(document.activeElement).toBe(target);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/focus/inert.ts b/packages/dom/src/compatibility/focus/inert.ts
new file mode 100644
index 0000000..d534479
--- /dev/null
+++ b/packages/dom/src/compatibility/focus/inert.ts
@@ -0,0 +1,131 @@
+// Adapted from WPT inert-node-is-unfocusable.html and inert-in-shadow-dom.html at
+// b719612f963a05ece9c89a7dd7598c1c59618874. Copyright web-platform-tests contributors;
+// Alice Boxhall. BSD-3-Clause; see ../../../test/wpt/LICENSE.txt and ../../../docs/wpt-state-styles.md.
+import type { CompatibilityContext } from '../context.ts';
+import type { DOMElement } from '../contracts/nodes.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/interaction.html#inert-subtrees';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/inert/inert-node-is-unfocusable.html';
+export const requires = [
+ 'focus.eligibility',
+ 'focus.transitions',
+ 'events.dispatch.capture',
+ 'element.selectors',
+ 'element.attributes',
+ 'parsing.fragments',
+] as const;
+/** Registers inertness reflection and focus restrictions across trees. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'reflects explicit inertness without reflecting ancestor inertness on descendants',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div'),
+ child = document.createElement('button');
+ parent.append(child);
+ expect(parent.inert).toBe(false);
+ parent.inert = true;
+ expect(parent.hasAttribute('inert')).toBe(true);
+ expect(child.inert).toBe(false);
+ parent.removeAttribute('inert');
+ expect(parent.inert).toBe(false);
+ parent.setAttribute('inert', 'false');
+ expect(parent.inert).toBe(true);
+ parent.inert = false;
+ expect(parent.hasAttribute('inert')).toBe(false);
+ },
+ );
+ context.test(
+ 'blocks focus and focus events throughout an inert subtree and restores eligibility',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div');
+ root.innerHTML =
+ '';
+ fixture.connect(root as unknown as DOMElement);
+ const outside = root.querySelector('#outside')!,
+ parent = root.querySelector('section')!;
+ outside.focus();
+ let events = 0;
+ parent.addEventListener('focus', () => events++, true);
+ const children = Array.from(parent.children) as HTMLElement[];
+ for (const child of children) {
+ child.focus();
+ expect(document.activeElement).toBe(outside);
+ }
+ expect(events).toBe(0);
+ parent.inert = false;
+ for (const child of children) {
+ child.focus();
+ expect(document.activeElement).toBe(child);
+ }
+ expect(events).toBe(children.length);
+ },
+ );
+ context.test(
+ 'a nested inert attribute continues blocking focus when its ancestor is cleared',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div'),
+ child = document.createElement('button'),
+ outside = document.createElement('button');
+ parent.inert = true;
+ child.inert = true;
+ parent.append(child);
+ fixture.connect(parent as unknown as DOMElement);
+ fixture.connect(outside as unknown as DOMElement);
+ outside.focus();
+ parent.inert = false;
+ child.focus();
+ expect(document.activeElement).toBe(outside);
+ child.inert = false;
+ child.focus();
+ expect(document.activeElement).toBe(child);
+ },
+ );
+ context.test(
+ 'inertness does not make a control match disabled',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const button = document.createElement('button');
+ button.inert = true;
+ expect(button.matches(':disabled')).toBe(false);
+ expect(button.matches(':enabled')).toBe(true);
+ button.disabled = true;
+ expect(button.matches(':disabled')).toBe(true);
+ },
+ ['element.selectors'],
+ );
+ for (const mode of ['open', 'closed'] as const) {
+ context.test(
+ `inert host blocks both slotted and ${mode} shadow descendants`,
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ outside = document.createElement('button'),
+ slotted = document.createElement('button'),
+ internal = document.createElement('button');
+ const shadow = host.attachShadow({ mode });
+ shadow.append(document.createElement('slot'), internal);
+ host.append(slotted);
+ host.inert = true;
+ fixture.connect(host as unknown as DOMElement);
+ fixture.connect(outside as unknown as DOMElement);
+ outside.focus();
+ for (const target of [slotted, internal]) {
+ target.focus();
+ expect(document.activeElement).toBe(outside);
+ expect(shadow.activeElement).toBeNull();
+ }
+ host.inert = false;
+ slotted.focus();
+ expect(document.activeElement).toBe(slotted);
+ internal.focus();
+ expect(document.activeElement).toBe(host);
+ expect(shadow.activeElement).toBe(internal);
+ },
+ ['shadow.slots.named', 'shadow.focus.retargeting'],
+ );
+ }
+}
diff --git a/packages/dom/src/compatibility/focus/reentrancy.ts b/packages/dom/src/compatibility/focus/reentrancy.ts
new file mode 100644
index 0000000..94d715e
--- /dev/null
+++ b/packages/dom/src/compatibility/focus/reentrancy.ts
@@ -0,0 +1,45 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/interaction.html#focusing-steps';
+export const requires = ['focus.eligibility', 'events.dispatch.target'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'allows a focus listener to move focus to another element',
+ ({ document, connect }) => {
+ const root = document.createElement('div');
+ const first = document.createElement('button');
+ const second = document.createElement('button');
+ root.append(first, second);
+ connect(root);
+ const calls: string[] = [];
+ first.addEventListener('focus', () => {
+ calls.push('first');
+ second.focus();
+ });
+ second.addEventListener('focus', () => calls.push('second'));
+ first.focus();
+ expect(document.activeElement).toBe(second);
+ expect(calls).toEqual(['first', 'second']);
+ },
+ );
+
+ context.test('focus handlers can synchronously redirect focus to another control', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('input'),
+ b = document.createElement('input');
+ p.append(a, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ const seen: string[] = [];
+ a.addEventListener('focus', () => {
+ seen.push('a');
+ b.focus();
+ });
+ b.addEventListener('focus', () => seen.push('b'));
+ a.focus();
+ expect(document.activeElement).toBe(b);
+ expect(seen).toEqual(['a', 'b']);
+ });
+}
diff --git a/packages/dom/src/compatibility/focus/removal.ts b/packages/dom/src/compatibility/focus/removal.ts
new file mode 100644
index 0000000..da00a9e
--- /dev/null
+++ b/packages/dom/src/compatibility/focus/removal.ts
@@ -0,0 +1,37 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/infrastructure.html#dom-trees';
+export const requires = ['focus.eligibility'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'removing a focused node clears focus without firing blur',
+ ({ document, connect }) => {
+ const root = document.createElement('div');
+ const button = document.createElement('button');
+ root.append(button);
+ connect(root);
+ button.focus();
+ let blurred = 0;
+ button.addEventListener('blur', () => blurred++);
+ button.remove();
+ expect(document.activeElement).not.toBe(button);
+ expect(blurred).toBe(0);
+ root.append(button);
+ expect(document.activeElement).not.toBe(button);
+ },
+ );
+
+ context.test('removing an unrelated subtree preserves the focused element', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('input'),
+ other = document.createElement('div');
+ p.append(e, other);
+ fixture.connect(p as unknown as DOMElementContract);
+ e.focus();
+ other.remove();
+ expect(document.activeElement).toBe(e);
+ });
+}
diff --git a/packages/dom/src/compatibility/focus/transitions.ts b/packages/dom/src/compatibility/focus/transitions.ts
new file mode 100644
index 0000000..6297b3d
--- /dev/null
+++ b/packages/dom/src/compatibility/focus/transitions.ts
@@ -0,0 +1,58 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+import type { DOMEvent } from '../contracts/events.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/interaction.html#focus-update-steps';
+const requiredGlobals = ['FocusEvent'] as const;
+export { requiredGlobals as globals };
+export const requires = ['focus.eligibility', 'events.dispatch.target'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'delivers focus transitions with related targets and ignores repeated focus',
+ ({ document, connect, globals, cleanup }) => {
+ const FocusEvent = globals('FocusEvent');
+ const root = document.createElement('div');
+ const first = document.createElement('button');
+ const second = document.createElement('button');
+ root.append(first, second);
+ connect(root);
+ first.focus();
+ const calls: string[] = [];
+ for (const node of [first, second])
+ for (const type of ['blur', 'focusout', 'focus', 'focusin']) {
+ const listener = (event: DOMEvent): void => {
+ if (!(event instanceof FocusEvent)) return expect.fail('Expected FocusEvent');
+ expect(event.relatedTarget).toBe(node === first ? second : first);
+ calls.push(type);
+ };
+ node.addEventListener(type, listener);
+ cleanup(() => node.removeEventListener(type, listener));
+ }
+ second.focus();
+ expect(document.activeElement).toBe(second);
+ expect(calls).toEqual(['blur', 'focusout', 'focus', 'focusin']);
+ second.focus();
+ first.blur();
+ expect(calls).toHaveLength(4);
+ },
+ );
+
+ context.test(
+ 'repeated focus does not refire focus or blur and blur releases activeElement',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ fixture.connect(e as unknown as DOMElementContract);
+ const seen: string[] = [];
+ e.addEventListener('focus', () => seen.push('focus'));
+ e.addEventListener('blur', () => seen.push('blur'));
+ e.focus();
+ e.focus();
+ expect(seen).toEqual(['focus']);
+ e.blur();
+ expect(seen).toEqual(['focus', 'blur']);
+ expect(document.activeElement).not.toBe(e);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/globals.ts b/packages/dom/src/compatibility/globals.ts
new file mode 100644
index 0000000..4eb6225
--- /dev/null
+++ b/packages/dom/src/compatibility/globals.ts
@@ -0,0 +1,61 @@
+import type { DOMDocument, DOMElement, DOMHTMLElement } from './contracts/nodes.ts';
+import type {
+ DOMEvent,
+ DOMEventTarget,
+ DOMEventConstructor,
+ DOMEventTargetConstructor,
+ DOMCustomEventConstructor,
+ DOMKeyboardEventConstructor,
+ DOMMouseEventConstructor,
+ DOMFocusEventConstructor,
+ DOMInputEventConstructor,
+} from './contracts/events.ts';
+import type { DOMMutationObserverConstructor } from './contracts/observation.ts';
+import type { DOMTemplateElement, DOMCustomElementRegistry } from './contracts/shadow.ts';
+import type {
+ DOMFormElement,
+ DOMInputElement,
+ DOMTextAreaElement,
+ DOMSelectElement,
+} from './contracts/elements.ts';
+
+/** Implementations supply only the bindings needed by their selected features. */
+export type DOMGlobalBindings = Record;
+
+interface DOMFormData {
+ get(name: string): string | File | null;
+ getAll(name: string): (string | File)[];
+ entries(): IterableIterator<[string, string | File]>;
+}
+
+/** Idealized test contracts, not a claim about an implementation's supported APIs. */
+export interface DOMGlobals extends DOMGlobalBindings {
+ Document: new () => DOMDocument;
+ Element: abstract new (...args: never[]) => DOMElement;
+ HTMLElement: new () => DOMHTMLElement;
+ HTMLTemplateElement: abstract new (...args: never[]) => DOMTemplateElement;
+ HTMLInputElement: abstract new (...args: never[]) => DOMInputElement;
+ HTMLTextAreaElement: abstract new (...args: never[]) => DOMTextAreaElement;
+ HTMLSelectElement: abstract new (...args: never[]) => DOMSelectElement;
+ Event: DOMEventConstructor;
+ EventTarget: DOMEventTargetConstructor;
+ CustomEvent: DOMCustomEventConstructor;
+ KeyboardEvent: DOMKeyboardEventConstructor;
+ MouseEvent: DOMMouseEventConstructor;
+ FocusEvent: DOMFocusEventConstructor;
+ InputEvent: DOMInputEventConstructor;
+ ErrorEvent: abstract new (...args: never[]) => DOMEvent & { readonly error: unknown };
+ MutationObserver: DOMMutationObserverConstructor;
+ AbortController: typeof AbortController;
+ FormData: new (form?: DOMFormElement, submitter?: DOMHTMLElement | null) => DOMFormData;
+ customElements: DOMCustomElementRegistry;
+ window: DOMEventTarget;
+}
+
+/** Compatibility MissingGlobalError support. */
+export class MissingGlobalError extends Error {
+ constructor(name: string) {
+ super(`Missing globals.${name}`);
+ this.name = 'MissingGlobalError';
+ }
+}
diff --git a/packages/dom/src/compatibility/input-driver.ts b/packages/dom/src/compatibility/input-driver.ts
new file mode 100644
index 0000000..3b84f40
--- /dev/null
+++ b/packages/dom/src/compatibility/input-driver.ts
@@ -0,0 +1,86 @@
+import type { DOMElement } from './contracts/nodes.ts';
+
+export type DOMInputCapability = 'keyboard' | 'text' | 'mouse';
+export type DOMMouseButton = 0 | 1 | 2;
+
+/** Host input, delivered through the implementation's normal input pipeline. */
+export interface DOMInputDriver {
+ keyboard?: {
+ /** DOM key values, including 'Enter', 'Shift', 'Tab', and ' ' for Space. */
+ keyDown(key: string): Promise;
+ keyUp(key: string): Promise;
+ };
+ /** Insert text through the host editing path, without synthesizing key events. */
+ insertText?(text: string): Promise;
+ mouse?: {
+ /** Resolve a point using host layout, then relocate there without intermediate steps. */
+ move(element: DOMElement): Promise;
+ down(button: DOMMouseButton): Promise;
+ up(button: DOMMouseButton): Promise;
+ };
+ /** Release held input and clear session state before the fixture is disposed. */
+ reset(): Promise;
+}
+
+/** Test conveniences only sequence input; default actions belong to the host. */
+export interface DOMTestInput {
+ keyDown(key: string): Promise;
+ keyUp(key: string): Promise;
+ press(key: string): Promise;
+ insertText(text: string): Promise;
+ move(element: DOMElement): Promise;
+ down(button?: DOMMouseButton): Promise;
+ up(button?: DOMMouseButton): Promise;
+ click(element: DOMElement, button?: DOMMouseButton): Promise;
+}
+
+/** Compatibility MissingInputError support. */
+export class MissingInputError extends Error {
+ constructor(capability?: DOMInputCapability) {
+ super(capability ? `Missing input.${capability} driver capability` : 'Missing input driver');
+ this.name = 'MissingInputError';
+ }
+}
+
+/** Compatibility testInput support. */
+export function testInput(driver: DOMInputDriver): DOMTestInput {
+ function keyboard(): NonNullable {
+ if (!driver.keyboard) throw new MissingInputError('keyboard');
+ return driver.keyboard;
+ }
+ function mouse(): NonNullable {
+ if (!driver.mouse) throw new MissingInputError('mouse');
+ return driver.mouse;
+ }
+
+ return {
+ async keyDown(key) {
+ await keyboard().keyDown(key);
+ },
+ async keyUp(key) {
+ await keyboard().keyUp(key);
+ },
+ async press(key) {
+ await keyboard().keyDown(key);
+ await keyboard().keyUp(key);
+ },
+ async insertText(text) {
+ if (!driver.insertText) throw new MissingInputError('text');
+ await driver.insertText(text);
+ },
+ async move(element) {
+ await mouse().move(element);
+ },
+ async down(button = 0) {
+ await mouse().down(button);
+ },
+ async up(button = 0) {
+ await mouse().up(button);
+ },
+ async click(element, button = 0) {
+ await mouse().move(element);
+ await mouse().down(button);
+ await mouse().up(button);
+ },
+ };
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/activation.ts b/packages/dom/src/compatibility/input/keyboard/activation.ts
new file mode 100644
index 0000000..aac4e03
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/activation.ts
@@ -0,0 +1,91 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://w3c.github.io/uievents/#event-type-keydown';
+export const input = ['keyboard'] as const;
+export const requires = ['input.keyboard.delivery', 'elements.button.activation'] as const;
+
+// This capability selects the desktop Enter-on-down / Space-on-up interaction policy.
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'activates a focused button on Enter keydown exactly once per press',
+ async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ const received: string[] = [];
+ for (const type of ['keydown', 'click', 'keyup']) {
+ button.addEventListener(type, () => received.push(type));
+ }
+ button.focus();
+ await fixture.input.keyDown('Enter');
+ expect(received).toEqual(['keydown', 'click']);
+ await fixture.input.keyUp('Enter');
+ expect(received).toEqual(['keydown', 'click', 'keyup']);
+ },
+ );
+
+ context.test('activates a focused button on Space release', async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ const received: string[] = [];
+ for (const type of ['keydown', 'click', 'keyup']) {
+ button.addEventListener(type, () => received.push(type));
+ }
+ button.focus();
+ await fixture.input.keyDown(' ');
+ expect(received).toEqual(['keydown']);
+ await fixture.input.keyUp(' ');
+ expect(received).toEqual(['keydown', 'keyup', 'click']);
+ });
+
+ for (const key of ['Enter', ' ']) {
+ context.test(
+ `canceling ${key === ' ' ? 'Space' : key} keydown suppresses button activation`,
+ async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ let clicks = 0;
+ button.addEventListener('keydown', (event) => event.preventDefault());
+ button.addEventListener('click', () => clicks++);
+ button.focus();
+ await fixture.input.press(key);
+ expect(clicks).toBe(0);
+ },
+ );
+ }
+
+ context.test(
+ 'canceling Space keyup suppresses the pending button activation',
+ async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ let clicks = 0;
+ button.addEventListener('keyup', (event) => event.preventDefault());
+ button.addEventListener('click', () => clicks++);
+ button.focus();
+ await fixture.input.press(' ');
+ expect(clicks).toBe(0);
+ },
+ );
+
+ context.test(
+ 'Space toggles a checkbox and canceled click restores its checkedness',
+ async (fixture) => {
+ const checkbox = fixture.connect(fixture.document.createElement('input'));
+ checkbox.type = 'checkbox';
+ const changes: string[] = [];
+ checkbox.addEventListener('input', () => changes.push('input'));
+ checkbox.addEventListener('change', () => changes.push('change'));
+ checkbox.focus();
+ await fixture.input.press(' ');
+ expect(checkbox.checked).toBe(true);
+ expect(changes).toEqual(['input', 'change']);
+ checkbox.addEventListener('click', (event) => event.preventDefault());
+ await fixture.input.press(' ');
+ expect(checkbox.checked).toBe(true);
+ expect(changes).toEqual(['input', 'change']);
+ },
+ ['elements.input.activation'],
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/delivery.ts b/packages/dom/src/compatibility/input/keyboard/delivery.ts
new file mode 100644
index 0000000..5f8549d
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/delivery.ts
@@ -0,0 +1,112 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMKeyboardEvent } from '../../contracts/events.ts';
+
+export const spec = 'https://w3c.github.io/uievents/#events-keyboard-event-order';
+export const input = ['keyboard'] as const;
+export const requires = [
+ 'focus.transitions',
+ 'events.dispatch.bubbling',
+ 'events.dispatch.cancellation',
+ 'events.data.keyboard',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'delivers keydown and keyup to the focused element and bubbles both',
+ async (fixture) => {
+ const { document } = fixture;
+ const parent = fixture.connect(document.createElement('div'));
+ const target = document.createElement('input');
+ parent.appendChild(target);
+ const received: unknown[][] = [];
+ for (const type of ['keydown', 'keyup']) {
+ target.addEventListener(type, (event) => {
+ const key = event as DOMKeyboardEvent;
+ received.push([
+ type,
+ 'target',
+ key.key,
+ key.target,
+ key.bubbles,
+ key.cancelable,
+ key.composed,
+ ]);
+ });
+ parent.addEventListener(type, (event) => received.push([type, 'parent', event.target]));
+ }
+ target.focus();
+ await fixture.input.press('ArrowLeft');
+ expect(received).toEqual([
+ ['keydown', 'target', 'ArrowLeft', target, true, true, true],
+ ['keydown', 'parent', target],
+ ['keyup', 'target', 'ArrowLeft', target, true, true, true],
+ ['keyup', 'parent', target],
+ ]);
+ },
+ );
+
+ context.test('targets keyup at the element focused after keydown', async (fixture) => {
+ const { document } = fixture;
+ const root = fixture.connect(document.createElement('div'));
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ root.append(first, second);
+ const received: unknown[][] = [];
+ first.addEventListener('keydown', (event) => received.push([event.type, event.target]));
+ first.addEventListener('keyup', (event) => received.push([event.type, event.target]));
+ second.addEventListener('keyup', (event) => received.push([event.type, event.target]));
+ first.focus();
+ await fixture.input.keyDown('ArrowRight');
+ second.focus();
+ await fixture.input.keyUp('ArrowRight');
+ expect(received).toEqual([
+ ['keydown', first],
+ ['keyup', second],
+ ]);
+ });
+
+ context.test('still delivers keyup after a canceled keydown', async (fixture) => {
+ const { document } = fixture;
+ const target = fixture.connect(document.createElement('input'));
+ const received: unknown[][] = [];
+ target.addEventListener('keydown', (event) => {
+ event.preventDefault();
+ received.push([event.type, event.defaultPrevented]);
+ });
+ target.addEventListener('keyup', (event) =>
+ received.push([event.type, event.defaultPrevented]),
+ );
+ target.focus();
+ await fixture.input.press('ArrowLeft');
+ expect(received).toEqual([
+ ['keydown', true],
+ ['keyup', false],
+ ]);
+ });
+
+ context.test(
+ 'keyup reaches the newly focused target after an awaited keydown',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('input'),
+ b = document.createElement('input');
+ p.append(a, b);
+ fixture.connect(p as unknown as DOMElementContract);
+ const seen: unknown[] = [];
+ a.addEventListener('keydown', (e) => seen.push(['down', e.target]));
+ b.addEventListener('keyup', (e) => seen.push(['up', e.target]));
+ a.focus();
+ await fixture.input.keyDown('Shift');
+ b.focus();
+ await fixture.input.keyUp('Shift');
+ expect(seen).toEqual([
+ ['down', a],
+ ['up', b],
+ ]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/editing.ts b/packages/dom/src/compatibility/input/keyboard/editing.ts
new file mode 100644
index 0000000..7f2972a
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/editing.ts
@@ -0,0 +1,217 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMInputEvent } from '../../contracts/events.ts';
+
+export const spec = 'https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/input-events/input-events-typing.html';
+export const input = ['keyboard', 'text'] as const;
+export const requires = [
+ 'input.keyboard.delivery',
+ 'events.data.input',
+ 'elements.input.value',
+ 'elements.input.selection',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'text input replaces the selection between beforeinput and input',
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ target.value = 'abcd';
+ target.focus();
+ target.setSelectionRange(1, 3);
+ const received: unknown[][] = [];
+ for (const type of ['beforeinput', 'input']) {
+ target.addEventListener(type, (event) => {
+ const edit = event as DOMInputEvent;
+ received.push([
+ edit.type,
+ edit.inputType,
+ edit.data,
+ edit.cancelable,
+ edit.bubbles,
+ target.value,
+ target.selectionStart,
+ target.selectionEnd,
+ ]);
+ });
+ }
+ await fixture.input.insertText('X');
+ expect(received).toEqual([
+ ['beforeinput', 'insertText', 'X', true, true, 'abcd', 1, 3],
+ ['input', 'insertText', 'X', false, true, 'aXd', 2, 2],
+ ]);
+ expect(target.value).toBe('aXd');
+ },
+ );
+
+ context.test(
+ 'canceling beforeinput preserves the value and selection and suppresses input',
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ target.value = 'abcd';
+ target.focus();
+ target.setSelectionRange(1, 3);
+ const received: string[] = [];
+ target.addEventListener('beforeinput', (event) => {
+ received.push(event.type);
+ event.preventDefault();
+ });
+ target.addEventListener('input', (event) => received.push(event.type));
+ await fixture.input.insertText('X');
+ expect(received).toEqual(['beforeinput']);
+ expect([target.value, target.selectionStart, target.selectionEnd]).toEqual(['abcd', 1, 3]);
+ },
+ );
+
+ for (const [key, inputType, value, caret] of [
+ ['Backspace', 'deleteContentBackward', 'acd', 1],
+ ['Delete', 'deleteContentForward', 'abd', 2],
+ ] as const) {
+ context.test(
+ `${key} deletes beside the caret with the appropriate input type`,
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ target.value = 'abcd';
+ target.focus();
+ target.setSelectionRange(2, 2);
+ const received: unknown[][] = [];
+ for (const type of ['beforeinput', 'input']) {
+ target.addEventListener(type, (event) => {
+ const edit = event as DOMInputEvent;
+ received.push([edit.type, edit.inputType, edit.data]);
+ });
+ }
+ await fixture.input.press(key);
+ expect(received).toEqual([
+ ['beforeinput', inputType, null],
+ ['input', inputType, null],
+ ]);
+ expect([target.value, target.selectionStart, target.selectionEnd]).toEqual([
+ value,
+ caret,
+ caret,
+ ]);
+ },
+ );
+ }
+
+ context.test(
+ 'Backspace deletes a selected range instead of the preceding character',
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ target.value = 'abcd';
+ target.focus();
+ target.setSelectionRange(1, 3);
+ await fixture.input.press('Backspace');
+ expect([target.value, target.selectionStart, target.selectionEnd]).toEqual(['ad', 1, 1]);
+ },
+ );
+
+ context.test(
+ 'canceling a deletion keydown prevents editing events and mutation',
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ target.value = 'abcd';
+ target.focus();
+ target.setSelectionRange(2, 2);
+ const received: string[] = [];
+ target.addEventListener('keydown', (event) => event.preventDefault());
+ for (const type of ['beforeinput', 'input']) {
+ target.addEventListener(type, () => received.push(type));
+ }
+ await fixture.input.press('Backspace');
+ expect(received).toEqual([]);
+ expect([target.value, target.selectionStart, target.selectionEnd]).toEqual(['abcd', 2, 2]);
+ },
+ );
+
+ context.test(
+ 'read-only controls accept focus without allowing text insertion or deletion',
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ target.value = 'abcd';
+ target.readOnly = true;
+ target.focus();
+ target.setSelectionRange(1, 3);
+ let changes = 0;
+ target.addEventListener('input', () => changes++);
+ await fixture.input.insertText('X');
+ await fixture.input.press('Backspace');
+ expect(fixture.document.activeElement).toBe(target);
+ expect(target.value).toBe('abcd');
+ expect(changes).toBe(0);
+ },
+ );
+
+ context.test(
+ 'Delete removes a noncollapsed selection and leaves a collapsed caret',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.value = 'abcdef';
+ e.focus();
+ e.setSelectionRange(2, 4, 'backward');
+ await fixture.input.press('Delete');
+ expect(e.value).toBe('abef');
+ expect(e.selectionStart).toBe(2);
+ expect(e.selectionEnd).toBe(2);
+ },
+ );
+
+ context.test(
+ 'Backspace at the start and Delete at the end leave text unchanged',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.value = 'abc';
+ e.focus();
+ e.setSelectionRange(0, 0);
+ await fixture.input.press('Backspace');
+ expect(e.value).toBe('abc');
+ expect(e.selectionStart).toBe(0);
+ e.setSelectionRange(3, 3);
+ await fixture.input.press('Delete');
+ expect(e.value).toBe('abc');
+ expect(e.selectionStart).toBe(3);
+ },
+ );
+
+ context.test('keydown cancellation blocks printable key default editing', async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.value = 'abc';
+ e.focus();
+ e.setSelectionRange(1, 1);
+ let edits = 0;
+ e.addEventListener('keydown', (event) => event.preventDefault());
+ e.addEventListener('input', () => edits++);
+ await fixture.input.press('x');
+ expect(e.value).toBe('abc');
+ expect(e.selectionStart).toBe(1);
+ expect(edits).toBe(0);
+ });
+
+ context.test(
+ 'successive host text insertions preserve Unicode and replace selection',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.focus();
+ await fixture.input.insertText('A😀B');
+ expect(e.value).toBe('A😀B');
+ expect(e.selectionStart).toBe(4);
+ e.setSelectionRange(1, 3);
+ await fixture.input.insertText('é');
+ expect(e.value).toBe('AéB');
+ expect(e.selectionStart).toBe(2);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/focus-styles.ts b/packages/dom/src/compatibility/input/keyboard/focus-styles.ts
new file mode 100644
index 0000000..dbc45bf
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/focus-styles.ts
@@ -0,0 +1,70 @@
+// WPT focus-within-011.html and user-action-pseudo-classes-in-has.html adaptations.
+// Revision b719612f963a05ece9c89a7dd7598c1c59618874; copyright WPT contributors,
+// Ethan Lin, Mozilla, Byungwoo Lee. BSD-3-Clause; ../../../../test/wpt/LICENSE.txt.
+import type { CompatibilityContext } from '../../context.ts';
+import { createStyleFixture } from '../../style-fixture.ts';
+export const spec = 'https://drafts.csswg.org/selectors-4/#focus-within-pseudo';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/invalidation/user-action-pseudo-classes-in-has.html';
+export const input = ['keyboard'] as const;
+export const requires = ['input.keyboard.navigation', 'style.focusState'] as const;
+export const globals = ['getComputedStyle'] as const;
+/** Registers native navigation and CSS focus state interactions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'Tab and Shift Tab move focus selectors and styles between subtrees',
+ async (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup:
+ '',
+ css: 'section {color:rgb(1,2,3)} section:focus-within {color:rgb(4,5,6)} button:focus {color:rgb(7,8,9)}',
+ });
+ const [first, second] = Array.from(root.querySelectorAll('button'));
+ const [parent, other] = Array.from(root.querySelectorAll('section'));
+ first!.focus();
+ await fixture.input.press('Tab');
+ expect(first!.matches(':focus')).toBe(false);
+ expect(second!.matches(':focus')).toBe(true);
+ expect(get(parent!).color).toBe('rgb(1, 2, 3)');
+ expect(get(other!).color).toBe('rgb(4, 5, 6)');
+ expect(get(second!).color).toBe('rgb(7, 8, 9)');
+ await fixture.input.keyDown('Shift');
+ await fixture.input.press('Tab');
+ await fixture.input.keyUp('Shift');
+ expect(first!.matches(':focus')).toBe(true);
+ expect(get(parent!).color).toBe('rgb(4, 5, 6)');
+ expect(get(other!).color).toBe('rgb(1, 2, 3)');
+ },
+ );
+ context.test('canceling Tab retains focus selectors and styles', async (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: 'button {color:rgb(1,2,3)} button:focus {color:rgb(4,5,6)}',
+ });
+ const [first, second] = Array.from(root.querySelectorAll('button'));
+ first!.focus();
+ first!.addEventListener('keydown', (event) => event.preventDefault());
+ await fixture.input.press('Tab');
+ expect(first!.matches(':focus')).toBe(true);
+ expect(second!.matches(':focus')).toBe(false);
+ expect(get(first!).color).toBe('rgb(4, 5, 6)');
+ expect(get(second!).color).toBe('rgb(1, 2, 3)');
+ });
+ context.test(
+ 'Tab skips inert descendants and never gives their ancestors focus-within',
+ async (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup:
+ '',
+ css: 'section {color:rgb(1,2,3)} section:focus-within {color:rgb(4,5,6)}',
+ });
+ root.querySelector('#first')!.focus();
+ await fixture.input.press('Tab');
+ expect(root.querySelector('#last')!.matches(':focus')).toBe(true);
+ expect(root.querySelector('section')!.matches(':focus-within')).toBe(false);
+ expect(get(root.querySelector('section')!).color).toBe('rgb(1, 2, 3)');
+ },
+ ['focus.inert'],
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/focus-visible.ts b/packages/dom/src/compatibility/input/keyboard/focus-visible.ts
new file mode 100644
index 0000000..19711fa
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/focus-visible.ts
@@ -0,0 +1,40 @@
+// Adapted from WPT focus-visible-001.html at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright web-platform-tests contributors; Rob Dodson. BSD-3-Clause.
+// See ../../../../test/wpt/LICENSE.txt and ../../../../docs/wpt-state-styles.md.
+import type { CompatibilityContext } from '../../context.ts';
+import { createStyleFixture } from '../../style-fixture.ts';
+export const spec = 'https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/focus-visible-001.html';
+export const input = ['keyboard'] as const;
+export const requires = ['input.keyboard.navigation', 'style.focusState'] as const;
+export const globals = ['getComputedStyle'] as const;
+/** Registers the explicit keyboard-focus-visible policy independently of event trust. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const markup of [
+ '',
+ 'Target',
+ '',
+ ]) {
+ context.test(
+ `Tab makes ${markup} focus-visible and clears matching after blur`,
+ async (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, {
+ markup: `${markup}`,
+ css: ':focus-visible {color:rgb(4,5,6)}',
+ });
+ const first = root.firstElementChild as HTMLElement,
+ target = root.lastElementChild as HTMLElement;
+ first.focus();
+ await fixture.input.press('Tab');
+ expect(document.activeElement).toBe(target);
+ expect(target.matches(':focus-visible')).toBe(true);
+ expect(Array.from(root.querySelectorAll(':focus-visible'))).toEqual([target]);
+ expect(get(target).color).toBe('rgb(4, 5, 6)');
+ target.blur();
+ expect(target.matches(':focus-visible')).toBe(false);
+ },
+ );
+ }
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/implicit-submission.ts b/packages/dom/src/compatibility/input/keyboard/implicit-submission.ts
new file mode 100644
index 0000000..75f8140
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/implicit-submission.ts
@@ -0,0 +1,130 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMSubmitEvent } from '../../contracts/events.ts';
+import type { DOMElement } from '../../contracts/nodes.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/master/html/semantics/forms/form-submission-0/implicit-submission.optional.html';
+export const input = ['keyboard'] as const;
+export const requires = [
+ 'input.keyboard.delivery',
+ 'elements.input.formAssociation',
+ 'elements.button.activation',
+ 'elements.form.submission',
+] as const;
+
+// Enter as the implicit-submission trigger is an explicitly selected interaction policy.
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('Enter in a text control activates the default submit button', async (fixture) => {
+ const form = fixture.connect(fixture.document.createElement('form'));
+ const text = fixture.document.createElement('input');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = 'submit';
+ second.type = 'submit';
+ form.append(text, first, second);
+ const clicks: DOMElement[] = [];
+ first.addEventListener('click', () => clicks.push(first));
+ second.addEventListener('click', () => clicks.push(second));
+ const submissions: DOMSubmitEvent[] = [];
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submissions.push(event as DOMSubmitEvent);
+ });
+ text.focus();
+ await fixture.input.press('Enter');
+ expect(clicks).toEqual([first]);
+ expect(submissions).toHaveLength(1);
+ const submitted = submissions[0];
+ if (!submitted) return expect.fail('Expected a submit event');
+ expect(submitted.submitter).toBe(first);
+ expect(submitted.target).toBe(form);
+ expect([submitted.bubbles, submitted.cancelable]).toEqual([true, true]);
+ });
+
+ context.test('canceling Enter keydown suppresses implicit submission', async (fixture) => {
+ const form = fixture.connect(fixture.document.createElement('form'));
+ const text = fixture.document.createElement('input');
+ const button = fixture.document.createElement('button');
+ button.type = 'submit';
+ form.append(text, button);
+ const received: string[] = [];
+ text.addEventListener('keydown', (event) => event.preventDefault());
+ button.addEventListener('click', () => received.push('click'));
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ received.push('submit');
+ });
+ text.focus();
+ await fixture.input.press('Enter');
+ expect(received).toEqual([]);
+ });
+
+ context.test(
+ 'canceling the default button click suppresses implicit submission',
+ async (fixture) => {
+ const form = fixture.connect(fixture.document.createElement('form'));
+ const text = fixture.document.createElement('input');
+ const button = fixture.document.createElement('button');
+ button.type = 'submit';
+ form.append(text, button);
+ const received: string[] = [];
+ button.addEventListener('click', (event) => {
+ event.preventDefault();
+ received.push('click');
+ });
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ received.push('submit');
+ });
+ text.focus();
+ await fixture.input.press('Enter');
+ expect(received).toEqual(['click']);
+ },
+ );
+
+ context.test(
+ 'a disabled default submit button blocks submission rather than selecting the next button',
+ async (fixture) => {
+ const form = fixture.connect(fixture.document.createElement('form'));
+ const text = fixture.document.createElement('input');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = 'submit';
+ second.type = 'submit';
+ first.disabled = true;
+ form.append(text, first, second);
+ const received: string[] = [];
+ first.addEventListener('click', () => received.push('first'));
+ second.addEventListener('click', () => received.push('second'));
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ received.push('submit');
+ });
+ text.focus();
+ await fixture.input.press('Enter');
+ expect(received).toEqual([]);
+ },
+ );
+
+ context.test(
+ 'a single text control submits without a submit button and reports a null submitter',
+ async (fixture) => {
+ const form = fixture.connect(fixture.document.createElement('form'));
+ const text = fixture.document.createElement('input');
+ form.appendChild(text);
+ const submissions: DOMSubmitEvent[] = [];
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submissions.push(event as DOMSubmitEvent);
+ });
+ text.focus();
+ await fixture.input.press('Enter');
+ expect(submissions).toHaveLength(1);
+ expect(submissions[0]?.submitter).toBe(null);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/modifiers.ts b/packages/dom/src/compatibility/input/keyboard/modifiers.ts
new file mode 100644
index 0000000..a7acb48
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/modifiers.ts
@@ -0,0 +1,80 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMKeyboardEvent } from '../../contracts/events.ts';
+
+export const spec = 'https://w3c.github.io/uievents/#keys-modifiers';
+export const input = ['keyboard'] as const;
+export const requires = ['input.keyboard.delivery'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('holds Shift across another key press and clears it on release', async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ const received: unknown[][] = [];
+ for (const type of ['keydown', 'keyup']) {
+ target.addEventListener(type, (event) => {
+ const key = event as DOMKeyboardEvent;
+ received.push([key.type, key.key, key.shiftKey, key.getModifierState('Shift')]);
+ });
+ }
+ target.focus();
+ await fixture.input.keyDown('Shift');
+ await fixture.input.press('ArrowRight');
+ await fixture.input.keyUp('Shift');
+ await fixture.input.press('ArrowRight');
+ expect(received).toEqual([
+ ['keydown', 'Shift', true, true],
+ ['keydown', 'ArrowRight', true, true],
+ ['keyup', 'ArrowRight', true, true],
+ ['keyup', 'Shift', false, false],
+ ['keydown', 'ArrowRight', false, false],
+ ['keyup', 'ArrowRight', false, false],
+ ]);
+ });
+
+ context.test('preserves held modifiers when focus moves between controls', async (fixture) => {
+ const root = fixture.connect(fixture.document.createElement('div'));
+ const first = fixture.document.createElement('input');
+ const second = fixture.document.createElement('input');
+ root.append(first, second);
+ const received: boolean[][] = [];
+ second.addEventListener('keydown', (event) => {
+ const key = event as DOMKeyboardEvent;
+ received.push([key.shiftKey, key.ctrlKey, key.altKey, key.metaKey]);
+ });
+ first.focus();
+ await fixture.input.keyDown('Shift');
+ second.focus();
+ await fixture.input.press('ArrowLeft');
+ await fixture.input.keyUp('Shift');
+ expect(received).toEqual([[true, false, false, false]]);
+ });
+
+ context.test(
+ 'Control remains held across independent key presses until release',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.focus();
+ const seen: unknown[] = [];
+ e.addEventListener('keydown', (event) => {
+ if (event.key === 'x' || event.key === 'y') {
+ event.preventDefault();
+ seen.push([event.key, event.ctrlKey, event.shiftKey]);
+ }
+ });
+ await fixture.input.keyDown('Control');
+ await fixture.input.press('x');
+ await fixture.input.press('y');
+ await fixture.input.keyUp('Control');
+ await fixture.input.press('x');
+ expect(seen).toEqual([
+ ['x', true, false],
+ ['y', true, false],
+ ['x', false, false],
+ ]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/multiline-editing.ts b/packages/dom/src/compatibility/input/keyboard/multiline-editing.ts
new file mode 100644
index 0000000..cebabab
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/multiline-editing.ts
@@ -0,0 +1,173 @@
+// Adapted from WPT input-events-typing.html at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause; see ../../../../test/wpt/LICENSE.txt.
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec = 'https://www.w3.org/TR/input-events-2/#input-event-types';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/input-events/input-events-typing.html';
+export const input = ['keyboard', 'text'] as const;
+export const requires = [
+ 'input.keyboard.delivery',
+ 'input.keyboard.modifiers',
+ 'events.data.input',
+ 'elements.textarea.value',
+ 'elements.textarea.selection',
+ 'selection.document.direction',
+] as const;
+// Selects the policy Enter = paragraph in contenteditable, Enter = line break in
+// textarea, and Shift+Enter = line break in contenteditable. No platform detection.
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const kind of ['textarea', 'contenteditable'] as const) {
+ context.test(`orders typing events around the ${kind} edit`, async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const target = document.createElement(kind === 'textarea' ? 'textarea' : 'div');
+ if (kind === 'contenteditable') target.contentEditable = 'true';
+ fixture.connect(target as unknown as DOMElementContract);
+ const value = (): string =>
+ kind === 'textarea' ? (target as HTMLTextAreaElement).value : (target.textContent ?? '');
+ const calls: unknown[] = [];
+ for (const type of ['keydown', 'beforeinput', 'input', 'keyup'])
+ target.addEventListener(type, (event) => {
+ const edit = event as InputEvent;
+ calls.push([
+ type,
+ event.target,
+ value(),
+ type === 'beforeinput' || type === 'input'
+ ? [edit.inputType, edit.data, edit.isComposing, event.cancelable]
+ : (event as KeyboardEvent).key,
+ ]);
+ });
+ target.focus();
+ await fixture.input.press('a');
+ expect(value()).toBe('a');
+ expect(calls).toEqual([
+ ['keydown', target, '', 'a'],
+ ['beforeinput', target, '', ['insertText', 'a', false, true]],
+ ['input', target, 'a', ['insertText', 'a', false, false]],
+ ['keyup', target, 'a', 'a'],
+ ]);
+ });
+
+ context.test(
+ `canceling beforeinput preserves ${kind} text and selected range`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const target = document.createElement(kind === 'textarea' ? 'textarea' : 'div');
+ const textarea = target as HTMLTextAreaElement;
+ if (kind === 'textarea') textarea.value = 'abcd';
+ else {
+ target.contentEditable = 'true';
+ target.textContent = 'abcd';
+ }
+ fixture.connect(target as unknown as DOMElementContract);
+ target.focus();
+ const text = target.firstChild;
+ const selection = document.getSelection();
+ if (kind === 'textarea') textarea.setSelectionRange(1, 3);
+ else {
+ if (!selection || !text)
+ return expect.fail('Connected editable must have a selection and text node');
+ fixture.cleanup(() => selection.removeAllRanges());
+ selection.setBaseAndExtent(text, 1, text, 3);
+ }
+ const calls: string[] = [];
+ target.addEventListener('beforeinput', (event) => {
+ calls.push('beforeinput');
+ event.preventDefault();
+ });
+ target.addEventListener('input', () => calls.push('input'));
+ await fixture.input.insertText('X');
+ expect(calls).toEqual(['beforeinput']);
+ if (kind === 'textarea') {
+ expect(textarea.value).toBe('abcd');
+ expect([textarea.selectionStart, textarea.selectionEnd]).toEqual([1, 3]);
+ } else {
+ expect(target.textContent).toBe('abcd');
+ expect(target.firstChild).toBe(text);
+ expect(selection?.anchorNode).toBe(text);
+ expect(selection?.focusNode).toBe(text);
+ expect([selection?.anchorOffset, selection?.focusOffset]).toEqual([1, 3]);
+ }
+ },
+ );
+ }
+
+ context.test('Enter replaces a textarea selection with a line break', async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const target = document.createElement('textarea');
+ fixture.connect(target as unknown as DOMElementContract);
+ target.value = 'abcd';
+ target.focus();
+ target.setSelectionRange(1, 3);
+ const calls: unknown[] = [];
+ for (const type of ['beforeinput', 'input'])
+ target.addEventListener(type, (event) => {
+ const edit = event as InputEvent;
+ calls.push([type, edit.inputType, target.value]);
+ });
+ await fixture.input.press('Enter');
+ expect(calls).toEqual([
+ ['beforeinput', 'insertLineBreak', 'abcd'],
+ ['input', 'insertLineBreak', 'a\nd'],
+ ]);
+ expect([target.selectionStart, target.selectionEnd]).toEqual([2, 2]);
+ });
+
+ for (const shift of [false, true]) {
+ context.test(
+ `${shift ? 'Shift+Enter' : 'Enter'} reports ${shift ? 'line-break' : 'paragraph'} insertion in contenteditable`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const target = document.createElement('div');
+ target.contentEditable = 'true';
+ target.textContent = 'abcd';
+ fixture.connect(target as unknown as DOMElementContract);
+ target.focus();
+ const selection = document.getSelection();
+ if (!selection || !target.firstChild)
+ return expect.fail('Connected editable must have a selection and text node');
+ fixture.cleanup(() => selection.removeAllRanges());
+ selection.collapse(target.firstChild, 2);
+ const originalMarkup = target.innerHTML;
+ const calls: unknown[] = [];
+ for (const type of ['beforeinput', 'input'])
+ target.addEventListener(type, (event) => {
+ const edit = event as InputEvent;
+ calls.push([type, event.target, edit.inputType, edit.data, event.cancelable]);
+ });
+ if (shift) await fixture.input.keyDown('Shift');
+ await fixture.input.press('Enter');
+ if (shift) await fixture.input.keyUp('Shift');
+ const inputType = shift ? 'insertLineBreak' : 'insertParagraph';
+ expect(calls).toEqual([
+ ['beforeinput', target, inputType, null, true],
+ ['input', target, inputType, null, false],
+ ]);
+ expect(target.innerHTML).not.toBe(originalMarkup);
+ expect(target.textContent).toBe('abcd');
+ expect(selection.isCollapsed).toBe(true);
+ expect(target.contains(selection.anchorNode)).toBe(true);
+ },
+ );
+ }
+
+ context.test(
+ 'text insertion replaces a multiline textarea selection with Unicode text',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const target = document.createElement('textarea');
+ fixture.connect(target as unknown as DOMElementContract);
+ target.value = 'one\ntwo\nthree';
+ target.focus();
+ target.setSelectionRange(2, 9);
+ await fixture.input.insertText('😀é');
+ expect(target.value).toBe('on😀éhree');
+ expect([target.selectionStart, target.selectionEnd]).toEqual([5, 5]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/navigation.ts b/packages/dom/src/compatibility/input/keyboard/navigation.ts
new file mode 100644
index 0000000..189ac54
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/navigation.ts
@@ -0,0 +1,85 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMKeyboardEvent } from '../../contracts/events.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interaction.html#sequential-focus-navigation';
+export const input = ['keyboard'] as const;
+export const requires = [
+ 'input.keyboard.delivery',
+ 'input.keyboard.modifiers',
+ 'focus.eligibility',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'Tab advances through controls and delivers keyup at the new focus',
+ async (fixture) => {
+ const root = fixture.connect(fixture.document.createElement('div'));
+ const first = fixture.document.createElement('input');
+ const second = fixture.document.createElement('input');
+ const third = fixture.document.createElement('input');
+ root.append(first, second, third);
+ const received: unknown[][] = [];
+ first.addEventListener('keydown', (event) => received.push([event.type, event.target]));
+ second.addEventListener('keyup', (event) => received.push([event.type, event.target]));
+ first.focus();
+ await fixture.input.press('Tab');
+ expect(fixture.document.activeElement).toBe(second);
+ expect(received).toEqual([
+ ['keydown', first],
+ ['keyup', second],
+ ]);
+ await fixture.input.press('Tab');
+ expect(fixture.document.activeElement).toBe(third);
+ },
+ );
+
+ context.test('Shift+Tab traverses controls in reverse order', async (fixture) => {
+ const root = fixture.connect(fixture.document.createElement('div'));
+ const first = fixture.document.createElement('input');
+ const second = fixture.document.createElement('input');
+ const third = fixture.document.createElement('input');
+ root.append(first, second, third);
+ third.focus();
+ await fixture.input.keyDown('Shift');
+ await fixture.input.press('Tab');
+ expect(fixture.document.activeElement).toBe(second);
+ await fixture.input.press('Tab');
+ expect(fixture.document.activeElement).toBe(first);
+ await fixture.input.keyUp('Shift');
+ });
+
+ context.test('canceled Tab leaves focus on the current control', async (fixture) => {
+ const root = fixture.connect(fixture.document.createElement('div'));
+ const first = fixture.document.createElement('input');
+ const second = fixture.document.createElement('input');
+ root.append(first, second);
+ first.addEventListener('keydown', (event) => {
+ if ((event as DOMKeyboardEvent).key === 'Tab') event.preventDefault();
+ });
+ first.focus();
+ await fixture.input.press('Tab');
+ expect(fixture.document.activeElement).toBe(first);
+ });
+
+ context.test(
+ 'skips disabled controls and negative tabindex during sequential navigation',
+ async (fixture) => {
+ const root = fixture.connect(fixture.document.createElement('div'));
+ const first = fixture.document.createElement('input');
+ const disabled = fixture.document.createElement('input');
+ disabled.disabled = true;
+ const excluded = fixture.document.createElement('input');
+ excluded.tabIndex = -1;
+ const last = fixture.document.createElement('input');
+ root.append(first, disabled, excluded, last);
+ first.focus();
+ await fixture.input.press('Tab');
+ expect(fixture.document.activeElement).toBe(last);
+ excluded.focus();
+ expect(fixture.document.activeElement).toBe(excluded);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/number-stepping.ts b/packages/dom/src/compatibility/input/keyboard/number-stepping.ts
new file mode 100644
index 0000000..53937fe
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/number-stepping.ts
@@ -0,0 +1,79 @@
+// Adapted from WPT input-events-arrow-key-on-number-input{,-prevent-default}.html
+// at b719612f963a05ece9c89a7dd7598c1c59618874. Copyright © web-platform-tests contributors.
+// BSD-3-Clause; see ../../../../test/wpt/LICENSE.txt.
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/input.html#number-state-(type=number)';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/input-events/input-events-arrow-key-on-number-input-prevent-default.html';
+export const input = ['keyboard'] as const;
+export const requires = [
+ 'input.keyboard.delivery',
+ 'events.data.input',
+ 'elements.input.numericValue',
+ 'elements.input.stepping',
+] as const;
+// Selects ArrowUp/ArrowDown numeric stepping, including immediate change events.
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const [key, expected] of [
+ ['ArrowUp', '6'],
+ ['ArrowDown', '2'],
+ ] as const) {
+ context.test(
+ `${key} applies the numeric step between beforeinput and input/change`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const target = document.createElement('input');
+ target.type = 'number';
+ target.min = '0';
+ target.max = '10';
+ target.step = '2';
+ target.value = '4';
+ fixture.connect(target as unknown as DOMElementContract);
+ target.focus();
+ const calls: unknown[] = [];
+ for (const type of ['keydown', 'beforeinput', 'input', 'change', 'keyup'])
+ target.addEventListener(type, (event) => {
+ calls.push([type, event.target, target.value]);
+ });
+ await fixture.input.press(key);
+ expect(target.value).toBe(expected);
+ expect(calls).toEqual([
+ ['keydown', target, '4'],
+ ['beforeinput', target, '4'],
+ ['input', target, expected],
+ ['change', target, expected],
+ ['keyup', target, expected],
+ ]);
+ },
+ );
+ }
+ for (const canceled of ['keydown', 'beforeinput']) {
+ context.test(
+ `canceling ${canceled} prevents numeric arrow stepping and input/change`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const target = document.createElement('input');
+ target.type = 'number';
+ target.value = '4';
+ fixture.connect(target as unknown as DOMElementContract);
+ target.focus();
+ const calls: string[] = [];
+ for (const type of ['keydown', 'beforeinput', 'input', 'change', 'keyup'])
+ target.addEventListener(type, (event) => {
+ calls.push(type);
+ if (type === canceled) event.preventDefault();
+ });
+ await fixture.input.press('ArrowUp');
+ expect(target.value).toBe('4');
+ expect(calls).toEqual(
+ canceled === 'keydown' ? ['keydown', 'keyup'] : ['keydown', 'beforeinput', 'keyup'],
+ );
+ },
+ );
+ }
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/shadow.ts b/packages/dom/src/compatibility/input/keyboard/shadow.ts
new file mode 100644
index 0000000..4154fea
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/shadow.ts
@@ -0,0 +1,169 @@
+// Adapted from WPT event-composed-path.html and focus-navigation-slot-fallback-default-tabindex.html
+// at b719612f963a05ece9c89a7dd7598c1c59618874. Copyright © web-platform-tests contributors.
+// Event-path author: Hayato Ito. BSD-3-Clause; see ../../../../test/wpt/LICENSE.txt.
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interaction.html#sequential-focus-navigation';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/focus-navigation/focus-navigation-slot-fallback-default-tabindex.html';
+export const input = ['keyboard'] as const;
+export const requires = [
+ 'input.keyboard.delivery',
+ 'shadow.focus.retargeting',
+ 'shadow.events.composedPath',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const mode of ['open', 'closed'] as const) {
+ context.test(
+ `retargets native keys and filters paths outside a ${mode} shadow root`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode });
+ const target = document.createElement('input');
+ root.append(target);
+ fixture.connect(host as unknown as DOMElementContract);
+ target.focus();
+ expect(document.activeElement).toBe(host);
+ expect(root.activeElement).toBe(target);
+ const calls: unknown[] = [];
+ for (const type of ['keydown', 'keyup']) {
+ target.addEventListener(type, (event) =>
+ calls.push([
+ type,
+ 'inside',
+ event.target,
+ event.composed,
+ event.composedPath().slice(0, 3),
+ ]),
+ );
+ host.addEventListener(type, (event) =>
+ calls.push([
+ type,
+ 'outside',
+ event.target,
+ event.composed,
+ event
+ .composedPath()
+ .filter((node) => node === target || node === root || node === host),
+ ]),
+ );
+ }
+ await fixture.input.press('ArrowLeft');
+ expect(calls).toEqual([
+ ['keydown', 'inside', target, true, [target, root, host]],
+ ['keydown', 'outside', host, true, mode === 'open' ? [target, root, host] : [host]],
+ ['keyup', 'inside', target, true, [target, root, host]],
+ ['keyup', 'outside', host, true, mode === 'open' ? [target, root, host] : [host]],
+ ]);
+ },
+ );
+ }
+
+ context.test(
+ 'canceling a retargeted host keydown prevents editing inside the shadow root',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const target = document.createElement('input');
+ target.value = 'abcd';
+ root.append(target);
+ fixture.connect(host as unknown as DOMElementContract);
+ target.focus();
+ target.setSelectionRange(2, 2);
+ const calls: unknown[] = [];
+ host.addEventListener('keydown', (event) => {
+ calls.push(['keydown', event.target]);
+ event.preventDefault();
+ });
+ host.addEventListener('keyup', (event) => calls.push(['keyup', event.target]));
+ for (const type of ['beforeinput', 'input'])
+ target.addEventListener(type, () => calls.push([type]));
+ await fixture.input.press('Backspace');
+ expect(calls).toEqual([
+ ['keydown', host],
+ ['keyup', host],
+ ]);
+ expect(target.value).toBe('abcd');
+ expect([target.selectionStart, target.selectionEnd]).toEqual([2, 2]);
+ },
+ ['input.keyboard.editing'],
+ );
+
+ context.test(
+ 'Tab follows slot scopes and skips fallback content replaced by assigned nodes',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const before = document.createElement('input');
+ const host = document.createElement('div');
+ const after = document.createElement('input');
+ const root = host.attachShadow({ mode: 'open' });
+ const firstSlot = document.createElement('slot'),
+ secondSlot = document.createElement('slot');
+ firstSlot.name = 'assigned';
+ secondSlot.name = 'fallback';
+ const assigned = document.createElement('input');
+ assigned.slot = 'assigned';
+ const hiddenFallback = document.createElement('input');
+ const fallback = document.createElement('input');
+ firstSlot.append(hiddenFallback);
+ secondSlot.append(fallback);
+ root.append(firstSlot, secondSlot);
+ host.append(assigned);
+ const container = document.createElement('div');
+ container.append(before, host, after);
+ fixture.connect(container as unknown as DOMElementContract);
+ before.focus();
+ await fixture.input.press('Tab');
+ expect(document.activeElement).toBe(assigned);
+ await fixture.input.press('Tab');
+ expect(document.activeElement).toBe(host);
+ expect(root.activeElement).toBe(fallback);
+ await fixture.input.press('Tab');
+ expect(document.activeElement).toBe(after);
+ await fixture.input.keyDown('Shift');
+ await fixture.input.press('Tab');
+ expect(root.activeElement).toBe(fallback);
+ await fixture.input.press('Tab');
+ expect(document.activeElement).toBe(assigned);
+ await fixture.input.press('Tab');
+ expect(document.activeElement).toBe(before);
+ await fixture.input.keyUp('Shift');
+ },
+ ['input.keyboard.navigation', 'shadow.slots.fallback'],
+ );
+
+ context.test(
+ 'keyup follows focus moved from a shadow descendant into the light tree',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const container = document.createElement('div');
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'closed' });
+ const inside = document.createElement('input'),
+ outside = document.createElement('input');
+ root.append(inside);
+ container.append(host, outside);
+ fixture.connect(container as unknown as DOMElementContract);
+ const calls: unknown[] = [];
+ container.addEventListener('keydown', (event) => calls.push(['keydown', event.target]));
+ container.addEventListener('keyup', (event) => calls.push(['keyup', event.target]));
+ inside.focus();
+ await fixture.input.keyDown('Shift');
+ outside.focus();
+ await fixture.input.keyUp('Shift');
+ expect(calls).toEqual([
+ ['keydown', host],
+ ['keyup', outside],
+ ]);
+ expect(root.activeElement).toBeNull();
+ expect(document.activeElement).toBe(outside);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/keyboard/trusted-events.ts b/packages/dom/src/compatibility/input/keyboard/trusted-events.ts
new file mode 100644
index 0000000..015e7a5
--- /dev/null
+++ b/packages/dom/src/compatibility/input/keyboard/trusted-events.ts
@@ -0,0 +1,37 @@
+import type { CompatibilityContext } from '../../context.ts';
+
+export const spec = 'https://w3c.github.io/webdriver/#dispatching-a-keydown-action';
+export const input = ['keyboard'] as const;
+export const requires = ['input.keyboard.delivery'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('input-driver keyboard events are trusted', async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('input'));
+ const received: unknown[][] = [];
+ for (const type of ['keydown', 'keyup']) {
+ target.addEventListener(type, (event) => received.push([event.type, event.isTrusted]));
+ }
+ target.focus();
+ await fixture.input.press('ArrowLeft');
+ expect(received).toEqual([
+ ['keydown', true],
+ ['keyup', true],
+ ]);
+ });
+
+ context.test(
+ 'keyboard-generated button activation emits a trusted click',
+ async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ const trusted: boolean[] = [];
+ button.addEventListener('click', (event) => trusted.push(event.isTrusted));
+ button.focus();
+ await fixture.input.press('Enter');
+ expect(trusted).toEqual([true]);
+ },
+ ['input.keyboard.activation'],
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/activation.ts b/packages/dom/src/compatibility/input/mouse/activation.ts
new file mode 100644
index 0000000..6f73070
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/activation.ts
@@ -0,0 +1,160 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMSubmitEvent } from '../../contracts/events.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/interaction.html#activation-behavior';
+export const input = ['mouse'] as const;
+export const requires = [
+ 'input.mouse.delivery',
+ 'elements.input.activation',
+ 'elements.button.activation',
+ 'elements.form.submission',
+ 'events.dispatch.cancellation',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'clicking a checkbox toggles checkedness before click and emits input then change',
+ async (fixture) => {
+ const checkbox = fixture.document.createElement('input');
+ checkbox.type = 'checkbox';
+ fixture.connect(checkbox);
+ const calls: [string, boolean][] = [];
+ for (const type of ['click', 'input', 'change'])
+ checkbox.addEventListener(type, (event) => calls.push([event.type, checkbox.checked]));
+ await fixture.input.click(checkbox);
+ expect(checkbox.checked).toBe(true);
+ expect(calls).toEqual([
+ ['click', true],
+ ['input', true],
+ ['change', true],
+ ]);
+ calls.length = 0;
+ await fixture.input.click(checkbox);
+ expect(checkbox.checked).toBe(false);
+ expect(calls).toEqual([
+ ['click', false],
+ ['input', false],
+ ['change', false],
+ ]);
+ },
+ );
+
+ context.test(
+ 'canceling checkbox click restores checkedness and indeterminate without input or change',
+ async (fixture) => {
+ const checkbox = fixture.document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.indeterminate = true;
+ fixture.connect(checkbox);
+ const preactivation: boolean[][] = [];
+ const changes: string[] = [];
+ checkbox.addEventListener('click', (event) => {
+ preactivation.push([checkbox.checked, checkbox.indeterminate]);
+ event.preventDefault();
+ });
+ for (const type of ['input', 'change'])
+ checkbox.addEventListener(type, (event) => changes.push(event.type));
+ await fixture.input.click(checkbox);
+ expect(preactivation).toEqual([[true, false]]);
+ expect([checkbox.checked, checkbox.indeterminate]).toEqual([false, true]);
+ expect(changes).toEqual([]);
+ },
+ );
+
+ context.test(
+ 'clicking a submit button submits its form with that button as submitter',
+ async (fixture) => {
+ const form = fixture.document.createElement('form');
+ const button = fixture.document.createElement('button');
+ button.type = 'submit';
+ button.textContent = 'Submit';
+ form.appendChild(button);
+ fixture.connect(form);
+ const calls: string[] = [];
+ const submissions: DOMSubmitEvent[] = [];
+ button.addEventListener('click', () => calls.push('click'));
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ calls.push('submit');
+ submissions.push(event as DOMSubmitEvent);
+ });
+ await fixture.input.click(button);
+ expect(calls).toEqual(['click', 'submit']);
+ expect(submissions).toHaveLength(1);
+ expect(submissions[0]?.target).toBe(form);
+ expect(submissions[0]?.submitter).toBe(button);
+ expect(submissions[0]?.bubbles).toBe(true);
+ expect(submissions[0]?.cancelable).toBe(true);
+ },
+ );
+
+ context.test(
+ 'canceling submit-button click prevents the form submission default action',
+ async (fixture) => {
+ const form = fixture.document.createElement('form');
+ const button = fixture.document.createElement('button');
+ button.type = 'submit';
+ button.textContent = 'Canceled submit';
+ form.appendChild(button);
+ fixture.connect(form);
+ let clicks = 0;
+ let submissions = 0;
+ button.addEventListener('click', (event) => {
+ event.preventDefault();
+ clicks++;
+ });
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submissions++;
+ });
+ await fixture.input.click(button);
+ expect(clicks).toBe(1);
+ expect(submissions).toBe(0);
+ },
+ );
+
+ context.test('disabled controls suppress click activation from mouse input', async (fixture) => {
+ const form = fixture.document.createElement('form');
+ const button = fixture.document.createElement('button');
+ const checkbox = fixture.document.createElement('input');
+ button.type = 'submit';
+ button.textContent = 'Disabled submit';
+ button.disabled = true;
+ checkbox.type = 'checkbox';
+ checkbox.disabled = true;
+ form.append(button, checkbox);
+ fixture.connect(form);
+ const calls: string[] = [];
+ button.addEventListener('click', () => calls.push('button click'));
+ checkbox.addEventListener('click', () => calls.push('checkbox click'));
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ calls.push('submit');
+ });
+ await fixture.input.click(button);
+ await fixture.input.click(checkbox);
+ expect(checkbox.checked).toBe(false);
+ expect(calls).toEqual([]);
+ });
+
+ context.test(
+ 'repeated native checkbox clicks preserve click input change order',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('input');
+ e.type = 'checkbox';
+ fixture.connect(e as unknown as DOMElementContract);
+ const seen: string[] = [];
+ for (const type of ['click', 'input', 'change'])
+ e.addEventListener(type, () => seen.push(type));
+ await fixture.input.click(e as unknown as DOMElementContract);
+ await fixture.input.click(e as unknown as DOMElementContract);
+ expect(e.checked).toBe(false);
+ expect(seen).toEqual(['click', 'input', 'change', 'click', 'input', 'change']);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/buttons.ts b/packages/dom/src/compatibility/input/mouse/buttons.ts
new file mode 100644
index 0000000..aa4093d
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/buttons.ts
@@ -0,0 +1,144 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMEvent, DOMMouseEvent } from '../../contracts/events.ts';
+
+export const spec = 'https://w3c.github.io/uievents/#dom-mouseevent-buttons';
+export const input = ['mouse'] as const;
+export const requires = ['input.mouse.delivery', 'events.data.mouse'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ for (const [button, mask] of [
+ [0, 1],
+ [1, 4],
+ [2, 2],
+ ] as const) {
+ context.test(
+ `reports button ${button} with buttons mask ${mask} on press and zero on release`,
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('button'));
+ target.type = 'button';
+ target.textContent = 'Buttons';
+ target.addEventListener('contextmenu', (event) => event.preventDefault());
+ target.addEventListener('auxclick', (event) => event.preventDefault());
+ const calls: [string, number, number][] = [];
+ for (const type of ['mousedown', 'mouseup']) {
+ target.addEventListener(type, (event) => {
+ const mouse = event as DOMMouseEvent;
+ calls.push([mouse.type, mouse.button, mouse.buttons]);
+ });
+ }
+ await fixture.input.move(target);
+ await fixture.input.down(button);
+ await fixture.input.up(button);
+ expect(calls).toEqual([
+ ['mousedown', button, mask],
+ ['mouseup', button, 0],
+ ]);
+ },
+ );
+ }
+
+ context.test(
+ 'preserves other held buttons when a button is pressed or released',
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('button'));
+ target.type = 'button';
+ target.textContent = 'Chord';
+ target.addEventListener('contextmenu', (event) => event.preventDefault());
+ target.addEventListener('auxclick', (event) => event.preventDefault());
+ const states: [string, number, number][] = [];
+ for (const type of ['mousedown', 'mouseup']) {
+ target.addEventListener(type, (event) => {
+ const mouse = event as DOMMouseEvent;
+ states.push([mouse.type, mouse.button, mouse.buttons]);
+ });
+ }
+ await fixture.input.move(target);
+ await fixture.input.down(0);
+ await fixture.input.down(2);
+ await fixture.input.up(0);
+ await fixture.input.up(2);
+ expect(states).toEqual([
+ ['mousedown', 0, 1],
+ ['mousedown', 2, 3],
+ ['mouseup', 0, 2],
+ ['mouseup', 2, 0],
+ ]);
+ },
+ );
+
+ context.test(
+ 'reports held and released buttons during movement to another element',
+ async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'Start';
+ second.textContent = 'Destination';
+ root.append(first, second);
+ fixture.connect(root);
+ // Suppress selection/drag defaults while observing the mouse button state.
+ first.addEventListener('mousedown', (event) => event.preventDefault());
+ const moves: DOMMouseEvent[] = [];
+ const record = (event: DOMEvent): void => {
+ moves.push(event as DOMMouseEvent);
+ };
+ first.addEventListener('mousemove', record);
+ second.addEventListener('mousemove', record);
+ await fixture.input.move(first);
+ moves.length = 0;
+ await fixture.input.down();
+ await fixture.input.move(second);
+ expect(moves.length).toBeGreaterThan(0);
+ expect(moves.at(-1)?.target).toBe(second);
+ expect(moves.at(-1)?.buttons).toBe(1);
+ await fixture.input.up();
+ moves.length = 0;
+ await fixture.input.move(first);
+ expect(moves.length).toBeGreaterThan(0);
+ expect(moves.at(-1)?.target).toBe(first);
+ expect(moves.at(-1)?.buttons).toBe(0);
+ },
+ );
+
+ context.test(
+ 'does not turn auxiliary or secondary button clicks into primary click events',
+ async (fixture) => {
+ const target = fixture.connect(fixture.document.createElement('button'));
+ target.type = 'button';
+ target.textContent = 'Auxiliary';
+ target.addEventListener('contextmenu', (event) => event.preventDefault());
+ target.addEventListener('auxclick', (event) => event.preventDefault());
+ let clicks = 0;
+ target.addEventListener('click', () => clicks++);
+ await fixture.input.click(target, 1);
+ await fixture.input.click(target, 2);
+ expect(clicks).toBe(0);
+ await fixture.input.click(target, 0);
+ expect(clicks).toBe(1);
+ },
+ );
+
+ context.test(
+ 'canceling auxiliary activation does not produce a primary click',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('button');
+ fixture.connect(e as unknown as DOMElementContract);
+ const clicks: number[] = [],
+ aux: number[] = [];
+ e.addEventListener('click', (event) => clicks.push(event.button));
+ e.addEventListener('auxclick', (event) => {
+ aux.push(event.button);
+ event.preventDefault();
+ });
+ await fixture.input.click(e as unknown as DOMElementContract, 1);
+ expect(clicks).toEqual([]);
+ expect(aux).toEqual([1]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/delivery.ts b/packages/dom/src/compatibility/input/mouse/delivery.ts
new file mode 100644
index 0000000..e032cad
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/delivery.ts
@@ -0,0 +1,100 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMEvent, DOMEventTarget } from '../../contracts/events.ts';
+
+export const spec = 'https://w3c.github.io/uievents/#events-mouseevent-event-order';
+export const input = ['mouse'] as const;
+export const requires = [
+ 'events.dispatch.target',
+ 'events.dispatch.capture',
+ 'events.dispatch.bubbling',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'delivers down, up, and click through capture, target, and bubble',
+ async (fixture) => {
+ const { document } = fixture;
+ const root = document.createElement('div');
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = 'Target';
+ root.appendChild(button);
+ fixture.connect(root);
+ const calls: {
+ type: string;
+ target: DOMEventTarget | null;
+ currentTarget: DOMEventTarget | null;
+ phase: number;
+ bubbles: boolean;
+ cancelable: boolean;
+ }[] = [];
+ const record = (event: DOMEvent): void => {
+ calls.push({
+ type: event.type,
+ target: event.target,
+ currentTarget: event.currentTarget,
+ phase: event.eventPhase,
+ bubbles: event.bubbles,
+ cancelable: event.cancelable,
+ });
+ };
+ for (const type of ['mousedown', 'mouseup', 'click']) {
+ root.addEventListener(type, record, true);
+ button.addEventListener(type, record);
+ root.addEventListener(type, record);
+ }
+ await fixture.input.click(button);
+ expect(calls).toEqual(
+ ['mousedown', 'mouseup', 'click'].flatMap((type) =>
+ [root, button, root].map((currentTarget, index) => ({
+ type,
+ target: button,
+ currentTarget,
+ phase: index + 1,
+ bubbles: true,
+ cancelable: true,
+ })),
+ ),
+ );
+ },
+ );
+
+ context.test(
+ 'defers mouseup and click until the held primary button is released',
+ async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ button.textContent = 'Hold';
+ const calls: string[] = [];
+ for (const type of ['mousedown', 'mouseup', 'click'])
+ button.addEventListener(type, (event) => calls.push(event.type));
+ await fixture.input.move(button);
+ await fixture.input.down();
+ expect(calls).toEqual(['mousedown']);
+ await fixture.input.up();
+ expect(calls).toEqual(['mousedown', 'mouseup', 'click']);
+ },
+ );
+
+ context.test(
+ 'retargets mouse delivery when the pointer moves between sibling controls',
+ async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'First';
+ second.textContent = 'Second';
+ root.append(first, second);
+ fixture.connect(root);
+ const targets: (DOMEventTarget | null)[] = [];
+ root.addEventListener('click', (event) => targets.push(event.target));
+ await fixture.input.click(first);
+ await fixture.input.click(second);
+ expect(targets).toEqual([first, second]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/focus-visible.ts b/packages/dom/src/compatibility/input/mouse/focus-visible.ts
new file mode 100644
index 0000000..d786144
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/focus-visible.ts
@@ -0,0 +1,58 @@
+// Adapted from WPT focus-visible-002.html and focus-visible-003.html at
+// b719612f963a05ece9c89a7dd7598c1c59618874. Copyright WPT contributors; Alice Boxhall.
+// BSD-3-Clause; ../../../../test/wpt/LICENSE.txt and ../../../../docs/wpt-state-styles.md.
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement } from '../../contracts/nodes.ts';
+import { createStyleFixture } from '../../style-fixture.ts';
+export const spec = 'https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/focus-visible-002.html';
+export const input = ['mouse'] as const;
+export const requires = ['input.mouse.delivery', 'style.focusState'] as const;
+export const globals = ['getComputedStyle'] as const;
+/** Registers the explicit text-entry versus non-text mouse focus indication policy. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const markup of [
+ '',
+ '',
+ '',
+ '',
+ ]) {
+ context.test(`mouse focus on ${markup} matches focus-visible and styles`, async (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, {
+ markup: markup,
+ css: ':focus-visible {color:rgb(4,5,6)}',
+ });
+ const target = root.firstElementChild as HTMLElement;
+ await fixture.input.click(target as unknown as DOMElement);
+ expect(document.activeElement).toBe(target);
+ expect(target.matches(':focus-visible')).toBe(true);
+ expect(get(target).color).toBe('rgb(4, 5, 6)');
+ target.blur();
+ expect(target.matches(':focus-visible')).toBe(false);
+ });
+ }
+ for (const markup of [
+ '',
+ 'Span',
+ '',
+ ]) {
+ context.test(
+ `mouse focus on ${markup} matches focus without focus-visible`,
+ async (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, {
+ markup: markup,
+ css: ':focus {color:rgb(1,2,3)} :focus-visible {color:rgb(4,5,6)}',
+ });
+ const target = root.firstElementChild as HTMLElement;
+ await fixture.input.click(target as unknown as DOMElement);
+ expect(document.activeElement).toBe(target);
+ expect(target.matches(':focus')).toBe(true);
+ expect(target.matches(':focus-visible')).toBe(false);
+ expect(get(target).color).toBe('rgb(1, 2, 3)');
+ },
+ ['input.mouse.focus'],
+ );
+ }
+}
diff --git a/packages/dom/src/compatibility/input/mouse/focus.ts b/packages/dom/src/compatibility/input/mouse/focus.ts
new file mode 100644
index 0000000..cff189e
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/focus.ts
@@ -0,0 +1,86 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement } from '../../contracts/nodes.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/interaction.html#click-focusable';
+export const input = ['mouse'] as const;
+// Click focusability is a host/platform policy. Enable this leaf for hosts that
+// focus enabled buttons on primary mousedown.
+export const requires = [
+ 'input.mouse.delivery',
+ 'focus.eligibility',
+ 'focus.transitions',
+ 'events.dispatch.cancellation',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test('primary mousedown transfers focus before mouseup and click', async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'Previously focused';
+ second.textContent = 'Mouse target';
+ root.append(first, second);
+ fixture.connect(root);
+ first.focus();
+ const calls: string[] = [];
+ const activeDuringRelease: (DOMElement | null)[] = [];
+ first.addEventListener('blur', () => calls.push('blur'));
+ second.addEventListener('focus', () => calls.push('focus'));
+ for (const type of ['mouseup', 'click'])
+ second.addEventListener(type, () => activeDuringRelease.push(fixture.document.activeElement));
+ await fixture.input.move(second);
+ await fixture.input.down();
+ expect(fixture.document.activeElement).toBe(second);
+ expect(calls).toEqual(['blur', 'focus']);
+ await fixture.input.up();
+ expect(activeDuringRelease).toEqual([second, second]);
+ });
+
+ context.test(
+ 'canceling mousedown preserves focus while still allowing click delivery',
+ async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'Keep focus';
+ second.textContent = 'Cancel mousedown';
+ root.append(first, second);
+ fixture.connect(root);
+ first.focus();
+ const transitions: string[] = [];
+ let clicks = 0;
+ first.addEventListener('blur', () => transitions.push('blur'));
+ second.addEventListener('focus', () => transitions.push('focus'));
+ second.addEventListener('mousedown', (event) => event.preventDefault());
+ second.addEventListener('click', () => clicks++);
+ await fixture.input.click(second);
+ expect(fixture.document.activeElement).toBe(first);
+ expect(transitions).toEqual([]);
+ expect(clicks).toBe(1);
+ },
+ );
+
+ context.test(
+ 'clicking the focused control does not repeat focus transitions',
+ async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ button.textContent = 'Already focused';
+ button.focus();
+ const transitions: string[] = [];
+ let clicks = 0;
+ for (const type of ['blur', 'focusout', 'focus', 'focusin'])
+ button.addEventListener(type, (event) => transitions.push(event.type));
+ button.addEventListener('click', () => clicks++);
+ await fixture.input.click(button);
+ expect(fixture.document.activeElement).toBe(button);
+ expect(transitions).toEqual([]);
+ expect(clicks).toBe(1);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/hover.ts b/packages/dom/src/compatibility/input/mouse/hover.ts
new file mode 100644
index 0000000..b21e28f
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/hover.ts
@@ -0,0 +1,114 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMEvent, DOMEventTarget, DOMMouseEvent } from '../../contracts/events.ts';
+
+export const spec = 'https://w3c.github.io/uievents/#events-mouseevent-event-order';
+export const input = ['mouse'] as const;
+export const requires = [
+ 'input.mouse.delivery',
+ 'events.data.mouse',
+ 'events.dispatch.capture',
+ 'events.dispatch.bubbling',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'moving between siblings leaves the old target before entering the new one',
+ async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'First';
+ second.textContent = 'Second';
+ root.append(first, second);
+ fixture.connect(root);
+ await fixture.input.move(first);
+ const calls: [string, DOMEventTarget | null, DOMEventTarget | null][] = [];
+ const record = (event: DOMEvent): void => {
+ const mouse = event as DOMMouseEvent;
+ calls.push([mouse.type, mouse.target, mouse.relatedTarget]);
+ };
+ for (const target of [first, second])
+ for (const type of ['mouseout', 'mouseleave', 'mouseover', 'mouseenter'])
+ target.addEventListener(type, record);
+ await fixture.input.move(second);
+ expect(calls).toEqual([
+ ['mouseout', first, second],
+ ['mouseleave', first, second],
+ ['mouseover', second, first],
+ ['mouseenter', second, first],
+ ]);
+ },
+ );
+
+ context.test(
+ 'over and out bubble while enter and leave are captured without bubbling',
+ async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'First';
+ second.textContent = 'Second';
+ root.append(first, second);
+ fixture.connect(root);
+ await fixture.input.move(first);
+ const calls: [string, string, boolean, DOMEventTarget | null][] = [];
+ for (const type of ['mouseout', 'mouseleave', 'mouseover', 'mouseenter']) {
+ root.addEventListener(
+ type,
+ (event) => {
+ calls.push(['capture', event.type, event.bubbles, event.target]);
+ },
+ true,
+ );
+ root.addEventListener(type, (event) => {
+ calls.push(['bubble', event.type, event.bubbles, event.target]);
+ });
+ }
+ await fixture.input.move(second);
+ expect(calls).toEqual([
+ ['capture', 'mouseout', true, first],
+ ['bubble', 'mouseout', true, first],
+ ['capture', 'mouseleave', false, first],
+ ['capture', 'mouseover', true, second],
+ ['bubble', 'mouseover', true, second],
+ ['capture', 'mouseenter', false, second],
+ ]);
+ },
+ );
+
+ context.test(
+ 'movement within a common ancestor does not leave or reenter that ancestor',
+ async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'First';
+ second.textContent = 'Second';
+ root.append(first, second);
+ fixture.connect(root);
+ await fixture.input.move(first);
+ const rootTransitions: string[] = [];
+ const childTransitions: string[] = [];
+ for (const type of ['mouseenter', 'mouseleave']) {
+ root.addEventListener(type, (event) => rootTransitions.push(event.type));
+ first.addEventListener(type, (event) => childTransitions.push(`first:${event.type}`));
+ second.addEventListener(type, (event) => childTransitions.push(`second:${event.type}`));
+ }
+ await fixture.input.move(second);
+ await fixture.input.move(first);
+ expect(rootTransitions).toEqual([]);
+ expect(childTransitions).toEqual([
+ 'first:mouseleave',
+ 'second:mouseenter',
+ 'second:mouseleave',
+ 'first:mouseenter',
+ ]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/shadow.ts b/packages/dom/src/compatibility/input/mouse/shadow.ts
new file mode 100644
index 0000000..553bfe0
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/shadow.ts
@@ -0,0 +1,147 @@
+// Adapted from WPT shadow event paths, related-target tests, and native mouseover/out tests
+// at b719612f963a05ece9c89a7dd7598c1c59618874. Copyright © web-platform-tests contributors.
+// Shadow test authors: Hayato Ito and Ryosuke Niwa. BSD-3-Clause; see ../../../../test/wpt/LICENSE.txt.
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/event-with-related-target.html';
+export const input = ['mouse'] as const;
+export const requires = [
+ 'input.mouse.delivery',
+ 'shadow.events.composedPath',
+ 'shadow.events.relatedTarget',
+] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const mode of ['open', 'closed'] as const) {
+ context.test(
+ `retargets native mouse down/up/click outside a ${mode} shadow root`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode });
+ const target = document.createElement('button');
+ target.type = 'button';
+ target.textContent = 'Shadow target';
+ root.append(target);
+ fixture.connect(host as unknown as DOMElementContract);
+ const calls: unknown[] = [];
+ for (const type of ['mousedown', 'mouseup', 'click']) {
+ target.addEventListener(type, (event) =>
+ calls.push([
+ type,
+ 'inside',
+ event.target,
+ event.composed,
+ event.composedPath().slice(0, 3),
+ ]),
+ );
+ host.addEventListener(type, (event) =>
+ calls.push([
+ type,
+ 'outside',
+ event.target,
+ event.composed,
+ event
+ .composedPath()
+ .filter((node) => node === target || node === root || node === host),
+ ]),
+ );
+ }
+ await fixture.input.click(target as unknown as DOMElementContract);
+ const outsidePath = mode === 'open' ? [target, root, host] : [host];
+ expect(calls).toEqual([
+ ['mousedown', 'inside', target, true, [target, root, host]],
+ ['mousedown', 'outside', host, true, outsidePath],
+ ['mouseup', 'inside', target, true, [target, root, host]],
+ ['mouseup', 'outside', host, true, outsidePath],
+ ['click', 'inside', target, true, [target, root, host]],
+ ['click', 'outside', host, true, outsidePath],
+ ]);
+ },
+ );
+ }
+
+ context.test(
+ 'canceling a retargeted host click rolls back shadow checkbox activation',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'closed' });
+ const target = document.createElement('input');
+ target.type = 'checkbox';
+ target.indeterminate = true;
+ root.append(target);
+ fixture.connect(host as unknown as DOMElementContract);
+ const calls: unknown[] = [];
+ target.addEventListener('click', (event) =>
+ calls.push(['inside', event.target, target.checked, target.indeterminate]),
+ );
+ host.addEventListener('click', (event) => {
+ calls.push(['outside', event.target, target.checked, target.indeterminate]);
+ event.preventDefault();
+ });
+ for (const type of ['input', 'change'])
+ target.addEventListener(type, () => calls.push([type]));
+ await fixture.input.click(target as unknown as DOMElementContract);
+ expect(calls).toEqual([
+ ['inside', target, true, false],
+ ['outside', host, true, false],
+ ]);
+ expect([target.checked, target.indeterminate]).toEqual([false, true]);
+ },
+ ['elements.input.activation', 'events.dispatch.cancellation'],
+ );
+
+ context.test(
+ 'trims internal hover transitions and retargets relatedTarget when leaving shadow',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const container = document.createElement('div');
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'closed' });
+ const first = document.createElement('button'),
+ second = document.createElement('button'),
+ outside = document.createElement('button');
+ first.type = second.type = outside.type = 'button';
+ first.textContent = 'First';
+ second.textContent = 'Second';
+ outside.textContent = 'Outside';
+ root.append(first, second);
+ container.append(host, outside);
+ fixture.connect(container as unknown as DOMElementContract);
+ await fixture.input.move(first as unknown as DOMElementContract);
+ const insideCalls: unknown[] = [],
+ outsideCalls: unknown[] = [];
+ for (const type of ['mouseover', 'mouseout']) {
+ root.addEventListener(type, (event) =>
+ insideCalls.push([type, event.target, (event as MouseEvent).relatedTarget]),
+ );
+ host.addEventListener(type, (event) =>
+ outsideCalls.push([type, event.target, (event as MouseEvent).relatedTarget]),
+ );
+ outside.addEventListener(type, (event) =>
+ outsideCalls.push([type, event.target, (event as MouseEvent).relatedTarget]),
+ );
+ }
+ await fixture.input.move(second as unknown as DOMElementContract);
+ expect(insideCalls).toEqual([
+ ['mouseout', first, second],
+ ['mouseover', second, first],
+ ]);
+ expect(outsideCalls).toEqual([]);
+ insideCalls.length = 0;
+ await fixture.input.move(outside as unknown as DOMElementContract);
+ expect(insideCalls).toEqual([['mouseout', second, outside]]);
+ expect(outsideCalls).toEqual([
+ ['mouseout', host, outside],
+ ['mouseover', outside, host],
+ ]);
+ },
+ ['input.mouse.hover'],
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/state-styles.ts b/packages/dom/src/compatibility/input/mouse/state-styles.ts
new file mode 100644
index 0000000..503aa3d
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/state-styles.ts
@@ -0,0 +1,131 @@
+// Adapted from WPT user-action-pseudo-classes-in-has.html at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright web-platform-tests contributors; Byungwoo Lee. BSD-3-Clause.
+// See ../../../../test/wpt/LICENSE.txt and ../../../../docs/wpt-state-styles.md.
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement } from '../../contracts/nodes.ts';
+import { createStyleFixture } from '../../style-fixture.ts';
+export const spec = 'https://drafts.csswg.org/selectors-4/#useraction-pseudos';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/invalidation/user-action-pseudo-classes-in-has.html';
+export const input = ['mouse'] as const;
+export const requires = [
+ 'input.mouse.delivery',
+ 'input.mouse.hover',
+ 'element.selectors',
+ 'parsing.fragments',
+ 'style.computed',
+] as const;
+export const globals = ['getComputedStyle'] as const;
+/** Registers pointer state and CSS interactions through the host input pipeline. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'hover follows pointer movement and updates both targets and ancestors',
+ async (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: 'section, button {color:rgb(1,2,3)} section:hover {color:rgb(4,5,6)} button:hover {color:rgb(7,8,9)}',
+ });
+ const first = root.querySelector('button')!,
+ outside = root.querySelector('#outside')!,
+ section = root.querySelector('section')!;
+ await fixture.input.move(first as unknown as DOMElement);
+ expect(first.matches(':hover'), 'target hover after move').toBe(true);
+ expect(section.matches(':hover'), 'ancestor hover after move').toBe(true);
+ expect(get(first).color).toBe('rgb(7, 8, 9)');
+ expect(get(section).color).toBe('rgb(4, 5, 6)');
+ await fixture.input.move(outside as unknown as DOMElement);
+ expect(first.matches(':hover')).toBe(false);
+ expect(section.matches(':hover')).toBe(false);
+ expect(get(first).color).toBe('rgb(1, 2, 3)');
+ expect(get(outside).color).toBe('rgb(7, 8, 9)');
+ },
+ );
+ context.test(
+ 'mouse down and up enter and leave active while retaining hover',
+ async (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: 'button:hover {color:rgb(1,2,3)} button:hover:active {color:rgb(4,5,6)}',
+ });
+ const target = root.querySelector('button')!;
+ await fixture.input.move(target as unknown as DOMElement);
+ expect(target.matches(':active')).toBe(false);
+ await fixture.input.down();
+ expect(target.matches(':hover:active'), 'hover and active after down').toBe(true);
+ expect(get(target).color).toBe('rgb(4, 5, 6)');
+ await fixture.input.up();
+ expect(target.matches(':active')).toBe(false);
+ expect(target.matches(':hover'), 'hover after release').toBe(true);
+ expect(get(target).color).toBe('rgb(1, 2, 3)');
+ },
+ );
+ context.test(
+ 'relational hover and active update an ancestor and a sibling descendant',
+ async (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup:
+ '',
+ css: 'section, i {color:rgb(1,2,3)} section:has(button:hover) {color:rgb(4,5,6)} section:has(button:hover) i {color:rgb(7,8,9)} section:has(button:active) i {color:rgb(10,11,12)}',
+ });
+ const target = root.querySelector('button')!,
+ section = root.querySelector('section')!,
+ sibling = root.querySelector('i')!;
+ await fixture.input.move(target as unknown as DOMElement);
+ expect(section.matches(':has(button:hover)')).toBe(true);
+ expect(get(section).color).toBe('rgb(4, 5, 6)');
+ expect(get(sibling).color).toBe('rgb(7, 8, 9)');
+ await fixture.input.down();
+ expect(get(sibling).color).toBe('rgb(10, 11, 12)');
+ await fixture.input.up();
+ expect(get(sibling).color).toBe('rgb(7, 8, 9)');
+ await fixture.input.move(root.querySelector('#outside')! as unknown as DOMElement);
+ expect(get(sibling).color).toBe('rgb(1, 2, 3)');
+ },
+ ['style.relationalSelectors'],
+ );
+ context.test(
+ 'canceling mousedown preserves prior focus and focus styles',
+ async (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: 'button {color:rgb(1,2,3)} button:focus {color:rgb(4,5,6)}',
+ });
+ const [first, second] = Array.from(root.querySelectorAll('button'));
+ first!.focus();
+ second!.addEventListener('mousedown', (event) => event.preventDefault());
+ await fixture.input.click(second! as unknown as DOMElement);
+ expect(document.activeElement).toBe(first);
+ expect(get(first!).color).toBe('rgb(4, 5, 6)');
+ expect(get(second!).color).toBe('rgb(1, 2, 3)');
+ },
+ ['input.mouse.focus', 'style.focusState'],
+ );
+ context.test(
+ 'native checkbox activation and cancellation update checked styles with rollback',
+ async (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: 'input {color:rgb(1,2,3)} input:checked {color:rgb(4,5,6)}',
+ });
+ const target = root.querySelector('input')!;
+ await fixture.input.click(target as unknown as DOMElement);
+ expect(target.matches(':checked')).toBe(true);
+ expect(get(target).color).toBe('rgb(4, 5, 6)');
+ const seen: unknown[] = [];
+ target.addEventListener(
+ 'click',
+ (event) => {
+ seen.push(target.matches(':checked'), get(target).color);
+ event.preventDefault();
+ },
+ { once: true },
+ );
+ await fixture.input.click(target as unknown as DOMElement);
+ expect(seen).toEqual([false, 'rgb(1, 2, 3)']);
+ expect(target.matches(':checked')).toBe(true);
+ expect(get(target).color).toBe('rgb(4, 5, 6)');
+ },
+ ['input.mouse.activation', 'style.controlSelectors', 'events.listeners.once'],
+ );
+}
diff --git a/packages/dom/src/compatibility/input/mouse/trusted-events.ts b/packages/dom/src/compatibility/input/mouse/trusted-events.ts
new file mode 100644
index 0000000..4b3288e
--- /dev/null
+++ b/packages/dom/src/compatibility/input/mouse/trusted-events.ts
@@ -0,0 +1,48 @@
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMEvent } from '../../contracts/events.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-event-istrusted';
+export const input = ['mouse'] as const;
+export const requires = ['input.mouse.delivery', 'input.mouse.hover'] as const;
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test('input-generated down, up, and click events are trusted', async (fixture) => {
+ const button = fixture.connect(fixture.document.createElement('button'));
+ button.type = 'button';
+ button.textContent = 'Trusted click';
+ const events: DOMEvent[] = [];
+ for (const type of ['mousedown', 'mouseup', 'click'])
+ button.addEventListener(type, (event) => events.push(event));
+ await fixture.input.click(button);
+ expect(events.map((event) => [event.type, event.isTrusted])).toEqual([
+ ['mousedown', true],
+ ['mouseup', true],
+ ['click', true],
+ ]);
+ });
+
+ context.test('input-generated hover and movement events are trusted', async (fixture) => {
+ const root = fixture.document.createElement('div');
+ const first = fixture.document.createElement('button');
+ const second = fixture.document.createElement('button');
+ first.type = second.type = 'button';
+ first.textContent = 'Start';
+ second.textContent = 'Destination';
+ root.append(first, second);
+ fixture.connect(root);
+ await fixture.input.move(first);
+ const events: DOMEvent[] = [];
+ for (const target of [first, second])
+ for (const type of ['mouseout', 'mouseleave', 'mouseover', 'mouseenter', 'mousemove'])
+ target.addEventListener(type, (event) => events.push(event));
+ await fixture.input.move(second);
+ for (const type of ['mouseout', 'mouseleave', 'mouseover', 'mouseenter', 'mousemove']) {
+ const matching = events.filter((event) => event.type === type);
+ expect(matching.length).toBeGreaterThan(0);
+ expect(matching.every((event) => event.isTrusted)).toBe(true);
+ }
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/detached.ts b/packages/dom/src/compatibility/mutation-observer/detached.ts
new file mode 100644
index 0000000..2259ffe
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/detached.ts
@@ -0,0 +1,55 @@
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const requires = ['mutationObserver.subtree', 'mutationObserver.microtaskDelivery'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'observes an explicitly targeted detached node',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributes: true });
+ target.setAttribute('title', 'detached');
+ const [record] = observer.takeRecords();
+ expect(record?.target).toBe(target);
+ },
+ );
+ context.test(
+ 'tracks a removed descendant until the removal notification microtask',
+ async ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const root = document.createElement('div');
+ const child = document.createElement('span');
+ root.appendChild(child);
+ const delivered: string[][] = [];
+ const observer = new MutationObserver((records) => {
+ delivered.push(
+ records.map((record) => {
+ return record.type;
+ }),
+ );
+ });
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(root, { childList: true, attributes: true, subtree: true });
+ root.removeChild(child);
+ child.setAttribute('title', 'still observed');
+ await Promise.resolve();
+ expect(delivered).toEqual([['childList', 'attributes']]);
+ child.setAttribute('title', 'no longer observed');
+ await Promise.resolve();
+ expect(delivered).toEqual([['childList', 'attributes']]);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/disconnect.ts b/packages/dom/src/compatibility/mutation-observer/disconnect.ts
new file mode 100644
index 0000000..63dbc93
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/disconnect.ts
@@ -0,0 +1,53 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'discards pending records and allows later re-observation',
+ async ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ let callbacks = 0;
+ const observer = new MutationObserver(() => {
+ callbacks++;
+ });
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributes: true });
+ target.setAttribute('id', 'pending');
+ observer.disconnect();
+ expect(observer.takeRecords()).toEqual([]);
+ target.setAttribute('id', 'unobserved');
+ await Promise.resolve();
+ expect(callbacks).toBe(0);
+ observer.observe(target, { attributes: true });
+ target.setAttribute('id', 'observed again');
+ expect(observer.takeRecords().length).toBe(1);
+ },
+ );
+
+ context.test('discards queued records and permits subsequent observation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const o = new MutationObserver(() => {});
+ fixture.cleanup(() => o.disconnect());
+ const e = document.createElement('div');
+ o.observe(e as unknown as DOMNodeContract, { attributes: true });
+ e.setAttribute('x', '1');
+ o.disconnect();
+ expect(o.takeRecords()).toEqual([]);
+ e.setAttribute('x', '2');
+ expect(o.takeRecords()).toEqual([]);
+ o.observe(e as unknown as DOMNodeContract, { attributes: true });
+ e.setAttribute('x', '3');
+ expect(o.takeRecords().length).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/filtering.ts b/packages/dom/src/compatibility/mutation-observer/filtering.ts
new file mode 100644
index 0000000..b21e2fa
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/filtering.ts
@@ -0,0 +1,57 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'filters by exact local name and excludes namespaced attributes',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributeFilter: ['title'] });
+ target.setAttribute('id', 'ignored');
+ target.setAttribute('title', 'observed');
+ target.setAttributeNS('urn:compatibility', 'x:title', 'ignored');
+ expect(
+ observer.takeRecords().map((record) => {
+ return record.attributeName;
+ }),
+ ).toEqual(['title']);
+ observer.observe(target, { attributeFilter: [] });
+ target.setAttribute('title', 'ignored');
+ expect(observer.takeRecords()).toEqual([]);
+ },
+ );
+
+ context.test(
+ 'matches attribute filters exactly and excludes namespaced attributes',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const observer = new MutationObserver(() => {});
+ fixture.cleanup(() => observer.disconnect());
+ const e = document.createElement('div');
+ observer.observe(e as unknown as DOMNodeContract, {
+ attributes: true,
+ attributeFilter: ['x'],
+ });
+ e.setAttribute('y', 'a');
+ e.setAttributeNS('urn:test', 'p:x', 'b');
+ e.setAttribute('x', 'c');
+ const records = observer.takeRecords();
+ expect(records.length).toBe(1);
+ expect(records[0]!.attributeName).toBe('x');
+ expect(records[0]!.attributeNamespace).toBeNull();
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/microtask-delivery.ts b/packages/dom/src/compatibility/mutation-observer/microtask-delivery.ts
new file mode 100644
index 0000000..db46003
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/microtask-delivery.ts
@@ -0,0 +1,62 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'batches records into a microtask and supplies the observer as receiver and argument',
+ async ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const deliveries: (string | null)[][] = [];
+ const receivers: unknown[] = [];
+ const arguments_: unknown[] = [];
+ const observer = new MutationObserver(function (records, suppliedObserver) {
+ receivers.push(this);
+ arguments_.push(suppliedObserver);
+ deliveries.push(
+ records.map((record) => {
+ return record.attributeName;
+ }),
+ );
+ });
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributes: true });
+ target.setAttribute('id', 'one');
+ target.setAttribute('title', 'two');
+ expect(deliveries).toEqual([]);
+ await Promise.resolve();
+ expect(deliveries).toEqual([['id', 'title']]);
+ expect(receivers[0]).toBe(observer);
+ expect(arguments_[0]).toBe(observer);
+ expect(observer.takeRecords()).toEqual([]);
+ },
+ );
+
+ context.test(
+ 'batches synchronous mutations and delivers before a later queued microtask',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const seen: string[] = [];
+ const o = new MutationObserver((records) => seen.push('observer:' + records.length));
+ fixture.cleanup(() => o.disconnect());
+ const e = document.createElement('div');
+ o.observe(e as unknown as DOMNodeContract, { attributes: true });
+ e.setAttribute('x', '1');
+ e.setAttribute('y', '2');
+ queueMicrotask(() => seen.push('microtask'));
+ expect(seen).toEqual([]);
+ await new Promise((resolve) => queueMicrotask(resolve));
+ expect(seen).toEqual(['observer:2', 'microtask']);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/old-value.ts b/packages/dom/src/compatibility/mutation-observer/old-value.ts
new file mode 100644
index 0000000..7e6eb54
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/old-value.ts
@@ -0,0 +1,52 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'captures prior attribute and character-data values in mutation order',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const text = document.createTextNode('a');
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributeOldValue: true });
+ observer.observe(text, { characterDataOldValue: true });
+ target.setAttribute('title', 'one');
+ target.setAttribute('title', 'two');
+ target.removeAttribute('title');
+ text.data = 'b';
+ text.data = 'c';
+ expect(
+ observer.takeRecords().map((record) => {
+ return record.oldValue;
+ }),
+ ).toEqual([null, 'one', 'two', 'a', 'b']);
+ },
+ );
+
+ context.test('records each prior value including null and repeated assignments', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const observer = new MutationObserver(() => {});
+ fixture.cleanup(() => observer.disconnect());
+ const e = document.createElement('div');
+ observer.observe(e as unknown as DOMNodeContract, {
+ attributes: true,
+ attributeOldValue: true,
+ });
+ e.setAttribute('x', 'a');
+ e.setAttribute('x', 'a');
+ e.removeAttribute('x');
+ expect(observer.takeRecords().map((r) => r.oldValue)).toEqual([null, 'a', 'a']);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/records/attributes.ts b/packages/dom/src/compatibility/mutation-observer/records/attributes.ts
new file mode 100644
index 0000000..59d2bf0
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/records/attributes.ts
@@ -0,0 +1,69 @@
+import type { DOMNode as DOMNodeContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'records attribute names, namespaces, and mutations that repeat a value',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributes: true });
+ target.setAttribute('title', 'one');
+ target.setAttribute('title', 'one');
+ target.removeAttribute('title');
+ target.setAttributeNS('urn:compatibility', 'x:flag', 'yes');
+ const records = observer.takeRecords();
+ expect(records.length).toBe(4);
+ expect(
+ records.map((record) => {
+ return record.attributeName;
+ }),
+ ).toEqual(['title', 'title', 'title', 'flag']);
+ expect(
+ records.map((record) => {
+ return record.attributeNamespace;
+ }),
+ ).toEqual([null, null, null, 'urn:compatibility']);
+ for (const record of records) {
+ expect(record.type).toBe('attributes');
+ expect(record.target).toBe(target);
+ expect(record.addedNodes.length).toBe(0);
+ expect(record.removedNodes.length).toBe(0);
+ expect(record.previousSibling).toBeNull();
+ expect(record.nextSibling).toBeNull();
+ expect(record.oldValue).toBeNull();
+ }
+ },
+ );
+
+ context.test('records namespace and local name independently of prefix', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver'),
+ o = new MutationObserver(() => {});
+ fixture.cleanup(() => o.disconnect());
+ const e = document.createElement('div');
+ o.observe(e as unknown as DOMNodeContract, { attributes: true, attributeOldValue: true });
+ e.setAttributeNS('urn:x', 'p:name', 'a');
+ e.setAttributeNS('urn:x', 'q:name', 'b');
+ const records = o.takeRecords();
+ expect(records.map((r) => [r.type, r.attributeName, r.attributeNamespace, r.oldValue])).toEqual(
+ [
+ ['attributes', 'name', 'urn:x', null],
+ ['attributes', 'name', 'urn:x', 'a'],
+ ],
+ );
+ expect(records[0]!.addedNodes.length).toBe(0);
+ expect(records[0]!.removedNodes.length).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/records/character-data.ts b/packages/dom/src/compatibility/mutation-observer/records/character-data.ts
new file mode 100644
index 0000000..96a6b51
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/records/character-data.ts
@@ -0,0 +1,60 @@
+import type { DOMNode as DOMNodeContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'records text and comment data mutations without old values by default',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const text = document.createTextNode('before');
+ const comment = document.createComment('before');
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(text, { characterData: true });
+ observer.observe(comment, { characterData: true });
+ text.data = 'after';
+ comment.data = 'after';
+ const records = observer.takeRecords();
+ expect(records.length).toBe(2);
+ expect(records[0]?.target).toBe(text);
+ expect(records[1]?.target).toBe(comment);
+ for (const record of records) {
+ expect(record.type).toBe('characterData');
+ expect(record.oldValue).toBeNull();
+ expect(record.addedNodes.length).toBe(0);
+ expect(record.removedNodes.length).toBe(0);
+ expect(record.attributeName).toBeNull();
+ expect(record.attributeNamespace).toBeNull();
+ }
+ },
+ );
+
+ context.test('reports each edit with previous data and null attribute fields', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver'),
+ o = new MutationObserver(() => {});
+ fixture.cleanup(() => o.disconnect());
+ const t = document.createTextNode('abc');
+ o.observe(t as unknown as DOMNodeContract, {
+ characterData: true,
+ characterDataOldValue: true,
+ });
+ t.appendData('d');
+ t.deleteData(0, 1);
+ const records = o.takeRecords();
+ expect(records.map((r) => r.oldValue)).toEqual(['abc', 'abcd']);
+ expect(records[0]!.attributeName).toBeNull();
+ expect(records[0]!.attributeNamespace).toBeNull();
+ expect(records[0]!.previousSibling).toBeNull();
+ expect(records[0]!.target).toBe(t);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/records/child-list.ts b/packages/dom/src/compatibility/mutation-observer/records/child-list.ts
new file mode 100644
index 0000000..5d0e42c
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/records/child-list.ts
@@ -0,0 +1,72 @@
+import type { DOMNode as DOMNodeContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'records inserted and removed node identities with sibling positions',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const parent = document.createElement('div');
+ const first = document.createElement('span');
+ const last = document.createElement('span');
+ parent.appendChild(first);
+ parent.appendChild(last);
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(parent, { childList: true });
+ const inserted = document.createTextNode('inserted');
+ parent.insertBefore(inserted, last);
+ parent.removeChild(inserted);
+ const records = observer.takeRecords();
+ expect(records.length).toBe(2);
+ const addition = records[0];
+ const removal = records[1];
+ expect(addition?.type).toBe('childList');
+ expect(addition?.target).toBe(parent);
+ expect(addition?.addedNodes.length).toBe(1);
+ expect(addition?.addedNodes[0]).toBe(inserted);
+ expect(addition?.removedNodes.length).toBe(0);
+ expect(addition?.previousSibling).toBe(first);
+ expect(addition?.nextSibling).toBe(last);
+ expect(addition?.attributeName).toBeNull();
+ expect(addition?.attributeNamespace).toBeNull();
+ expect(addition?.oldValue).toBeNull();
+ expect(removal?.addedNodes.length).toBe(0);
+ expect(removal?.removedNodes.length).toBe(1);
+ expect(removal?.removedNodes[0]).toBe(inserted);
+ expect(removal?.previousSibling).toBe(first);
+ expect(removal?.nextSibling).toBe(last);
+ },
+ );
+
+ context.test('records sibling anchors and groups fragment insertion', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const o = new MutationObserver(() => {});
+ fixture.cleanup(() => o.disconnect());
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createElement('b'),
+ f = document.createDocumentFragment();
+ p.append(a, b);
+ o.observe(p as unknown as DOMNodeContract, { childList: true });
+ const x = document.createElement('u'),
+ y = document.createTextNode('y');
+ f.append(x, y);
+ p.insertBefore(f, b);
+ const records = o.takeRecords();
+ expect(records.length).toBe(1);
+ expect(Array.from(records[0]!.addedNodes)).toEqual([x, y]);
+ expect(records[0]!.previousSibling).toBe(a);
+ expect(records[0]!.nextSibling).toBe(b);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/reentrancy.ts b/packages/dom/src/compatibility/mutation-observer/reentrancy.ts
new file mode 100644
index 0000000..f2505c5
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/reentrancy.ts
@@ -0,0 +1,59 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const requires = ['mutationObserver.microtaskDelivery'] as const;
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'queues callback mutations for another notification',
+ async ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const deliveries: string[][] = [];
+ let inCallback = false;
+ let reentered = false;
+ const observer = new MutationObserver((records) => {
+ if (inCallback) reentered = true;
+ inCallback = true;
+ deliveries.push(
+ records.map((record) => {
+ return record.attributeName ?? '';
+ }),
+ );
+ if (deliveries.length === 1) target.setAttribute('title', 'second');
+ inCallback = false;
+ });
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributes: true });
+ target.setAttribute('id', 'first');
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(deliveries).toEqual([['id'], ['title']]);
+ expect(reentered).toBe(false);
+ },
+ );
+
+ context.test('delivers callback-generated mutations in a later notification', async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver'),
+ e = document.createElement('div'),
+ seen: number[] = [];
+ const o = new MutationObserver((records) => {
+ seen.push(records.length);
+ if (seen.length === 1) e.setAttribute('y', '2');
+ });
+ fixture.cleanup(() => o.disconnect());
+ o.observe(e as unknown as DOMNodeContract, { attributes: true });
+ e.setAttribute('x', '1');
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(seen).toEqual([1, 1]);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/registration.ts b/packages/dom/src/compatibility/mutation-observer/registration.ts
new file mode 100644
index 0000000..acdcdd5
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/registration.ts
@@ -0,0 +1,88 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'rejects empty or contradictory observation options',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ expect(() => observer.observe(target, {})).toThrow(
+ expect.objectContaining({ name: 'TypeError' }),
+ );
+ expect(() =>
+ observer.observe(target, { attributes: false, attributeOldValue: true }),
+ ).toThrow(expect.objectContaining({ name: 'TypeError' }));
+ expect(() => observer.observe(target, { attributes: false, attributeFilter: [] })).toThrow(
+ expect.objectContaining({ name: 'TypeError' }),
+ );
+ expect(() =>
+ observer.observe(target, { characterData: false, characterDataOldValue: true }),
+ ).toThrow(expect.objectContaining({ name: 'TypeError' }));
+ expect(() => observer.observe(target, { attributeOldValue: true })).not.toThrow();
+ expect(() => observer.observe(target, { characterDataOldValue: true })).not.toThrow();
+ },
+ );
+ context.test(
+ 'replaces options when the same observer observes a target again',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributes: true });
+ observer.observe(target, { childList: true });
+ target.setAttribute('title', 'unobserved');
+ const child = document.createElement('span');
+ target.appendChild(child);
+ const records = observer.takeRecords();
+ expect(records.length).toBe(1);
+ expect(records[0]?.type).toBe('childList');
+ expect(records[0]?.addedNodes[0]).toBe(child);
+ },
+ );
+
+ context.test(
+ 'requires a mutation type and rejects contradictory old-value options',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const observer = new MutationObserver(() => {});
+ fixture.cleanup(() => observer.disconnect());
+ const e = document.createElement('div');
+ for (const options of [
+ {},
+ { attributes: false, attributeOldValue: true },
+ { characterData: false, characterDataOldValue: true },
+ { attributes: false, attributeFilter: ['x'] },
+ ])
+ expect(() => observer.observe(e as unknown as DOMNodeContract, options)).toThrow(
+ expect.objectContaining({ name: 'TypeError' }),
+ );
+ },
+ );
+
+ context.test('implicitly enables attributes when only a filter is supplied', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const observer = new MutationObserver(() => {});
+ fixture.cleanup(() => observer.disconnect());
+ const e = document.createElement('div');
+ observer.observe(e as unknown as DOMNodeContract, { attributeFilter: ['x'] });
+ e.setAttribute('x', '1');
+ expect(observer.takeRecords().length).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/subtree.ts b/packages/dom/src/compatibility/mutation-observer/subtree.ts
new file mode 100644
index 0000000..b9af97a
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/subtree.ts
@@ -0,0 +1,51 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'observes descendants only when subtree is enabled and deduplicates overlapping registrations',
+ ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const root = document.createElement('div');
+ const child = document.createElement('span');
+ root.appendChild(child);
+ const observer = new MutationObserver(() => {});
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(root, { attributes: true });
+ child.setAttribute('title', 'unobserved');
+ expect(observer.takeRecords()).toEqual([]);
+ observer.observe(root, { attributes: true, subtree: true });
+ observer.observe(child, { attributes: true, attributeOldValue: true });
+ child.setAttribute('title', 'observed');
+ const records = observer.takeRecords();
+ expect(records.length).toBe(1);
+ expect(records[0]?.target).toBe(child);
+ expect(records[0]?.oldValue).toBe('unobserved');
+ },
+ );
+
+ context.test('does not include descendant mutations unless subtree is requested', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver'),
+ o = new MutationObserver(() => {});
+ fixture.cleanup(() => o.disconnect());
+ const p = document.createElement('div'),
+ child = document.createElement('i');
+ p.append(child);
+ o.observe(p as unknown as DOMNodeContract, { attributes: true });
+ child.setAttribute('x', '1');
+ expect(o.takeRecords()).toEqual([]);
+ o.observe(p as unknown as DOMNodeContract, { attributes: true, subtree: true });
+ child.setAttribute('x', '2');
+ expect(o.takeRecords()[0]!.target).toBe(child);
+ });
+}
diff --git a/packages/dom/src/compatibility/mutation-observer/take-records.ts b/packages/dom/src/compatibility/mutation-observer/take-records.ts
new file mode 100644
index 0000000..f942f14
--- /dev/null
+++ b/packages/dom/src/compatibility/mutation-observer/take-records.ts
@@ -0,0 +1,54 @@
+import type { DOMNode as DOMNodeContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+const requiredGlobals = ['MutationObserver'] as const;
+export { requiredGlobals as globals };
+export const spec = 'https://dom.spec.whatwg.org/#mutation-observers';
+
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+
+ context.test(
+ 'drains pending records without invoking the callback',
+ async ({ document, globals, cleanup }) => {
+ const MutationObserver = globals('MutationObserver');
+ const target = document.createElement('div');
+ let callbacks = 0;
+ const observer = new MutationObserver(() => {
+ callbacks++;
+ });
+ cleanup(() => {
+ observer.disconnect();
+ });
+ observer.observe(target, { attributes: true });
+ target.setAttribute('id', 'one');
+ const records = observer.takeRecords();
+ expect(records.length).toBe(1);
+ expect(records[0]?.attributeName).toBe('id');
+ expect(observer.takeRecords()).toEqual([]);
+ await Promise.resolve();
+ expect(callbacks).toBe(0);
+ },
+ );
+
+ context.test('drains only the calling observer and preserves record identity', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals('MutationObserver');
+ const a = new MutationObserver(() => {}),
+ b = new MutationObserver(() => {});
+ fixture.cleanup(() => {
+ a.disconnect();
+ b.disconnect();
+ });
+ const e = document.createElement('div');
+ a.observe(e as unknown as DOMNodeContract, { attributes: true });
+ b.observe(e as unknown as DOMNodeContract, { attributes: true });
+ e.setAttribute('x', '1');
+ const first = a.takeRecords();
+ expect(first.length).toBe(1);
+ expect(a.takeRecords()).toEqual([]);
+ expect(b.takeRecords().length).toBe(1);
+ expect(first[0]!.target).toBe(e);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/adoption.ts b/packages/dom/src/compatibility/node/adoption.ts
new file mode 100644
index 0000000..d71bf1c
--- /dev/null
+++ b/packages/dom/src/compatibility/node/adoption.ts
@@ -0,0 +1,51 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#concept-node-adopt';
+const requiredGlobals = ['Document'] as const;
+export { requiredGlobals as globals };
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'adopts descendants and attributes while preserving their identity',
+ ({ document, globals }) => {
+ const other = new (globals('Document'))();
+ const parent = document.createElement('div');
+ const child = document.createElement('span');
+ const text = document.createTextNode('kept');
+ child.setAttribute('title', 'value');
+ const attr = child.getAttributeNode('title');
+ child.appendChild(text);
+ parent.appendChild(child);
+ expect(other.adoptNode(child)).toBe(child);
+ expect(child.parentNode).toBeNull();
+ expect(child.ownerDocument).toBe(other);
+ expect(text.ownerDocument).toBe(other);
+ expect(attr?.ownerDocument).toBe(other);
+ expect(attr?.ownerElement).toBe(child);
+ expect(child.firstChild).toBe(text);
+ },
+ );
+
+ context.test('adopts descendants and attributes while preserving identity', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const other = document.implementation.createHTMLDocument('');
+ const p = other.createElement('div'),
+ child = other.createElement('i');
+ p.setAttribute('title', 'x');
+ p.append(child);
+ other.body.append(p);
+ expect(document.adoptNode(p)).toBe(p);
+ expect(p.parentNode).toBeNull();
+ expect(child.ownerDocument).toBe(document);
+ expect(p.getAttributeNode('title')!.ownerDocument).toBe(document);
+ });
+
+ context.test('rejects Document adoption without changing ownership', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ expect(() => document.adoptNode(document)).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ expect(document.ownerDocument).toBeNull();
+ });
+}
diff --git a/packages/dom/src/compatibility/node/append.ts b/packages/dom/src/compatibility/node/append.ts
new file mode 100644
index 0000000..1bf8784
--- /dev/null
+++ b/packages/dom/src/compatibility/node/append.ts
@@ -0,0 +1,57 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#concept-node-pre-insert';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('moves existing nodes and rejects cycles before changing a tree', ({ document }) => {
+ const parent = document.createElement('div');
+ const other = document.createElement('div');
+ const child = document.createElement('span');
+ const text = document.createTextNode('state');
+ child.appendChild(text);
+ parent.appendChild(child);
+ expect(other.appendChild(child)).toBe(child);
+ expect(parent.childNodes.length).toBe(0);
+ expect(child.firstChild).toBe(text);
+ expect(other.appendChild(child)).toBe(child);
+ expect(other.childNodes.length).toBe(1);
+ expect(() => child.appendChild(other)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(() => child.appendChild(child)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(child.parentNode).toBe(other);
+ expect(child.firstChild).toBe(text);
+ });
+
+ context.test('moves a same-parent node to the end without duplication', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createElement('b');
+ p.appendChild(a);
+ p.appendChild(b);
+ p.appendChild(a);
+ expect(Array.from(p.childNodes)).toEqual([b, a]);
+ expect(a.previousSibling).toBe(b);
+ expect(b.nextSibling).toBe(a);
+ });
+
+ context.test(
+ 'rejects a second document element and text under Document atomically',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const other = document.implementation.createHTMLDocument('');
+ const before = Array.from(other.childNodes);
+ expect(() => other.appendChild(other.createElement('i'))).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(() => other.appendChild(other.createTextNode('x'))).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(Array.from(other.childNodes)).toEqual(before);
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/node/basic-append.ts b/packages/dom/src/compatibility/node/basic-append.ts
new file mode 100644
index 0000000..8776dcb
--- /dev/null
+++ b/packages/dom/src/compatibility/node/basic-append.ts
@@ -0,0 +1,25 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-node-appendchild';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('returns the appended node and preserves insertion order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createTextNode('a'),
+ b = document.createElement('span');
+ expect(p.appendChild(a)).toBe(a);
+ expect(p.appendChild(b)).toBe(b);
+ expect(Array.from(p.childNodes)).toEqual([a, b]);
+ });
+ context.test('keeps nested child identity and permits detached tree construction', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ child = document.createElement('span'),
+ t = document.createTextNode('x');
+ child.appendChild(t);
+ p.appendChild(child);
+ expect(p.childNodes[0]).toBe(child);
+ expect(child.childNodes[0]).toBe(t);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/cloning.ts b/packages/dom/src/compatibility/node/cloning.ts
new file mode 100644
index 0000000..908524d
--- /dev/null
+++ b/packages/dom/src/compatibility/node/cloning.ts
@@ -0,0 +1,62 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#concept-node-clone';
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['events.dispatch.target'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'copies descendants without sharing identity or registered listeners',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const source = document.createElement('div');
+ const text = document.createTextNode('one');
+ source.appendChild(text);
+ let called = 0;
+ source.addEventListener('probe', () => {
+ called++;
+ });
+ const shallow = source.cloneNode();
+ const deep = source.cloneNode(true);
+ expect(shallow.childNodes.length).toBe(0);
+ expect(deep.firstChild).not.toBe(text);
+ expect(deep.textContent).toBe('one');
+ expect(deep.parentNode).toBeNull();
+ expect(deep.ownerDocument).toBe(document);
+ deep.dispatchEvent(new Event('probe'));
+ expect(called).toBe(0);
+ source.dispatchEvent(new Event('probe'));
+ expect(called).toBe(1);
+ },
+ );
+
+ context.test('shallow clones copy attributes independently without children', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.setAttribute('title', 'before');
+ p.append('text');
+ const copy = p.cloneNode() as Element;
+ expect(copy).not.toBe(p);
+ expect(copy.ownerDocument).toBe(document);
+ expect(copy.parentNode).toBeNull();
+ expect(copy.childNodes.length).toBe(0);
+ expect(copy.getAttribute('title')).toBe('before');
+ copy.setAttribute('title', 'after');
+ expect(p.getAttribute('title')).toBe('before');
+ });
+
+ context.test('does not copy registered event listeners', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ let count = 0;
+ p.addEventListener('x', () => count++);
+ const copy = p.cloneNode(true);
+ const Event = fixture.globals('Event');
+ copy.dispatchEvent(new Event('x') as unknown as Event);
+ expect(count).toBe(0);
+ p.dispatchEvent(new Event('x') as unknown as Event);
+ expect(count).toBe(1);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/comparison.ts b/packages/dom/src/compatibility/node/comparison.ts
new file mode 100644
index 0000000..15de413
--- /dev/null
+++ b/packages/dom/src/compatibility/node/comparison.ts
@@ -0,0 +1,44 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-node-comparedocumentposition';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('compares identity, ancestors, and ordered siblings', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('a'),
+ b = document.createElement('b');
+ p.append(a, b);
+ expect(a.compareDocumentPosition(a)).toBe(0);
+ expect(a.compareDocumentPosition(b)).toBe(4);
+ expect(b.compareDocumentPosition(a)).toBe(2);
+ expect(p.compareDocumentPosition(a)).toBe(20);
+ expect(a.compareDocumentPosition(p)).toBe(10);
+ expect(p.contains(a)).toBe(true);
+ expect(a.contains(null)).toBe(false);
+ });
+ context.test('distinguishes structural equality from identity', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createElement('div');
+ a.setAttribute('title', 'x');
+ a.append('text');
+ const b = a.cloneNode(true);
+ expect(a.isEqualNode(b)).toBe(true);
+ expect(a.isSameNode(b)).toBe(false);
+ (b as Element).setAttribute('title', 'y');
+ expect(a.isEqualNode(b)).toBe(false);
+ });
+ context.test(
+ 'reports disconnected nodes symmetrically with implementation-specific order',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createElement('i'),
+ b = document.createElement('i');
+ const ab = a.compareDocumentPosition(b),
+ ba = b.compareDocumentPosition(a);
+ expect(ab & 1).toBe(1);
+ expect(ab & 32).toBe(32);
+ expect(Boolean(ab & 2)).toBe(Boolean(ba & 4));
+ },
+ );
+}
diff --git a/packages/dom/src/compatibility/node/connectivity.ts b/packages/dom/src/compatibility/node/connectivity.ts
new file mode 100644
index 0000000..5f1b9da
--- /dev/null
+++ b/packages/dom/src/compatibility/node/connectivity.ts
@@ -0,0 +1,35 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-node-isconnected';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('updates connectivity on attachment and removal', ({ document, connect }) => {
+ const parent = document.createElement('div');
+ const child = document.createTextNode('child');
+ parent.appendChild(child);
+ expect(parent.isConnected).toBe(false);
+ expect(child.getRootNode()).toBe(parent);
+ connect(parent);
+ expect(child.isConnected).toBe(true);
+ expect(child.getRootNode()).toBe(document);
+ parent.remove();
+ expect(child.isConnected).toBe(false);
+ expect(child.getRootNode()).toBe(parent);
+ });
+
+ context.test('updates connected state recursively on insertion and removal', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ child = document.createElement('i');
+ p.append(child);
+ expect(child.isConnected).toBe(false);
+ fixture.connect(p as unknown as DOMElementContract);
+ expect(child.isConnected).toBe(true);
+ expect(child.getRootNode()).toBe(document);
+ p.remove();
+ expect(child.isConnected).toBe(false);
+ expect(child.getRootNode()).toBe(p);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/fragments.ts b/packages/dom/src/compatibility/node/fragments.ts
new file mode 100644
index 0000000..8e37cad
--- /dev/null
+++ b/packages/dom/src/compatibility/node/fragments.ts
@@ -0,0 +1,66 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#concept-node-ensure-pre-insertion-validity';
+const requiredGlobals = ['Document'] as const;
+export { requiredGlobals as globals };
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'transfers fragment children and validates document insertion before moving them',
+ ({ document, globals }) => {
+ const fragment = document.createDocumentFragment();
+ const a = document.createElement('div');
+ const b = document.createElement('span');
+ fragment.append(a, b);
+ const target = document.createElement('section');
+ expect(target.appendChild(fragment)).toBe(fragment);
+ expect(fragment.childNodes.length).toBe(0);
+ expect(target.childNodes[0]).toBe(a);
+ expect(target.childNodes[1]).toBe(b);
+ fragment.append(a, b);
+ const empty = new (globals('Document'))();
+ expect(() => empty.appendChild(fragment)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(fragment.childNodes[0]).toBe(a);
+ expect(fragment.childNodes[1]).toBe(b);
+ expect(empty.childNodes.length).toBe(0);
+ for (const leaf of [
+ document.createTextNode('text'),
+ document.createComment('comment'),
+ document.createAttribute('title'),
+ ]) {
+ expect(() => leaf.appendChild(a)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(a.parentNode).toBe(fragment);
+ }
+ },
+ );
+
+ context.test('empties inserted fragments and preserves their child order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const f = document.createDocumentFragment(),
+ p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createTextNode('b');
+ f.append(a, b);
+ expect(p.appendChild(f)).toBe(f);
+ expect(f.childNodes.length).toBe(0);
+ expect(Array.from(p.childNodes)).toEqual([a, b]);
+ expect(a.parentNode).toBe(p);
+ });
+
+ context.test('rejects invalid document fragments before removing any child', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const other = document.implementation.createDocument(null, 'root'),
+ f = document.createDocumentFragment();
+ f.append(document.createElement('a'), document.createElement('b'));
+ expect(() => other.replaceChild(f, other.documentElement)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(f.childNodes.length).toBe(2);
+ expect(other.documentElement.localName).toBe('root');
+ });
+}
diff --git a/packages/dom/src/compatibility/node/insert-before.ts b/packages/dom/src/compatibility/node/insert-before.ts
new file mode 100644
index 0000000..20f98d3
--- /dev/null
+++ b/packages/dom/src/compatibility/node/insert-before.ts
@@ -0,0 +1,58 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-node-insertbefore';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'supports null anchors and reorders in both directions atomically',
+ ({ document }) => {
+ const parent = document.createElement('div');
+ const a = document.createElement('a');
+ const b = document.createElement('b');
+ const c = document.createElement('i');
+ parent.appendChild(a);
+ parent.appendChild(b);
+ parent.appendChild(c);
+ parent.insertBefore(c, a);
+ expect(parent.firstChild).toBe(c);
+ expect(c.nextSibling).toBe(a);
+ parent.insertBefore(c, null);
+ expect(parent.lastChild).toBe(c);
+ parent.insertBefore(a, a);
+ expect(parent.firstChild).toBe(a);
+ const outside = document.createElement('span');
+ expect(() => outside.insertBefore(a, b)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ expect(a.parentNode).toBe(parent);
+ expect(outside.childNodes.length).toBe(0);
+ },
+ );
+
+ context.test('treats self insertion as a no-op and null as append', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createElement('b');
+ p.appendChild(a);
+ expect(p.insertBefore(a, a)).toBe(a);
+ expect(Array.from(p.childNodes)).toEqual([a]);
+ p.insertBefore(b, null);
+ expect(Array.from(p.childNodes)).toEqual([a, b]);
+ });
+
+ context.test('rejects a foreign anchor without detaching the candidate', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ other = document.createElement('div'),
+ a = document.createElement('i'),
+ foreign = document.createElement('b');
+ other.appendChild(a);
+ expect(() => p.insertBefore(a, foreign)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ expect(a.parentNode).toBe(other);
+ expect(p.childNodes.length).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/live-collections.ts b/packages/dom/src/compatibility/node/live-collections.ts
new file mode 100644
index 0000000..abf0586
--- /dev/null
+++ b/packages/dom/src/compatibility/node/live-collections.ts
@@ -0,0 +1,58 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#old-style-collections';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('retains live childNodes and children views across mutations', ({ document }) => {
+ const parent = document.createElement('div');
+ const nodes = parent.childNodes;
+ const elements = parent.children;
+ const child = document.createElement('span');
+ const text = document.createTextNode('text');
+ parent.append(text, child);
+ expect(nodes.length).toBe(2);
+ expect(nodes[0]).toBe(text);
+ expect(elements.length).toBe(1);
+ expect(elements[0]).toBe(child);
+ child.remove();
+ expect(nodes.length).toBe(1);
+ expect(elements.length).toBe(0);
+ });
+
+ context.test(
+ 'keeps childNodes live and supplies standard item and iteration methods',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ nodes = p.childNodes,
+ a = document.createTextNode('a');
+ expect(nodes.item(0)).toBeNull();
+ p.append(a);
+ expect(nodes.item(0)).toBe(a);
+ expect([...nodes.values()]).toEqual([a]);
+ expect([...nodes.keys()]).toEqual([0]);
+ expect([...nodes.entries()]).toEqual([[0, a]]);
+ const seen: unknown[] = [];
+ nodes.forEach((n, i, list) => seen.push([n, i, list]));
+ expect(seen).toEqual([[a, 0, nodes]]);
+ a.remove();
+ expect(nodes.length).toBe(0);
+ },
+ );
+
+ context.test('updates HTMLCollection named lookups after name and id changes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('input');
+ const children = p.children;
+ p.append(e);
+ e.id = 'first';
+ expect(children.namedItem('first')).toBe(e);
+ e.id = 'second';
+ expect(children.namedItem('first')).toBeNull();
+ expect(children.namedItem('second')).toBe(e);
+ e.remove();
+ expect(children.item(0)).toBeNull();
+ });
+}
diff --git a/packages/dom/src/compatibility/node/normalization.ts b/packages/dom/src/compatibility/node/normalization.ts
new file mode 100644
index 0000000..2e77cf6
--- /dev/null
+++ b/packages/dom/src/compatibility/node/normalization.ts
@@ -0,0 +1,33 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-node-normalize';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('merges adjacent text and removes empties without crossing comments', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createTextNode('a'),
+ empty = document.createTextNode(''),
+ b = document.createTextNode('b'),
+ c = document.createComment('c');
+ p.append(empty, a, b, c, 'd', 'e');
+ p.normalize();
+ expect(Array.from(p.childNodes).map((n) => n.nodeValue)).toEqual(['ab', 'c', 'de']);
+ expect(p.firstChild).toBe(a);
+ expect(empty.parentNode).toBeNull();
+ expect(b.parentNode).toBeNull();
+ p.normalize();
+ expect(p.childNodes.length).toBe(3);
+ });
+ context.test('normalizes descendants while retaining element boundaries', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ child = document.createElement('i');
+ child.append('a', '', 'b');
+ p.append('x', child, 'y');
+ p.normalize();
+ expect(child.childNodes.length).toBe(1);
+ expect(child.textContent).toBe('ab');
+ expect(p.childNodes.length).toBe(3);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/relationships.ts b/packages/dom/src/compatibility/node/relationships.ts
new file mode 100644
index 0000000..43b5b09
--- /dev/null
+++ b/packages/dom/src/compatibility/node/relationships.ts
@@ -0,0 +1,43 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-node';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('maintains parent child and sibling identity', ({ document }) => {
+ const parent = document.createElement('div');
+ const a = document.createElement('span');
+ const b = document.createTextNode('b');
+ parent.appendChild(a);
+ parent.appendChild(b);
+ expect(a.parentNode).toBe(parent);
+ expect(b.parentElement).toBe(parent);
+ expect(parent.firstChild).toBe(a);
+ expect(parent.lastChild).toBe(b);
+ expect(a.nextSibling).toBe(b);
+ expect(b.previousSibling).toBe(a);
+ expect(a.previousSibling).toBeNull();
+ expect(b.nextSibling).toBeNull();
+ expect(parent.contains(b)).toBe(true);
+ expect(a.contains(b)).toBe(false);
+ expect(parent.contains(parent)).toBe(true);
+ expect(parent.hasChildNodes()).toBe(true);
+ });
+
+ context.test('tracks first, last and sibling links after reordering', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createTextNode('b'),
+ c = document.createElement('u');
+ p.append(a, b, c);
+ expect(p.firstChild).toBe(a);
+ expect(p.lastChild).toBe(c);
+ expect(b.previousSibling).toBe(a);
+ expect(b.nextSibling).toBe(c);
+ p.insertBefore(c, a);
+ expect(c.previousSibling).toBeNull();
+ expect(a.previousSibling).toBe(c);
+ expect(b.nextSibling).toBeNull();
+ });
+}
diff --git a/packages/dom/src/compatibility/node/remove.ts b/packages/dom/src/compatibility/node/remove.ts
new file mode 100644
index 0000000..f63dcb6
--- /dev/null
+++ b/packages/dom/src/compatibility/node/remove.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-childnode-remove';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'distinguishes harmless ChildNode.remove from wrong-parent removeChild',
+ ({ document }) => {
+ const parent = document.createElement('div');
+ const other = document.createElement('div');
+ const child = document.createElement('span');
+ parent.appendChild(child);
+ expect(() => other.removeChild(child)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ expect(child.parentNode).toBe(parent);
+ expect(parent.removeChild(child)).toBe(child);
+ expect(child.parentNode).toBeNull();
+ child.remove();
+ child.remove();
+ expect(parent.childNodes.length).toBe(0);
+ },
+ );
+
+ context.test('throws on removing an absent child and detaches sibling links', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createElement('b');
+ p.append(a, b);
+ expect(p.removeChild(a)).toBe(a);
+ expect(a.parentNode).toBeNull();
+ expect(a.previousSibling).toBeNull();
+ expect(a.nextSibling).toBeNull();
+ expect(b.previousSibling).toBeNull();
+ expect(() => p.removeChild(a)).toThrow(expect.objectContaining({ name: 'NotFoundError' }));
+ expect(p.firstChild).toBe(b);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/replace.ts b/packages/dom/src/compatibility/node/replace.ts
new file mode 100644
index 0000000..d173a9b
--- /dev/null
+++ b/packages/dom/src/compatibility/node/replace.ts
@@ -0,0 +1,63 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#concept-node-replace';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'returns the removed node and preserves invariants on invalid replacements',
+ ({ document }) => {
+ const parent = document.createElement('div');
+ const a = document.createElement('span');
+ const b = document.createElement('b');
+ parent.appendChild(a);
+ parent.appendChild(b);
+ expect(parent.replaceChild(b, a)).toBe(a);
+ expect(parent.childNodes.length).toBe(1);
+ expect(parent.firstChild).toBe(b);
+ expect(a.parentNode).toBeNull();
+ expect(parent.replaceChild(b, b)).toBe(b);
+ expect(() => parent.replaceChild(parent, b)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(parent.firstChild).toBe(b);
+ },
+ );
+
+ context.test('replacing a child with itself preserves identity and order', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i');
+ p.appendChild(a);
+ expect(p.replaceChild(a, a)).toBe(a);
+ expect(p.firstChild).toBe(a);
+ expect(a.parentNode).toBe(p);
+ });
+
+ context.test('replaces using an existing sibling without duplicate references', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createElement('b'),
+ c = document.createElement('u');
+ p.append(a, b, c);
+ expect(p.replaceChild(c, a)).toBe(a);
+ expect(Array.from(p.childNodes)).toEqual([c, b]);
+ expect(a.parentNode).toBeNull();
+ });
+
+ context.test('rejects missing children and cycles before changing either tree', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ other = document.createElement('b');
+ p.appendChild(a);
+ expect(() => p.replaceChild(other, other)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ expect(() => p.replaceChild(p, a)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(p.firstChild).toBe(a);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/text-content.ts b/packages/dom/src/compatibility/node/text-content.ts
new file mode 100644
index 0000000..2c3d1c8
--- /dev/null
+++ b/packages/dom/src/compatibility/node/text-content.ts
@@ -0,0 +1,50 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#dom-node-textcontent';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'excludes comments and replaces children while document textContent stays null',
+ ({ document }) => {
+ const parent = document.createElement('div');
+ const text = document.createTextNode('one');
+ const span = document.createElement('span');
+ span.textContent = 'two';
+ parent.append(text, document.createComment('ignored'), span);
+ expect(parent.textContent).toBe('onetwo');
+ parent.textContent = 'replacement';
+ expect(parent.childNodes.length).toBe(1);
+ expect(parent.firstChild?.nodeType).toBe(3);
+ expect(text.parentNode).toBeNull();
+ parent.textContent = null;
+ expect(parent.childNodes.length).toBe(0);
+ expect(parent.textContent).toBe('');
+ expect(document.textContent).toBeNull();
+ },
+ );
+
+ context.test('excludes comments and replaces descendants with one text node', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.append('a', document.createComment('ignored'), document.createElement('i'));
+ p.lastChild!.textContent = 'b';
+ expect(p.textContent).toBe('ab');
+ const old = p.firstChild!;
+ p.textContent = 'next';
+ expect(p.childNodes.length).toBe(1);
+ expect(p.textContent).toBe('next');
+ expect(old.parentNode).toBeNull();
+ p.textContent = null;
+ expect(p.childNodes.length).toBe(0);
+ });
+
+ context.test('keeps Document textContent null with no setter mutation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const before = Array.from(document.childNodes);
+ expect(document.textContent).toBeNull();
+ Reflect.set(document, 'textContent', 'ignored');
+ expect(document.textContent).toBeNull();
+ expect(Array.from(document.childNodes)).toEqual(before);
+ });
+}
diff --git a/packages/dom/src/compatibility/node/variadic-mutation.ts b/packages/dom/src/compatibility/node/variadic-mutation.ts
new file mode 100644
index 0000000..57cb334
--- /dev/null
+++ b/packages/dom/src/compatibility/node/variadic-mutation.ts
@@ -0,0 +1,56 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-parentnode';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'converts strings and preserves argument order when reusing siblings',
+ ({ document }) => {
+ const parent = document.createElement('div');
+ const a = document.createElement('span');
+ const b = document.createElement('b');
+ const c = document.createElement('i');
+ parent.append(a, b, c);
+ parent.prepend(a, c);
+ expect(parent.childNodes[0]).toBe(a);
+ expect(parent.childNodes[1]).toBe(c);
+ expect(parent.childNodes[2]).toBe(b);
+ c.before('before');
+ c.after('after');
+ expect(c.previousSibling?.nodeValue).toBe('before');
+ expect(c.nextSibling?.nodeValue).toBe('after');
+ c.replaceWith(c, 'tail');
+ expect(c.parentNode).toBe(parent);
+ expect(c.nextSibling?.nodeValue).toBe('tail');
+ parent.replaceChildren('one', a, 'two');
+ expect(parent.textContent).toBe('onetwo');
+ expect(parent.childNodes[1]).toBe(a);
+ },
+ );
+
+ context.test('supports no-argument operations and string conversion', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.append();
+ expect(p.childNodes.length).toBe(0);
+ p.append('a', 'b');
+ expect(p.childNodes.length).toBe(2);
+ p.prepend('first');
+ expect(p.textContent).toBe('firstab');
+ p.replaceChildren();
+ expect(p.childNodes.length).toBe(0);
+ });
+
+ context.test('handles self references in before and after operations', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ a = document.createElement('i'),
+ b = document.createElement('b');
+ p.append(a, b);
+ a.after(a, 'x');
+ expect(Array.from(p.childNodes).map((n) => n.nodeName)).toEqual(['I', '#text', 'B']);
+ b.before(b);
+ expect(p.lastChild).toBe(b);
+ });
+}
diff --git a/packages/dom/src/compatibility/parsing/contextual.ts b/packages/dom/src/compatibility/parsing/contextual.ts
new file mode 100644
index 0000000..be20354
--- /dev/null
+++ b/packages/dom/src/compatibility/parsing/contextual.ts
@@ -0,0 +1,33 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-range-createcontextualfragment';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('uses table context for contextual fragments', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const table = document.createElement('table');
+ const range = document.createRange();
+ range.selectNodeContents(table);
+ const fragment = range.createContextualFragment('| x |
');
+ expect((fragment.firstChild as Element).localName).toBe('tbody');
+ expect(fragment.querySelector('td')!.textContent).toBe('x');
+ expect(table.childNodes.length).toBe(0);
+ });
+ context.test('parses raw text and entities according to context', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('textarea');
+ e.innerHTML = '<b>&';
+ expect(e.textContent).toBe('&');
+ const script = document.createElement('script');
+ script.innerHTML = 'if (a < b) x="&";';
+ expect(script.textContent).toBe('if (a < b) x="&";');
+ });
+ context.test('keeps scripts inert during markup replacement', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.innerHTML = 'ok';
+ expect(e.lastChild!.textContent).toBe('ok');
+ });
+}
diff --git a/packages/dom/src/compatibility/parsing/fragments.ts b/packages/dom/src/compatibility/parsing/fragments.ts
new file mode 100644
index 0000000..cf553e0
--- /dev/null
+++ b/packages/dom/src/compatibility/parsing/fragments.ts
@@ -0,0 +1,37 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/parsing.html#html-fragment-parsing-algorithm';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('parses markup and references into real fragment children', ({ document }) => {
+ const container = document.createElement('div');
+ container.innerHTML = 'one&two';
+ expect(container.childNodes.length).toBe(2);
+ expect(container.firstElementChild?.localName).toBe('span');
+ expect(container.firstElementChild?.getAttribute('title')).toBe('a&b');
+ expect(container.firstElementChild?.textContent).toBe('one&two');
+ expect(container.lastChild?.nodeType).toBe(8);
+ });
+
+ context.test('repairs table structure and decodes numeric character references', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('table');
+ e.innerHTML = '| A😀 |
';
+ expect(e.firstElementChild!.localName).toBe('tbody');
+ expect(e.querySelector('td')!.textContent).toBe('A😀');
+ });
+
+ context.test('innerHTML replacement detaches previous descendants', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.innerHTML = 'a';
+ const old = e.firstChild!;
+ e.innerHTML = 'b';
+ expect(old.parentNode).toBeNull();
+ expect(e.firstElementChild!.localName).toBe('b');
+ e.innerHTML = '';
+ expect(e.childNodes.length).toBe(0);
+ });
+}
diff --git a/packages/dom/src/compatibility/parsing/markup-mutation.ts b/packages/dom/src/compatibility/parsing/markup-mutation.ts
new file mode 100644
index 0000000..eb73ded
--- /dev/null
+++ b/packages/dom/src/compatibility/parsing/markup-mutation.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-element-insertadjacenthtml';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('inserts markup in all four adjacent positions', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ e.insertAdjacentHTML('beforebegin', '');
+ e.insertAdjacentHTML('afterbegin', '');
+ e.insertAdjacentHTML('beforeend', '');
+ e.insertAdjacentHTML('afterend', '');
+ expect(Array.from(p.children).map((n) => n.localName)).toEqual(['a', 'i', 'u']);
+ expect(Array.from(e.children).map((n) => n.localName)).toEqual(['b', 'em']);
+ });
+ context.test('replaces outerHTML without changing the removed node', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ e.textContent = 'old';
+ p.append(e);
+ e.outerHTML = 'new';
+ expect(e.parentNode).toBeNull();
+ expect(e.textContent).toBe('old');
+ expect(p.children.length).toBe(2);
+ expect(p.firstChild!.textContent).toBe('new');
+ });
+ context.test('rejects invalid insertion positions without mutating', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.innerHTML = '';
+ expect(() => e.insertAdjacentHTML('invalid' as InsertPosition, 'x')).toThrow(
+ expect.objectContaining({ name: 'SyntaxError' }),
+ );
+ expect(e.innerHTML).toBe('');
+ });
+}
diff --git a/packages/dom/src/compatibility/parsing/namespaces.ts b/packages/dom/src/compatibility/parsing/namespaces.ts
new file mode 100644
index 0000000..421f206
--- /dev/null
+++ b/packages/dom/src/compatibility/parsing/namespaces.ts
@@ -0,0 +1,26 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inforeign';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('uses HTML SVG and MathML namespaces during fragment parsing', ({ document }) => {
+ const container = document.createElement('div');
+ container.innerHTML = '';
+ expect(container.children[0]?.namespaceURI).toBe('http://www.w3.org/2000/svg');
+ expect(container.children[0]?.firstElementChild?.localName).toBe('linearGradient');
+ expect(container.children[1]?.namespaceURI).toBe('http://www.w3.org/1998/Math/MathML');
+ });
+
+ context.test('switches into HTML at SVG foreignObject integration points', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.innerHTML = '';
+ const svg = e.firstElementChild!,
+ foreign = svg.firstElementChild!,
+ div = foreign.firstElementChild!;
+ expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg');
+ expect(foreign.localName).toBe('foreignObject');
+ expect(div.namespaceURI).toBe('http://www.w3.org/1999/xhtml');
+ });
+}
diff --git a/packages/dom/src/compatibility/parsing/recovery.ts b/packages/dom/src/compatibility/parsing/recovery.ts
new file mode 100644
index 0000000..21a3f63
--- /dev/null
+++ b/packages/dom/src/compatibility/parsing/recovery.ts
@@ -0,0 +1,97 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://html.spec.whatwg.org/multipage/parsing.html#tree-construction';
+export const requires = ['parsing.fragments', 'document.parsing', 'templates.parsing'] as const;
+const requiredGlobals = ['DOMParser'] as const;
+export { requiredGlobals as globals };
+// Adapted from WPT html/syntax/parsing/resources/{adoption01,tables01}.dat.
+// Copyright © web-platform-tests contributors (BSD-3-Clause).
+// Copyright (c) 2006-2013 James Graham, Geoffrey Sneddon, and other contributors (MIT).
+// See ../../../test/wpt/{LICENSE,HTML5LIB-LICENSE}.txt and docs/wpt-ranges-parsing.md.
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/syntax/parsing/resources/adoption01.dat';
+
+const recoveryCases = [
+ {
+ name: 'reconstructs formatting across misnested closing tags',
+ markup: '123',
+ expected: '123',
+ },
+ {
+ name: 'splits formatting around a paragraph without losing reconstructed ancestry',
+ markup: '
',
+ expected: '
',
+ },
+ {
+ name: 'preserves attributes when reconstructing multiple formatting elements',
+ markup: '123
45',
+ expected: '123
45',
+ },
+ {
+ name: 'foster parents table text in order and inserts missing row containers',
+ markup: '',
+ expected: 'AC',
+ },
+ {
+ name: 'foster parents a select and retains its option contents',
+ markup: '',
+ expected: '',
+ },
+ {
+ name: 'closes a button and its table cell when a new cell begins',
+ markup: ' |