diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 9ed201d..44a3008 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -41,3 +41,16 @@ jobs:
- uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
- run: pnpm install --frozen-lockfile
- run: pnpm test
+
+ dom-wpt:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter @bomb.sh/web-platform-tests typecheck
+ - run: pnpm --filter @bomb.sh/web-platform-tests exec playwright install --with-deps chromium
+ - run: pnpm --filter @bomb.sh/web-platform-tests test:wpt-selectors
+ - run: pnpm --filter @bomb.sh/web-platform-tests test:browser:input
diff --git a/knip.config.ts b/knip.config.ts
new file mode 100644
index 0000000..4a9ed76
--- /dev/null
+++ b/knip.config.ts
@@ -0,0 +1,10 @@
+import webPlatformTests from './packages/web-platform-tests/knip.json' with { type: 'json' };
+
+export default {
+ workspaces: {
+ 'packages/web-platform-tests': {
+ entry: webPlatformTests.entry,
+ project: webPlatformTests.project,
+ },
+ },
+};
diff --git a/oxlint.config.ts b/oxlint.config.ts
index 07d583e..a3d2bca 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/web-platform-tests/src/**/*.ts',
+ 'packages/web-platform-tests/test/browser/*.ts',
+ 'packages/web-platform-tests/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..cf51ceb 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/web-platform-tests 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/web-platform-tests build && bsh test"
},
"devDependencies": {
"@bomb.sh/tools": "^0.8.0",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 6e27ec6..5e696f1 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -33,7 +33,7 @@
"build": "bsh build --dts",
"format": "bsh format",
"lint": "bsh lint",
- "test": "bsh test"
+ "test": "pnpm --filter @bomb.sh/web-platform-tests build && bsh test"
},
"dependencies": {
"@bomb.sh/tty": "catalog:"
@@ -49,5 +49,8 @@
"version": "^22.14.0",
"onFail": "error"
}
+ },
+ "devDependencies": {
+ "@bomb.sh/web-platform-tests": "workspace:*"
}
}
diff --git a/packages/ui/src/dom.compatibility.test.ts b/packages/ui/src/dom.compatibility.test.ts
new file mode 100644
index 0000000..a972738
--- /dev/null
+++ b/packages/ui/src/dom.compatibility.test.ts
@@ -0,0 +1,11 @@
+import { describe, it, expect } from 'vitest';
+import { testDOMCompatibility } from '@bomb.sh/web-platform-tests';
+import { Document, Element, Node, Text } from './dom.ts';
+import { domFeatures } from '../test/dom-features.ts';
+
+testDOMCompatibility({
+ runner: { describe, it, expect },
+ features: domFeatures,
+ create: () => new Document(() => {}),
+ globals: { Document, Element, Node, Text },
+});
diff --git a/packages/ui/test/README.md b/packages/ui/test/README.md
new file mode 100644
index 0000000..50a19e0
--- /dev/null
+++ b/packages/ui/test/README.md
@@ -0,0 +1,42 @@
+# UI tests
+
+Tests live alongside the implementation in `src/`; this directory contains their shared fixtures and the DOM feature manifest. Run the UI tests from the workspace root:
+
+```sh
+pnpm --filter @clack/ui test
+```
+
+The test script builds the private `@bomb.sh/web-platform-tests` package before running Vitest. Use `pnpm test` from the workspace root to run the workspace tests.
+
+## Terminal fixtures
+
+[`createTestUI`](ui.ts) starts the real UI with an in-process [Termless terminal](terminal.ts). It supplies application-facing input/output streams and exposes the document and terminal screen. Use `await using` to dispose the UI and terminal together, and `expect.poll` to wait for rendered output, as in [`ui.test.ts`](../src/ui.test.ts).
+
+[`createTestTerminal`](terminal.ts) can also be used independently. Its `type` and `press` helpers write to the application's input stream, and terminal responses return through that same stream. It is an async disposable resource.
+
+Rendering, scheduling, notifications, and lifecycle behavior belong in the UI tests. [`render.test.ts`](../src/render.test.ts) exercises render requests and cancellation; [`ui.lifecycle.test.ts`](../src/ui.lifecycle.test.ts) exercises terminal restoration, pending writes, and failure propagation.
+
+## DOM compatibility
+
+[`dom.compatibility.test.ts`](../src/dom.compatibility.test.ts) registers the shared compatibility suite with Vitest. Its synchronous factory creates a fresh instance of the real [`Document`](../src/dom.ts) for each case, with a no-op render notification callback. The supplied global bindings are the UI's `Document`, `Element`, `Node`, and `Text` classes. This fixture owns no external resources, so it needs no disposal callback.
+
+[`dom-features.ts`](dom-features.ts) is the source of truth for the features this integration verifies. It satisfies the suite's `DOMFeatures` type and declares every leaf explicitly. The suite also validates the manifest at runtime: omitted, unknown, and non-boolean feature values are configuration errors.
+
+A `true` leaf enables its registered cases; a `false` leaf leaves those cases unregistered. The suite does not infer support from available methods or turn disabled features into skipped tests. Required feature dependencies must also be enabled; missing dependencies fail configuration before any tests are registered. Individual cases with additional feature requirements run only when those requirements are enabled.
+
+Feature names describe the behavior being asserted. For example, `node.basicAppend` covers detached tree construction, while `node.append` also requires moving nodes and rejecting cycles. Passing a narrow feature does not establish support for the broader interface or complete standards conformance. The shared [feature catalogue](../../web-platform-tests/src/compatibility/features.ts) and [registry](../../web-platform-tests/src/compatibility/registry.ts) lead to each feature's cases, prerequisites, and specification references.
+
+When implementing a feature:
+
+1. Read its registered cases and prerequisites to understand the behavior the flag promises.
+2. Implement that behavior in the UI, then enable the relevant leaves in `dom-features.ts`.
+3. Extend the compatibility fixture when the cases need additional constructors, document parsing options, host input, or resource cleanup.
+4. Run the UI tests and add implementation-specific regressions for rendering, notifications, or lifecycle effects.
+
+Keep support decisions in the manifest rather than duplicating flag values or test counts in documentation.
+
+## Host input
+
+Host input features require a [`DOMInputDriver`](../../web-platform-tests/src/compatibility/input-driver.ts) that delivers input through the normal terminal input pipeline. A terminal adapter must resolve mouse targets through the UI's layout, await operation completion, and release held input during cleanup, including after failures. Calling `dispatchEvent`, invoking `.click()`, or directly assigning control values does not exercise that host behavior.
+
+Keyboard/mouse delivery, platform interaction policies, and trusted-event support are separate feature decisions. Enable each only when the implementation and its adapter can satisfy its cases. The shared suite's [browser tooling](../../web-platform-tests/README.md#development) validates the input contract with Playwright; the UI adapter must use terminal layout and input semantics.
diff --git a/packages/ui/test/dom-features.ts b/packages/ui/test/dom-features.ts
new file mode 100644
index 0000000..c762123
--- /dev/null
+++ b/packages/ui/test/dom-features.ts
@@ -0,0 +1,306 @@
+import type { DOMFeatures } from '@bomb.sh/web-platform-tests';
+
+export const domFeatures = {
+ document: {
+ basicCreation: true,
+ creation: false,
+ lookup: false,
+ importNode: false,
+ xml: false,
+ parsing: false,
+ xmlSerialization: false,
+ },
+ node: {
+ basicAppend: true,
+ relationships: false,
+ connectivity: false,
+ liveCollections: false,
+ textContent: false,
+ append: false,
+ insertBefore: false,
+ remove: false,
+ replace: false,
+ fragments: false,
+ variadicMutation: false,
+ cloning: false,
+ comparison: false,
+ normalization: false,
+ adoption: false,
+ },
+ characterData: {
+ stringStorage: true,
+ data: false,
+ editing: false,
+ splitText: false,
+ },
+ element: {
+ childElements: true,
+ attributes: false,
+ attributeNodes: false,
+ namespaces: false,
+ classList: false,
+ dataset: false,
+ reflection: false,
+ selectors: false,
+ },
+ traversal: {
+ nodeIterator: false,
+ treeWalker: false,
+ },
+ parsing: {
+ fragments: false,
+ namespaces: false,
+ serialization: false,
+ contextual: false,
+ markupMutation: false,
+ recovery: false,
+ },
+ templates: {
+ content: false,
+ cloning: false,
+ parsing: false,
+ },
+ events: {
+ data: {
+ custom: false,
+ focus: false,
+ keyboard: false,
+ mouse: false,
+ input: false,
+ },
+ dispatch: {
+ target: false,
+ capture: false,
+ bubbling: false,
+ cancellation: false,
+ stopPropagation: false,
+ stopImmediatePropagation: false,
+ pathSnapshot: false,
+ reuse: false,
+ reentrancy: false,
+ },
+ listeners: {
+ registration: false,
+ objects: false,
+ once: false,
+ passive: false,
+ abortSignal: false,
+ snapshots: false,
+ exceptionReporting: false,
+ },
+ },
+ mutationObserver: {
+ registration: false,
+ records: {
+ childList: false,
+ attributes: false,
+ characterData: false,
+ },
+ filtering: false,
+ oldValue: false,
+ subtree: false,
+ takeRecords: false,
+ disconnect: false,
+ microtaskDelivery: false,
+ detached: false,
+ reentrancy: false,
+ },
+ focus: {
+ eligibility: false,
+ transitions: false,
+ removal: false,
+ inert: false,
+ reentrancy: false,
+ },
+ selection: {
+ range: {
+ boundaries: false,
+ comparison: false,
+ stringification: false,
+ cloneContents: false,
+ deleteContents: false,
+ extractContents: false,
+ liveBoundaries: false,
+ mutation: false,
+ },
+ document: {
+ rangeManagement: false,
+ direction: false,
+ connectivity: false,
+ liveUpdates: false,
+ mutation: false,
+ changeEvents: false,
+ },
+ },
+ elements: {
+ progress: false,
+ meter: false,
+ option: {
+ attributes: false,
+ text: false,
+ selectedness: false,
+ },
+ optgroup: false,
+ datalist: false,
+ input: {
+ value: false,
+ sanitization: false,
+ colorValue: false,
+ numericValue: false,
+ dateValue: false,
+ stepping: false,
+ checkedness: false,
+ radioGroups: false,
+ selection: false,
+ rangeText: false,
+ validation: false,
+ formAssociation: false,
+ labels: false,
+ activation: false,
+ cloning: false,
+ },
+ textarea: {
+ value: false,
+ selection: false,
+ rangeText: false,
+ validation: false,
+ formAssociation: false,
+ labels: false,
+ cloning: false,
+ },
+ select: {
+ options: false,
+ indexedOptions: false,
+ selection: false,
+ mutation: false,
+ validation: false,
+ cloning: false,
+ },
+ selectedcontent: false,
+ form: {
+ controls: false,
+ namedControls: false,
+ reset: false,
+ validation: false,
+ submission: false,
+ formData: false,
+ dataEvents: false,
+ },
+ button: {
+ attributes: false,
+ formAssociation: false,
+ activation: false,
+ },
+ label: {
+ association: false,
+ activation: false,
+ },
+ fieldset: {
+ controls: false,
+ disabled: false,
+ },
+ legend: false,
+ output: {
+ htmlFor: false,
+ value: false,
+ reset: false,
+ },
+ details: {
+ open: false,
+ toggleEvents: false,
+ exclusiveGroups: false,
+ },
+ summary: {
+ activation: false,
+ },
+ dialog: {
+ show: false,
+ close: false,
+ modal: false,
+ requestClose: false,
+ focus: false,
+ formSubmission: false,
+ },
+ },
+ customElements: {
+ registration: false,
+ construction: false,
+ upgrading: false,
+ observedAttributes: false,
+ connection: false,
+ adoption: false,
+ reactionOrdering: false,
+ reentrancy: false,
+ exceptionReporting: false,
+ formAssociation: false,
+ },
+ shadow: {
+ roots: {
+ attachment: false,
+ hostEligibility: false,
+ connectivity: false,
+ cloning: false,
+ adoption: false,
+ },
+ slots: {
+ named: false,
+ fallback: false,
+ manual: false,
+ slotchange: false,
+ observerOrdering: false,
+ },
+ events: {
+ retargeting: false,
+ composedPath: false,
+ relatedTarget: false,
+ },
+ focus: {
+ retargeting: false,
+ delegation: false,
+ },
+ declarative: false,
+ serialization: false,
+ },
+ style: {
+ inline: false,
+ parsing: false,
+ attributeSynchronization: false,
+ customProperties: false,
+ stylesheets: false,
+ computed: false,
+ cascade: false,
+ layers: false,
+ constructedStylesheets: false,
+ focusState: false,
+ controlSelectors: false,
+ relationalSelectors: false,
+ mutationObservation: false,
+ },
+ input: {
+ keyboard: {
+ delivery: false,
+ modifiers: false,
+ navigation: false,
+ activation: false,
+ editing: false,
+ multilineEditing: false,
+ numberStepping: false,
+ focusStyles: false,
+ focusVisible: false,
+ shadow: false,
+ implicitSubmission: false,
+ trustedEvents: false,
+ },
+ mouse: {
+ delivery: false,
+ buttons: false,
+ hover: false,
+ focus: false,
+ activation: false,
+ stateStyles: false,
+ focusVisible: false,
+ shadow: false,
+ trustedEvents: false,
+ },
+ },
+} satisfies DOMFeatures;
diff --git a/packages/web-platform-tests/LICENSE b/packages/web-platform-tests/LICENSE
new file mode 100644
index 0000000..e72db0e
--- /dev/null
+++ b/packages/web-platform-tests/LICENSE
@@ -0,0 +1,21 @@
+MIT License Copyright (c) 2026-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/web-platform-tests/README.md b/packages/web-platform-tests/README.md
new file mode 100644
index 0000000..e4f2c7c
--- /dev/null
+++ b/packages/web-platform-tests/README.md
@@ -0,0 +1,86 @@
+# @bomb.sh/web-platform-tests
+
+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.
+
+Each adapted file records its upstream sources and original author attribution in comments or source metadata. The original WPT BSD-3-Clause notice and the html5lib parsing corpus MIT notice are retained. See [standards references and licenses](docs/references.md) for the pinned sources, attribution, and applicable licenses.
+
+```ts
+import { testDOMCompatibility, type DOMFeatures } from '@bomb.sh/web-platform-tests';
+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
+
+Supply a runner with `describe`, `it`, and Vitest-compatible `expect` matchers, plus a set of `features` conforming to the `DOMFeatures` interface. Every feature must be explicitly declared supported or unsupported. The suite registers cases for enabled features and checks their prerequisites before registration.
+
+### Harness
+
+* `create(options?)`: A synchronous or asynchronous factory for a fresh `Document`. When a case supplies `DOMDocumentOptions`, honor its markup, MIME type, and URL fragment; the selector matrix uses these to test HTML, XHTML, and `:target`.
+* `globals`: An object containing the implementation's global bindings, or a function that returns them for a document. For example, a test looks up `HTMLInputElement` on this object.
+* `input`: An optional factory for a `DOMInputDriver` session that delivers keyboard, text, and mouse input through the implementation's normal host input pipeline. This allows testing of default actions like focus on click, form submission on `Enter`, and trusted events.
+* `dispose(document)`: Optional cleanup for implementation-owned resources after each case, including failures.
+
+### Development
+
+```sh
+pnpm build
+pnpm test
+pnpm --filter @bomb.sh/web-platform-tests typecheck
+pnpm --filter @bomb.sh/web-platform-tests exec playwright install chromium
+pnpm --filter @bomb.sh/web-platform-tests test:browser:build
+pnpm --filter @bomb.sh/web-platform-tests test:browser:input
+pnpm --filter @bomb.sh/web-platform-tests test:browser:run
+pnpm --filter @bomb.sh/web-platform-tests test:wpt-selectors
+# Manual, without input: open /compatibility.html on the printed URL
+pnpm --filter @bomb.sh/web-platform-tests test:browser
+```
+To validate the test suite itself, run it against an actual browser runtime. 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 [selector WPT omission check](docs/wpt-selectors.md) compares the adapted registrations with actual pinned upstream case names. Its cache is ignored, and results are printed rather than committed.
+
+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 eight 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. |
+| [`element.selectors`](src/compatibility/element/selectors/grammar.ts): WPT `querySelector-mixed-case.html`, mixed HTML/SVG/MathML tree with various mixed-case attributes | `[viewbox]` matches four elements where WPT expects two HTML elements. | The [pinned upstream case](https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/dom/nodes/querySelector-mixed-case.html) produces the same failure in Chromium; its assertions remain unchanged. |
+
+### 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. The [UI feature manifest](../ui/test/dom-features.ts) declares its implementation's supported features; the [UI test README](../ui/test/README.md) explains the integration and how to update those decisions. [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/web-platform-tests/docs/references.md b/packages/web-platform-tests/docs/references.md
new file mode 100644
index 0000000..deaca83
--- /dev/null
+++ b/packages/web-platform-tests/docs/references.md
@@ -0,0 +1,18 @@
+# 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](../test/wpt/LICENSE).
+
+The suite and browser tooling use the repository's [MIT license](../LICENSE), copyright 2026-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.
+The [selector adaptations](wpt-selectors.md) retain the complete shared selector
+matrix and identify the upstream files, harness adaptations, and explicit exclusions.
+
+[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/web-platform-tests/docs/wpt-css-forms.md b/packages/web-platform-tests/docs/wpt-css-forms.md
new file mode 100644
index 0000000..7926b1e
--- /dev/null
+++ b/packages/web-platform-tests/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). 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/web-platform-tests/docs/wpt-ranges-parsing.md b/packages/web-platform-tests/docs/wpt-ranges-parsing.md
new file mode 100644
index 0000000..615dfda
--- /dev/null
+++ b/packages/web-platform-tests/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).
+
+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/web-platform-tests/docs/wpt-selectors.md b/packages/web-platform-tests/docs/wpt-selectors.md
new file mode 100644
index 0000000..45b8665
--- /dev/null
+++ b/packages/web-platform-tests/docs/wpt-selectors.md
@@ -0,0 +1,64 @@
+# Selector WPT adaptations
+
+The selector cases use Web Platform Tests at
+[`b719612f963a05ece9c89a7dd7598c1c59618874`](https://github.com/web-platform-tests/wpt/tree/b719612f963a05ece9c89a7dd7598c1c59618874).
+Copyright © web-platform-tests contributors; the upstream BSD-3-Clause license
+is retained in [LICENSE](../test/wpt/LICENSE). File-specific author credits
+are retained in the adapted modules.
+
+All cases register through [`element.selectors`](../src/compatibility/element/selectors.ts).
+The supporting modules group cases by behavior. Each exports `sourceFiles`, the
+exact upstream paths it adapts, and retains the original case titles after a
+`WPT :` prefix.
+
+| Module | Upstream coverage |
+| --- | --- |
+| [Matrix](../src/compatibility/element/selectors/matrix.ts) | `ParentNode-querySelector-All.html`, its XHTML counterpart, and `Element-matches.html`, including their shared `selectors.js` corpus and content documents. |
+| [Roots and mutations](../src/compatibility/element/selectors/roots.ts) | Document, element, fragment, shadow-root and detached scopes; ordering, duplicate IDs, string conversion, static lists, and mutations. |
+| [Matching](../src/compatibility/element/selectors/matching.ts) | `matches`, `closest`, default `:active` state, disabled controls, and structural pseudo-classes. |
+| [Namespaces](../src/compatibility/element/selectors/namespaces.ts) | HTML/XML case sensitivity, namespaced attributes and elements, SVG, and template contents. |
+| [Grammar](../src/compatibility/element/selectors/grammar.ts) | Escapes, attribute operators and flags, nested structural and logical selectors, mixed HTML/SVG/MathML, and CSS `is-where-basic.html` / `has-basic.html`. |
+
+The matrix retains every upstream data row, API mask, expected ID list, and
+document/root exclusion. Query results are checked in order, including first-match
+identity and exclusion of the cloned sibling tree. The HTML and XHTML markup is
+the upstream fixture content, supplied to a fresh document factory with its MIME
+type and `#target` fragment. It contains no WPT runner scripts. Constructors used
+for exception and collection assertions come from the implementation's globals.
+
+Original assertions are preserved when adapting the harness. Global named-element
+bindings become explicit node references, and mutations shared between upstream
+tests are repeated in each fresh fixture. CSS escape and mixed-namespace data are
+retained in full. Rendering output is not asserted.
+
+The following exclusions concern the upstream API and harness, not browser failures:
+
+- `Element-webkitMatchesSelector.html` and the three `webkitMatchesSelector` cases
+ in `Element-matches-namespaced-elements.html` test a vendor-prefixed alias outside
+ the standard `matches` API.
+- The unnamed asynchronous iframe-loader cases in the three matrix wrappers are
+ replaced by the consumer's document factory. Their behavioral subtests remain.
+- Rows marked only `TEST_FIND` are for the obsolete `find`/`findAll` proposal. As in
+ the upstream query-selector and matches wrappers, those API masks do not register
+ tests here. Rows also marked `TEST_MATCH` do register.
+
+## Checking for omissions
+
+`pnpm --filter @bomb.sh/web-platform-tests test:wpt-selectors` compares registered case names with
+the pinned upstream tests. The check discovers selector API pages in `dom/nodes`
+and includes the two CSS grammar files above. Missing or extra cases fail the
+check; the exclusions above are explicit. Downloaded upstream resources are cached
+under the package's ignored `node_modules/.cache` directory. Reports go to stdout.
+The check also compares the complete selector data, API masks, and fixture bytes
+with their pinned sources, and rejects unclassified files in the selected scope.
+The repository's `dom-wpt` CI job runs this check, package type checks, and native
+input validation on pull requests.
+
+When changing this coverage, update the upstream pin and source mapping together,
+review every new upstream case and its assertions, then run both the omission
+check and browser validation. Matching names protects against omitted registrations;
+reviewing assertion adaptations and running the cases protects their meaning.
+Neither check establishes coverage of all WPT directories: CSS rendering tests,
+other CSS selector files, and the broader suite's documented gaps remain separate
+work. Keep those boundaries explicit rather than treating a passing case count
+as complete coverage.
diff --git a/packages/web-platform-tests/docs/wpt-shadow-input.md b/packages/web-platform-tests/docs/wpt-shadow-input.md
new file mode 100644
index 0000000..1c45b00
--- /dev/null
+++ b/packages/web-platform-tests/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) 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/web-platform-tests/docs/wpt-state-styles.md b/packages/web-platform-tests/docs/wpt-state-styles.md
new file mode 100644
index 0000000..4abd854
--- /dev/null
+++ b/packages/web-platform-tests/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) 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/web-platform-tests/knip.json b/packages/web-platform-tests/knip.json
new file mode 100644
index 0000000..d323bc7
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/package.json b/packages/web-platform-tests/package.json
new file mode 100644
index 0000000..97c6782
--- /dev/null
+++ b/packages/web-platform-tests/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@bomb.sh/web-platform-tests",
+ "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:wpt-selectors": "node scripts/check-wpt-selectors.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/web-platform-tests/scripts/check-wpt-selectors.mjs b/packages/web-platform-tests/scripts/check-wpt-selectors.mjs
new file mode 100644
index 0000000..7316b9a
--- /dev/null
+++ b/packages/web-platform-tests/scripts/check-wpt-selectors.mjs
@@ -0,0 +1,453 @@
+// Development-only registration and data audit against the pinned WPT sources.
+// Usage: node scripts/check-wpt-selectors.mjs
+// Options: --source-root , --offline, --json
+// Downloads are cached in ignored node_modules/.cache; no reports are written.
+import { deepStrictEqual } from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { pathToFileURL } from 'node:url';
+import { runInNewContext } from 'node:vm';
+import { chromium } from 'playwright';
+import { register } from '../src/compatibility/element/selectors.ts';
+import {
+ invalidSelectors,
+ validSelectors,
+ scopedSelectors,
+ TEST_QSA,
+ TEST_FIND,
+ TEST_MATCH,
+} from '../src/compatibility/element/selectors/data.ts';
+import * as fixtureMarkup from '../src/compatibility/element/selectors/fixtures.ts';
+
+class SelectorAuditError extends Error {
+ constructor(message, details = {}) {
+ super(message);
+ this.name = 'SelectorAuditError';
+ this.details = details;
+ }
+}
+
+const revision = 'b719612f963a05ece9c89a7dd7598c1c59618874';
+const cacheRoot = new URL(`../node_modules/.cache/wpt-selectors/${revision}/`, import.meta.url);
+const options = { offline: false, json: false, sourceRoot: undefined };
+for (let index = 2; index < process.argv.length; index++) {
+ const argument = process.argv[index];
+ if (argument === '--offline') options.offline = true;
+ else if (argument === '--json') options.json = true;
+ else if (argument === '--source-root' && process.argv[index + 1])
+ options.sourceRoot = pathToFileURL(`${process.argv[++index]}/`);
+ else throw new SelectorAuditError(`Unknown or incomplete argument: ${argument}`);
+}
+
+// The audit discovers every matching file in dom/nodes at the pinned revision.
+// CSS coverage is deliberately scoped to these two additional adapted sources.
+const supplementalSources = ['css/selectors/is-where-basic.html', 'css/selectors/has-basic.html'];
+const sourceExclusions = new Map([
+ [
+ 'dom/nodes/Element-webkitMatchesSelector.html',
+ 'Vendor-prefixed alias; the unprefixed Element.matches matrix is adapted.',
+ ],
+]);
+const caseExclusions = new Map([
+ [
+ 'dom/nodes/Element-matches-namespaced-elements.html',
+ new Set([
+ 'empty string namespace, webkitMatchesSelector',
+ 'has a namespace, webkitMatchesSelector',
+ 'has a namespace, *|, webkitMatchesSelector',
+ ]),
+ ],
+]);
+const bootstrapSources = new Set([
+ 'dom/nodes/ParentNode-querySelector-All.html',
+ 'dom/nodes/ParentNode-querySelector-All-xht.xht',
+ 'dom/nodes/Element-matches.html',
+]);
+const directoryPromises = new Map();
+const sourcePromises = new Map();
+
+async function optionalRead(path) {
+ try {
+ return await readFile(path);
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ return undefined;
+ }
+}
+
+async function download(url) {
+ if (options.offline) throw new SelectorAuditError(`Not cached in offline mode: ${url}`);
+ const response = await fetch(url, {
+ headers: { 'User-Agent': '@bomb.sh/web-platform-tests WPT selector audit' },
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (!response.ok)
+ throw new SelectorAuditError(`${response.status} ${response.statusText}: ${url}`);
+ return Buffer.from(await response.arrayBuffer());
+}
+
+async function directory(path) {
+ if (!directoryPromises.has(path)) {
+ directoryPromises.set(
+ path,
+ (async () => {
+ const cachePath = new URL(`directories/${path}.json`, cacheRoot);
+ let data = await optionalRead(cachePath);
+ if (!data) {
+ data = await download(
+ `https://api.github.com/repos/web-platform-tests/wpt/contents/${path}?ref=${revision}`,
+ );
+ await mkdir(new URL('./', cachePath), { recursive: true });
+ await writeFile(cachePath, data);
+ }
+ const entries = JSON.parse(data.toString());
+ // GitHub's contents API truncates at 1,000 files. Never accept a partial listing.
+ if (!Array.isArray(entries) || entries.length >= 1_000)
+ throw new SelectorAuditError(`Incomplete upstream directory listing: ${path}`);
+ return entries;
+ })(),
+ );
+ }
+ return directoryPromises.get(path);
+}
+
+async function source(path) {
+ if (!sourcePromises.has(path)) {
+ sourcePromises.set(
+ path,
+ (async () => {
+ const entry = (await directory(path.split('/').slice(0, -1).join('/'))).find(
+ (item) => item.path === path,
+ );
+ if (!entry || entry.type !== 'file')
+ throw new SelectorAuditError(`Unknown pinned WPT file: ${path}`, { missing: true });
+ const cachePath = new URL(`sources/${path}`, cacheRoot);
+ const local = options.sourceRoot && (await optionalRead(new URL(path, options.sourceRoot)));
+ const cached = await optionalRead(cachePath);
+ const data =
+ local ||
+ cached ||
+ (await download(
+ `https://raw.githubusercontent.com/web-platform-tests/wpt/${revision}/${path}`,
+ ));
+ const hash = createHash('sha1').update(`blob ${data.length}\0`).update(data).digest('hex');
+ if (hash !== entry.sha)
+ throw new SelectorAuditError(`Source does not match pinned Git blob: ${path}`);
+ if (!cached) {
+ await mkdir(new URL('./', cachePath), { recursive: true });
+ await writeFile(cachePath, data);
+ }
+ return data.toString();
+ })(),
+ );
+ }
+ return sourcePromises.get(path);
+}
+
+function difference(expected, actual) {
+ const remaining = new Map();
+ for (const name of actual) remaining.set(name, (remaining.get(name) ?? 0) + 1);
+ return expected.filter((name) => {
+ const count = remaining.get(name) ?? 0;
+ if (!count) return true;
+ remaining.set(name, count - 1);
+ return false;
+ });
+}
+
+// Record the real public leaf registration, including conditionally required
+// cases, without executing bodies or filtering by an implementation manifest.
+const adapted = [];
+register({ runner: { expect: {} }, test: (name) => adapted.push(name) });
+const adaptedNames = adapted.filter((name) => name.startsWith('WPT '));
+const declaredSources = new Set();
+const moduleRoot = new URL('../src/compatibility/element/selectors/', import.meta.url);
+for (const file of (await readdir(moduleRoot)).filter((name) => name.endsWith('.ts'))) {
+ const module = await import(new URL(file, moduleRoot).href);
+ for (const path of module.sourceFiles ?? []) declaredSources.add(path);
+}
+
+const candidates = (await directory('dom/nodes'))
+ .filter(
+ (entry) =>
+ entry.type === 'file' &&
+ /queryselector|^Element-(?:closest|matches|webkitMatchesSelector)/i.test(entry.name),
+ )
+ .map((entry) => entry.path);
+const pages = [];
+const supportFiles = [];
+for (const path of [...candidates, ...supplementalSources].toSorted()) {
+ const content = await source(path);
+ if (
+ path.endsWith('.window.js') ||
+ /`)
+ .join('');
+ body = ` ${dependencies}`;
+ } else if (path === 'resources/testharness.js') body += completionHook;
+ response.writeHead(200, { 'Content-Type': `${contentType}; charset=utf-8` }).end(body);
+ } catch (error) {
+ // The upstream fixture intentionally references nonexistent foo.jpg. A WPT
+ // server returns 404; missing test documents/scripts must still fail the audit.
+ if (error.details?.missing && request.headers['sec-fetch-dest'] === 'image') {
+ response.writeHead(404).end();
+ return;
+ }
+ requestErrors.push(error.message);
+ response.writeHead(500).end(error.message);
+ }
+});
+
+let browser;
+const results = [];
+const exclusions = [];
+const errors = [];
+try {
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ browser = await chromium.launch({
+ headless: true,
+ ...(process.env.CHROME_BIN ? { executablePath: process.env.CHROME_BIN } : {}),
+ });
+ const origin = `http://127.0.0.1:${server.address().port}`;
+ for (const path of pages) {
+ const context = await browser.newContext();
+ try {
+ await context.route('**/*', (route) =>
+ new URL(route.request().url()).origin === origin ? route.continue() : route.abort(),
+ );
+ const page = await context.newPage();
+ await page.goto(`${origin}/${path}${path.endsWith('.window.js') ? '?audit-wrapper' : ''}`);
+ await page.waitForFunction(() => globalThis.__selectorAudit, undefined, { timeout: 30_000 });
+ const result = await page.evaluate(() => globalThis.__selectorAudit);
+ if (result.status.status !== 0)
+ throw new SelectorAuditError(
+ `WPT harness status ${result.status.status}: ${result.status.message}`,
+ );
+ let tests = result.tests;
+ if (sourceExclusions.has(path)) {
+ exclusions.push({
+ source: path,
+ reason: sourceExclusions.get(path),
+ cases: tests.map((test) => test.name),
+ });
+ continue;
+ }
+ if (caseExclusions.has(path)) {
+ const names = caseExclusions.get(path);
+ const excluded = tests.filter((test) => names.has(test.name));
+ if (excluded.length !== names.size)
+ throw new SelectorAuditError(
+ 'Vendor alias exclusions do not match upstream registrations',
+ { source: path },
+ );
+ exclusions.push({
+ source: path,
+ reason:
+ 'Vendor-prefixed alias cases; the same namespace assertions for unprefixed matches are adapted.',
+ cases: excluded.map((test) => test.name),
+ });
+ tests = tests.filter((test) => !names.has(test.name));
+ }
+ if (bootstrapSources.has(path)) {
+ const bootstrap = tests.filter((test) => test.name === result.title);
+ if (bootstrap.length !== 1)
+ throw new SelectorAuditError(
+ `Expected exactly one unnamed iframe-loader case, found ${bootstrap.length}`,
+ );
+ exclusions.push({
+ source: path,
+ reason:
+ 'WPT iframe-loader harness case; adapted cases receive their fixture from the document factory.',
+ cases: bootstrap.map((test) => test.name),
+ });
+ tests = tests.filter((test) => test.name !== result.title);
+ }
+ if (!tests.length) throw new SelectorAuditError('No upstream behavioral cases registered');
+ results.push({
+ source: path,
+ cases: tests.map((test) => `WPT ${path.split('/').at(-1)}: ${test.name}`),
+ browserFailures: tests
+ .filter((test) => test.status !== 0)
+ .map(({ name, status, message }) => ({ name, status, message })),
+ });
+ } catch (error) {
+ errors.push({ source: path, message: error.message });
+ } finally {
+ await context.close();
+ }
+ }
+} finally {
+ await browser?.close();
+ await new Promise((resolve) => server.close(resolve));
+}
+
+const expectedNames = results.flatMap((result) => result.cases);
+const report = {
+ revision,
+ scope:
+ 'All dom/nodes querySelector/querySelectorAll, matches and closest testharness sources, plus css/selectors/is-where-basic.html and has-basic.html.',
+ upstreamSources: results.length,
+ upstreamCases: expectedNames.length,
+ adaptedCases: adaptedNames.length,
+ sources: results.map((result) => ({ source: result.source, cases: result.cases.length })),
+ missing: difference(expectedNames, adaptedNames),
+ extra: difference(adaptedNames, expectedNames),
+ undeclaredSources: results
+ .filter((result) => !declaredSources.has(result.source))
+ .map((result) => result.source),
+ exclusions,
+ supportFiles,
+ preservationChecks,
+ errors: [...preservationErrors, ...errors, ...requestErrors.map((message) => ({ message }))],
+ // Assertion failures are retained for visibility but this tool checks case
+ // registration coverage, not browser conformance or assertion equivalence.
+ upstreamBrowserFailures: results
+ .filter((result) => result.browserFailures.length)
+ .map((result) => ({ source: result.source, cases: result.browserFailures })),
+};
+if (options.json) console.info(JSON.stringify(report, null, 2));
+else {
+ console.info(
+ `Pinned WPT ${revision}: ${report.upstreamCases} upstream cases in ${report.upstreamSources} sources; ${report.adaptedCases} adapted cases.`,
+ );
+ console.info(`Corpus and fixture preservation checks passed: ${preservationChecks.length}/8.`);
+ for (const exclusion of exclusions)
+ console.info(
+ `EXCLUDED ${exclusion.source}: ${exclusion.cases.length} case(s). ${exclusion.reason}`,
+ );
+ for (const name of report.missing) console.info(`MISSING ${name}`);
+ for (const name of report.extra) console.info(`EXTRA ${name}`);
+ for (const path of report.undeclaredSources) console.info(`UNDECLARED SOURCE ${path}`);
+ for (const error of report.errors)
+ console.info(`ERROR ${error.source ?? 'resource'}: ${error.message}`);
+ for (const result of report.upstreamBrowserFailures)
+ console.info(
+ `UPSTREAM BROWSER FAILURES ${result.source}: ${result.cases.map((test) => `${test.name}: ${test.message}`).join('; ')}`,
+ );
+ console.info(
+ `Missing: ${report.missing.length}; extra: ${report.extra.length}; undeclared sources: ${report.undeclaredSources.length}; extraction errors: ${report.errors.length}.`,
+ );
+ console.info(
+ 'Case registration and matrix data preservation only: this audit does not establish complete assertion equivalence or selector conformance. Use --json for full source and exclusion details.',
+ );
+}
+if (
+ report.missing.length ||
+ report.extra.length ||
+ report.undeclaredSources.length ||
+ report.errors.length
+)
+ process.exitCode = 1;
diff --git a/packages/web-platform-tests/src/compatibility/character-data/data.ts b/packages/web-platform-tests/src/compatibility/character-data/data.ts
new file mode 100644
index 0000000..bc13028
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/character-data/editing.ts b/packages/web-platform-tests/src/compatibility/character-data/editing.ts
new file mode 100644
index 0000000..c612d6c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/character-data/split-text.ts b/packages/web-platform-tests/src/compatibility/character-data/split-text.ts
new file mode 100644
index 0000000..89e78d7
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/character-data/string-storage.ts b/packages/web-platform-tests/src/compatibility/character-data/string-storage.ts
new file mode 100644
index 0000000..d84fec2
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/context.ts b/packages/web-platform-tests/src/compatibility/context.ts
new file mode 100644
index 0000000..ab2932f
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/context.ts
@@ -0,0 +1,67 @@
+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';
+import type { DOMDocumentOptions } from './fixture.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;
+}
+
+/** Optional prerequisites and document source for an individual case. */
+export interface CompatibilityTestOptions {
+ requires?: readonly DOMFeaturePath[];
+ document?: DOMDocumentOptions;
+}
+
+export interface CompatibilityContext {
+ readonly runner: CompatibilityRunner;
+ test(
+ name: string,
+ run: (fixture: DOMFixture) => void | Promise,
+ options?: readonly DOMFeaturePath[] | CompatibilityTestOptions,
+ ): 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/web-platform-tests/src/compatibility/contracts/collections.ts b/packages/web-platform-tests/src/compatibility/contracts/collections.ts
new file mode 100644
index 0000000..d16290c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/contracts/elements.ts b/packages/web-platform-tests/src/compatibility/contracts/elements.ts
new file mode 100644
index 0000000..d205ea6
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/contracts/events.ts b/packages/web-platform-tests/src/compatibility/contracts/events.ts
new file mode 100644
index 0000000..b463af0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/contracts/nodes.ts b/packages/web-platform-tests/src/compatibility/contracts/nodes.ts
new file mode 100644
index 0000000..51d2985
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/contracts/observation.ts b/packages/web-platform-tests/src/compatibility/contracts/observation.ts
new file mode 100644
index 0000000..b21414a
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/contracts/selection.ts b/packages/web-platform-tests/src/compatibility/contracts/selection.ts
new file mode 100644
index 0000000..143d7e3
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/contracts/shadow.ts b/packages/web-platform-tests/src/compatibility/contracts/shadow.ts
new file mode 100644
index 0000000..53de4a8
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/adoption.ts b/packages/web-platform-tests/src/compatibility/custom-elements/adoption.ts
new file mode 100644
index 0000000..83c9d17
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/connection.ts b/packages/web-platform-tests/src/compatibility/custom-elements/connection.ts
new file mode 100644
index 0000000..b712de2
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/construction.ts b/packages/web-platform-tests/src/compatibility/custom-elements/construction.ts
new file mode 100644
index 0000000..a852eae
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/exception-reporting.ts b/packages/web-platform-tests/src/compatibility/custom-elements/exception-reporting.ts
new file mode 100644
index 0000000..dd58133
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/form-association.ts b/packages/web-platform-tests/src/compatibility/custom-elements/form-association.ts
new file mode 100644
index 0000000..014d8a6
--- /dev/null
+++ b/packages/web-platform-tests/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 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/web-platform-tests/src/compatibility/custom-elements/observed-attributes.ts b/packages/web-platform-tests/src/compatibility/custom-elements/observed-attributes.ts
new file mode 100644
index 0000000..2c73ad0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/reaction-ordering.ts b/packages/web-platform-tests/src/compatibility/custom-elements/reaction-ordering.ts
new file mode 100644
index 0000000..1ef4e12
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/reentrancy.ts b/packages/web-platform-tests/src/compatibility/custom-elements/reentrancy.ts
new file mode 100644
index 0000000..1ac0071
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/registration.ts b/packages/web-platform-tests/src/compatibility/custom-elements/registration.ts
new file mode 100644
index 0000000..3a19831
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/custom-elements/upgrading.ts b/packages/web-platform-tests/src/compatibility/custom-elements/upgrading.ts
new file mode 100644
index 0000000..209f675
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/document/basic-creation.ts b/packages/web-platform-tests/src/compatibility/document/basic-creation.ts
new file mode 100644
index 0000000..96c0ba5
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/document/creation.ts b/packages/web-platform-tests/src/compatibility/document/creation.ts
new file mode 100644
index 0000000..cf852cf
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/document/import-node.ts b/packages/web-platform-tests/src/compatibility/document/import-node.ts
new file mode 100644
index 0000000..42cd3ab
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/document/lookup.ts b/packages/web-platform-tests/src/compatibility/document/lookup.ts
new file mode 100644
index 0000000..3ed073f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/document/parsing.ts b/packages/web-platform-tests/src/compatibility/document/parsing.ts
new file mode 100644
index 0000000..92ccde3
--- /dev/null
+++ b/packages/web-platform-tests/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 & B x
',
+ '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/web-platform-tests/src/compatibility/document/xml-serialization.ts b/packages/web-platform-tests/src/compatibility/document/xml-serialization.ts
new file mode 100644
index 0000000..294bb73
--- /dev/null
+++ b/packages/web-platform-tests/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 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/web-platform-tests/src/compatibility/document/xml.ts b/packages/web-platform-tests/src/compatibility/document/xml.ts
new file mode 100644
index 0000000..018f8e5
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/attribute-nodes.ts b/packages/web-platform-tests/src/compatibility/element/attribute-nodes.ts
new file mode 100644
index 0000000..f8413f1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/attributes.ts b/packages/web-platform-tests/src/compatibility/element/attributes.ts
new file mode 100644
index 0000000..2d984aa
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/child-elements.ts b/packages/web-platform-tests/src/compatibility/element/child-elements.ts
new file mode 100644
index 0000000..8c2c5cd
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/class-list.ts b/packages/web-platform-tests/src/compatibility/element/class-list.ts
new file mode 100644
index 0000000..2980a70
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/dataset.ts b/packages/web-platform-tests/src/compatibility/element/dataset.ts
new file mode 100644
index 0000000..a58a4e9
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/namespaces.ts b/packages/web-platform-tests/src/compatibility/element/namespaces.ts
new file mode 100644
index 0000000..c8de3b1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/reflection.ts b/packages/web-platform-tests/src/compatibility/element/reflection.ts
new file mode 100644
index 0000000..5c58ddd
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/element/selectors.ts b/packages/web-platform-tests/src/compatibility/element/selectors.ts
new file mode 100644
index 0000000..9ed8649
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors.ts
@@ -0,0 +1,54 @@
+import type { CompatibilityContext } from '../context.ts';
+import { register as registerGrammar } from './selectors/grammar.ts';
+import { register as registerMatching } from './selectors/matching.ts';
+import { register as registerNamespaces } from './selectors/namespaces.ts';
+import { register as registerRoots } from './selectors/roots.ts';
+import { register as registerMatrix } from './selectors/matrix.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-parentnode-queryselector';
+export const requires = ['parsing.fragments', 'element.attributes'] as const;
+export const globals = ['DOMException', 'NodeList', 'TypeError'] as const;
+/** 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(found[0]).toBe(p.firstChild);
+ expect(found[1]).toBe(p.lastChild);
+ 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' }));
+ });
+ registerMatrix(context);
+ registerRoots(context);
+ registerMatching(context);
+ registerNamespaces(context);
+ registerGrammar(context);
+}
diff --git a/packages/web-platform-tests/src/compatibility/element/selectors/data.ts b/packages/web-platform-tests/src/compatibility/element/selectors/data.ts
new file mode 100644
index 0000000..da99636
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors/data.ts
@@ -0,0 +1,3666 @@
+// WPT dom/nodes/selectors.js at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+// Complete upstream data, including its API masks and context exclusions.
+export interface InvalidSelector {
+ name: string;
+ selector: string;
+}
+export interface Selector extends InvalidSelector {
+ expect: string[];
+ unexpected?: string[];
+ exclude?: string[];
+ level: number;
+ testType: number;
+ ctx?: string;
+}
+
+// Bit-mapped flags to indicate which tests the selector is suitable for
+export const TEST_QSA = 0x01; // querySelector() and querySelectorAll() tests
+export const TEST_FIND = 0x04; // find() and findAll() tests, may be unsuitable for querySelector[All]
+export const TEST_MATCH = 0x10; // matches() tests
+
+/*
+ * All of these invalid selectors should result in a SyntaxError being thrown by the APIs.
+ *
+ * name: A descriptive name of the selector being tested
+ * selector: The selector to test
+ */
+export const invalidSelectors: readonly InvalidSelector[] = [
+ { name: 'Empty String', selector: '' },
+ { name: 'Invalid character', selector: '[' },
+ { name: 'Invalid character', selector: ']' },
+ { name: 'Invalid character', selector: '(' },
+ { name: 'Invalid character', selector: ')' },
+ { name: 'Invalid character', selector: '{' },
+ { name: 'Invalid character', selector: '}' },
+ { name: 'Invalid character', selector: '<' },
+ { name: 'Invalid character', selector: '>' },
+ { name: 'Invalid ID', selector: '#' },
+ { name: 'Invalid group of selectors', selector: 'div,' },
+ { name: 'Invalid class', selector: '.' },
+ { name: 'Invalid class', selector: '.5cm' },
+ { name: 'Invalid class', selector: '..test' },
+ { name: 'Invalid class', selector: '.foo..quux' },
+ { name: 'Invalid class', selector: '.bar.' },
+ { name: 'Invalid combinator', selector: 'div % address, p' },
+ { name: 'Invalid combinator', selector: 'div ++ address, p' },
+ { name: 'Invalid combinator', selector: 'div ~~ address, p' },
+ { name: 'Invalid [att=value] selector', selector: '[*=test]' },
+ { name: 'Invalid [att=value] selector', selector: '[*|*=test]' },
+ { name: 'Invalid [att=value] selector', selector: '[class= space unquoted ]' },
+ { name: 'Unknown pseudo-class', selector: 'div:example' },
+ { name: 'Unknown pseudo-class', selector: ':example' },
+ { name: 'Unknown pseudo-class', selector: 'div:linkexample' },
+ { name: 'Unknown pseudo-element', selector: 'div::example' },
+ { name: 'Unknown pseudo-element', selector: '::example' },
+ { name: 'Invalid pseudo-element', selector: ':::before' },
+ { name: 'Invalid pseudo-element', selector: ':: before' },
+ { name: 'Undeclared namespace', selector: 'ns|div' },
+ { name: 'Undeclared namespace', selector: ':not(ns|div)' },
+ { name: 'Invalid namespace', selector: '^|div' },
+ { name: 'Invalid namespace', selector: '$|div' },
+ { name: 'Relative selector', selector: '>*' },
+];
+
+/*
+ * All of these should be valid selectors, expected to match zero or more elements in the document.
+ * None should throw any errors.
+ *
+ * name: A descriptive name of the selector being tested
+ * selector: The selector to test
+ * expect: A list of IDs of the elements expected to be matched. List must be given in tree order.
+ * exclude: An array of contexts to exclude from testing. The valid values are:
+ * ["document", "element", "fragment", "detached", "html", "xhtml"]
+ * The "html" and "xhtml" values represent the type of document being queried. These are useful
+ * for tests that are affected by differences between HTML and XML, such as case sensitivity.
+ * level: An integer indicating the CSS or Selectors level in which the selector being tested was introduced.
+ * testType: A bit-mapped flag indicating the type of test.
+ *
+ * Note: Interactive pseudo-classes (:active :hover and :focus) have not been tested in this test suite.
+ */
+export const validSelectors: readonly Selector[] = [
+ // Type Selector
+ {
+ name: 'Type selector, matching html element',
+ selector: 'html',
+ expect: ['html'],
+ exclude: ['element', 'fragment', 'detached'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Type selector, matching html element',
+ selector: 'html',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 1,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Type selector, matching body element',
+ selector: 'body',
+ expect: ['body'],
+ exclude: ['element', 'fragment', 'detached'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Type selector, matching body element',
+ selector: 'body',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 1,
+ testType: TEST_QSA,
+ },
+
+ // Universal Selector
+ {
+ name: 'Universal selector, matching all elements',
+ selector: '*',
+ expect: [
+ 'universal',
+ 'universal-p1',
+ 'universal-code1',
+ 'universal-hr1',
+ 'universal-pre1',
+ 'universal-span1',
+ 'universal-p2',
+ 'universal-a1',
+ 'universal-address1',
+ 'universal-code2',
+ 'universal-a2',
+ ],
+ level: 2,
+ testType: TEST_MATCH,
+ },
+ {
+ name: 'Universal selector, matching all children of element with specified ID',
+ selector: '#universal>*',
+ expect: [
+ 'universal-p1',
+ 'universal-hr1',
+ 'universal-pre1',
+ 'universal-p2',
+ 'universal-address1',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Universal selector, matching all grandchildren of element with specified ID',
+ selector: '#universal>*>*',
+ expect: ['universal-code1', 'universal-span1', 'universal-a1', 'universal-code2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Universal selector, matching all children of empty element with specified ID',
+ selector: '#empty>*',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Universal selector, matching all descendants of element with specified ID',
+ selector: '#universal *',
+ expect: [
+ 'universal-p1',
+ 'universal-code1',
+ 'universal-hr1',
+ 'universal-pre1',
+ 'universal-span1',
+ 'universal-p2',
+ 'universal-a1',
+ 'universal-address1',
+ 'universal-code2',
+ 'universal-a2',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // Attribute Selectors
+ // - presence [att]
+ {
+ name: 'Attribute presence selector, matching align attribute with value',
+ selector: '.attr-presence-div1[align]',
+ expect: ['attr-presence-div1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching align attribute with empty value',
+ selector: '.attr-presence-div2[align]',
+ expect: ['attr-presence-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching title attribute, case insensitivity',
+ selector: '#attr-presence [*|TiTlE]',
+ expect: ['attr-presence-a1', 'attr-presence-span1', 'attr-presence-i1'],
+ exclude: ['xhtml'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, not matching title attribute, case sensitivity',
+ selector: '#attr-presence [*|TiTlE]',
+ expect: [],
+ exclude: ['html'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching custom data-* attribute',
+ selector: '[data-attr-presence]',
+ expect: ['attr-presence-pre1', 'attr-presence-blockquote1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, not matching attribute with similar name',
+ selector: '.attr-presence-div3[align], .attr-presence-div4[align]',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute presence selector, matching attribute with non-ASCII characters',
+ selector: 'ul[data-中文]',
+ expect: ['attr-presence-ul1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, not matching default option without selected attribute',
+ selector: '#attr-presence-select1 option[selected]',
+ expect: [] /* no matches */,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute presence selector, matching option with selected attribute',
+ selector: '#attr-presence-select2 option[selected]',
+ expect: ['attr-presence-select2-option4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching multiple options with selected attributes',
+ selector: '#attr-presence-select3 option[selected]',
+ expect: ['attr-presence-select3-option2', 'attr-presence-select3-option3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - value [att=val]
+ {
+ name: 'Attribute value selector, matching align attribute with value',
+ selector: '#attr-value [align="center"]',
+ expect: ['attr-value-div1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, matching align attribute with value, unclosed bracket',
+ selector: '#attr-value [align="center"',
+ expect: ['attr-value-div1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, matching align attribute with empty value',
+ selector: '#attr-value [align=""]',
+ expect: ['attr-value-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, not matching align attribute with partial value',
+ selector: '#attr-value [align="c"]',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute value selector, not matching align attribute with incorrect value',
+ selector: '#attr-value [align="centera"]',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute value selector, matching custom data-* attribute with unicode escaped value',
+ selector: '[data-attr-value="\\e9"]',
+ expect: ['attr-value-div3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, matching custom data-* attribute with escaped character',
+ selector: '[data-attr-value_foo="\\e9"]',
+ expect: ['attr-value-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector with single-quoted value, matching multiple inputs with type attributes',
+ selector: "#attr-value input[type='hidden'],#attr-value input[type='radio']",
+ expect: [
+ 'attr-value-input3',
+ 'attr-value-input4',
+ 'attr-value-input6',
+ 'attr-value-input8',
+ 'attr-value-input9',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector with double-quoted value, matching multiple inputs with type attributes',
+ selector: '#attr-value input[type="hidden"],#attr-value input[type=\'radio\']',
+ expect: [
+ 'attr-value-input3',
+ 'attr-value-input4',
+ 'attr-value-input6',
+ 'attr-value-input8',
+ 'attr-value-input9',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector with unquoted value, matching multiple inputs with type attributes',
+ selector: '#attr-value input[type=hidden],#attr-value input[type=radio]',
+ expect: [
+ 'attr-value-input3',
+ 'attr-value-input4',
+ 'attr-value-input6',
+ 'attr-value-input8',
+ 'attr-value-input9',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, matching attribute with value using non-ASCII characters',
+ selector: '[data-attr-value=中文]',
+ expect: ['attr-value-div5'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - whitespace-separated list [att~=val]
+ {
+ name: 'Attribute whitespace-separated list selector, matching class attribute with value',
+ selector: '#attr-whitespace [class~="div1"]',
+ expect: ['attr-whitespace-div1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, not matching class attribute with empty value',
+ selector: '#attr-whitespace [class~=""]',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, not matching class attribute with partial value',
+ selector: '[data-attr-whitespace~="div"]',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, matching custom data-* attribute with unicode escaped value',
+ selector: '[data-attr-whitespace~="\\0000e9"]',
+ expect: ['attr-whitespace-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, matching custom data-* attribute with escaped character',
+ selector: '[data-attr-whitespace_foo~="\\e9"]',
+ expect: ['attr-whitespace-div5'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with single-quoted value, matching multiple links with rel attributes',
+ selector: "#attr-whitespace a[rel~='bookmark'], #attr-whitespace a[rel~='nofollow']",
+ expect: [
+ 'attr-whitespace-a1',
+ 'attr-whitespace-a2',
+ 'attr-whitespace-a3',
+ 'attr-whitespace-a5',
+ 'attr-whitespace-a7',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with double-quoted value, matching multiple links with rel attributes',
+ selector: '#attr-whitespace a[rel~="bookmark"],#attr-whitespace a[rel~=\'nofollow\']',
+ expect: [
+ 'attr-whitespace-a1',
+ 'attr-whitespace-a2',
+ 'attr-whitespace-a3',
+ 'attr-whitespace-a5',
+ 'attr-whitespace-a7',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with unquoted value, matching multiple links with rel attributes',
+ selector: '#attr-whitespace a[rel~=bookmark], #attr-whitespace a[rel~=nofollow]',
+ expect: [
+ 'attr-whitespace-a1',
+ 'attr-whitespace-a2',
+ 'attr-whitespace-a3',
+ 'attr-whitespace-a5',
+ 'attr-whitespace-a7',
+ ],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with double-quoted value, not matching value with space',
+ selector: '#attr-whitespace a[rel~="book mark"]',
+ expect: [] /* no matches */,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, matching title attribute with value using non-ASCII characters',
+ selector: '#attr-whitespace [title~=中文]',
+ expect: ['attr-whitespace-p1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - hyphen-separated list [att|=val]
+ {
+ name: 'Attribute hyphen-separated list selector, not matching unspecified lang attribute',
+ selector: '#attr-hyphen-div1[lang|="en"]',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute hyphen-separated list selector, matching lang attribute with exact value',
+ selector: '#attr-hyphen-div2[lang|="fr"]',
+ expect: ['attr-hyphen-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute hyphen-separated list selector, matching lang attribute with partial value',
+ selector: '#attr-hyphen-div3[lang|="en"]',
+ expect: ['attr-hyphen-div3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute hyphen-separated list selector, not matching incorrect value',
+ selector: '#attr-hyphen-div4[lang|="es-AR"]',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+
+ // - substring begins-with [att^=val] (Level 3)
+ {
+ name: 'Attribute begins with selector, matching href attributes beginning with specified substring',
+ selector: '#attr-begins a[href^="http://www"]',
+ expect: ['attr-begins-a1', 'attr-begins-a3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector, matching lang attributes beginning with specified substring, ',
+ selector: '#attr-begins [lang^="en-"]',
+ expect: ['attr-begins-div2', 'attr-begins-div4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector, not matching class attribute with empty value',
+ selector: '#attr-begins [class^=""]',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute begins with selector, not matching class attribute not beginning with specified substring',
+ selector: '#attr-begins [class^=apple]',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute begins with selector with single-quoted value, matching class attribute beginning with specified substring',
+ selector: "#attr-begins [class^=' apple']",
+ expect: ['attr-begins-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector with double-quoted value, matching class attribute beginning with specified substring',
+ selector: '#attr-begins [class^=" apple"]',
+ expect: ['attr-begins-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector with unquoted value, not matching class attribute not beginning with specified substring',
+ selector: '#attr-begins [class^= apple]',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // - substring ends-with [att$=val] (Level 3)
+ {
+ name: 'Attribute ends with selector, matching href attributes ending with specified substring',
+ selector: '#attr-ends a[href$=".org"]',
+ expect: ['attr-ends-a1', 'attr-ends-a3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector, matching lang attributes ending with specified substring, ',
+ selector: '#attr-ends [lang$="-CH"]',
+ expect: ['attr-ends-div2', 'attr-ends-div4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector, not matching class attribute with empty value',
+ selector: '#attr-ends [class$=""]',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute ends with selector, not matching class attribute not ending with specified substring',
+ selector: '#attr-ends [class$=apple]',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute ends with selector with single-quoted value, matching class attribute ending with specified substring',
+ selector: "#attr-ends [class$='apple ']",
+ expect: ['attr-ends-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector with double-quoted value, matching class attribute ending with specified substring',
+ selector: '#attr-ends [class$="apple "]',
+ expect: ['attr-ends-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector with unquoted value, not matching class attribute not ending with specified substring',
+ selector: '#attr-ends [class$=apple ]',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // - substring contains [att*=val] (Level 3)
+ {
+ name: 'Attribute contains selector, matching href attributes beginning with specified substring',
+ selector: '#attr-contains a[href*="http://www"]',
+ expect: ['attr-contains-a1', 'attr-contains-a3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching href attributes ending with specified substring',
+ selector: '#attr-contains a[href*=".org"]',
+ expect: ['attr-contains-a1', 'attr-contains-a2'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching href attributes containing specified substring',
+ selector: '#attr-contains a[href*=".example."]',
+ expect: ['attr-contains-a1', 'attr-contains-a3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching lang attributes beginning with specified substring, ',
+ selector: '#attr-contains [lang*="en-"]',
+ expect: ['attr-contains-div2', 'attr-contains-div6'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching lang attributes ending with specified substring, ',
+ selector: '#attr-contains [lang*="-CH"]',
+ expect: ['attr-contains-div3', 'attr-contains-div5'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, not matching class attribute with empty value',
+ selector: '#attr-contains [class*=""]',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Attribute contains selector with single-quoted value, matching class attribute beginning with specified substring',
+ selector: "#attr-contains [class*=' apple']",
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with single-quoted value, matching class attribute ending with specified substring',
+ selector: "#attr-contains [class*='orange ']",
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with single-quoted value, matching class attribute containing specified substring',
+ selector: "#attr-contains [class*='ple banana ora']",
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with double-quoted value, matching class attribute beginning with specified substring',
+ selector: '#attr-contains [class*=" apple"]',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with double-quoted value, matching class attribute ending with specified substring',
+ selector: '#attr-contains [class*="orange "]',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with double-quoted value, matching class attribute containing specified substring',
+ selector: '#attr-contains [class*="ple banana ora"]',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with unquoted value, matching class attribute beginning with specified substring',
+ selector: '#attr-contains [class*= apple]',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with unquoted value, matching class attribute ending with specified substring',
+ selector: '#attr-contains [class*=orange ]',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with unquoted value, matching class attribute containing specified substring',
+ selector: '#attr-contains [class*= banana ]',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // Pseudo-classes
+ // - :root (Level 3)
+ {
+ name: ':root pseudo-class selector, matching document root element',
+ selector: ':root',
+ expect: ['html'],
+ exclude: ['element', 'fragment', 'detached'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':root pseudo-class selector, not matching document root element',
+ selector: ':root',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // - :nth-child(n) (Level 3)
+ // XXX write descriptions
+ {
+ name: ':nth-child selector, matching the third child element',
+ selector: '#pseudo-nth-table1 :nth-child(3)',
+ expect: ['pseudo-nth-td3', 'pseudo-nth-td9', 'pseudo-nth-tr3', 'pseudo-nth-td15'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every third child element',
+ selector: '#pseudo-nth li:nth-child(3n)',
+ expect: ['pseudo-nth-li3', 'pseudo-nth-li6', 'pseudo-nth-li9', 'pseudo-nth-li12'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every second child element, starting from the fourth',
+ selector: '#pseudo-nth li:nth-child(2n+4)',
+ expect: [
+ 'pseudo-nth-li4',
+ 'pseudo-nth-li6',
+ 'pseudo-nth-li8',
+ 'pseudo-nth-li10',
+ 'pseudo-nth-li12',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every fourth child element, starting from the third',
+ selector: '#pseudo-nth-p1 :nth-child(4n-1)',
+ expect: ['pseudo-nth-em2', 'pseudo-nth-span3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :nth-last-child (Level 3)
+ {
+ name: ':nth-last-child selector, matching the third last child element',
+ selector: '#pseudo-nth-table1 :nth-last-child(3)',
+ expect: ['pseudo-nth-tr1', 'pseudo-nth-td4', 'pseudo-nth-td10', 'pseudo-nth-td16'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-child selector, matching every third child element from the end',
+ selector: '#pseudo-nth li:nth-last-child(3n)',
+ expect: ['pseudo-nth-li1', 'pseudo-nth-li4', 'pseudo-nth-li7', 'pseudo-nth-li10'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-child selector, matching every second child element from the end, starting from the fourth last',
+ selector: '#pseudo-nth li:nth-last-child(2n+4)',
+ expect: [
+ 'pseudo-nth-li1',
+ 'pseudo-nth-li3',
+ 'pseudo-nth-li5',
+ 'pseudo-nth-li7',
+ 'pseudo-nth-li9',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-child selector, matching every fourth element from the end, starting from the third last',
+ selector: '#pseudo-nth-p1 :nth-last-child(4n-1)',
+ expect: ['pseudo-nth-span2', 'pseudo-nth-span4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :nth-of-type(n) (Level 3)
+ {
+ name: ':nth-of-type selector, matching the third em element',
+ selector: '#pseudo-nth-p1 em:nth-of-type(3)',
+ expect: ['pseudo-nth-em3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-of-type selector, matching every second element of their type',
+ selector: '#pseudo-nth-p1 :nth-of-type(2n)',
+ expect: [
+ 'pseudo-nth-em2',
+ 'pseudo-nth-span2',
+ 'pseudo-nth-span4',
+ 'pseudo-nth-strong2',
+ 'pseudo-nth-em4',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-of-type selector, matching every second elemetn of their type, starting from the first',
+ selector: '#pseudo-nth-p1 span:nth-of-type(2n-1)',
+ expect: ['pseudo-nth-span1', 'pseudo-nth-span3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :nth-last-of-type(n) (Level 3)
+ {
+ name: ':nth-last-of-type selector, matching the third last em element',
+ selector: '#pseudo-nth-p1 em:nth-last-of-type(3)',
+ expect: ['pseudo-nth-em2'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-of-type selector, matching every second last element of their type',
+ selector: '#pseudo-nth-p1 :nth-last-of-type(2n)',
+ expect: [
+ 'pseudo-nth-span1',
+ 'pseudo-nth-em1',
+ 'pseudo-nth-strong1',
+ 'pseudo-nth-em3',
+ 'pseudo-nth-span3',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-of-type selector, matching every second last element of their type, starting from the last',
+ selector: '#pseudo-nth-p1 span:nth-last-of-type(2n-1)',
+ expect: ['pseudo-nth-span2', 'pseudo-nth-span4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :first-of-type (Level 3)
+ {
+ name: ':first-of-type selector, matching the first em element',
+ selector: '#pseudo-nth-p1 em:first-of-type',
+ expect: ['pseudo-nth-em1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':first-of-type selector, matching the first of every type of element',
+ selector: '#pseudo-nth-p1 :first-of-type',
+ expect: ['pseudo-nth-span1', 'pseudo-nth-em1', 'pseudo-nth-strong1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':first-of-type selector, matching the first td element in each table row',
+ selector: '#pseudo-nth-table1 tr :first-of-type',
+ expect: ['pseudo-nth-td1', 'pseudo-nth-td7', 'pseudo-nth-td13'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :last-of-type (Level 3)
+ {
+ name: ':last-of-type selector, matching the last em elemnet',
+ selector: '#pseudo-nth-p1 em:last-of-type',
+ expect: ['pseudo-nth-em4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':last-of-type selector, matching the last of every type of element',
+ selector: '#pseudo-nth-p1 :last-of-type',
+ expect: ['pseudo-nth-span4', 'pseudo-nth-strong2', 'pseudo-nth-em4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':last-of-type selector, matching the last td element in each table row',
+ selector: '#pseudo-nth-table1 tr :last-of-type',
+ expect: ['pseudo-nth-td6', 'pseudo-nth-td12', 'pseudo-nth-td18'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :first-child
+ {
+ name: ':first-child pseudo-class selector, matching first child div element',
+ selector: '#pseudo-first-child div:first-child',
+ expect: ['pseudo-first-child-div1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ":first-child pseudo-class selector, doesn't match non-first-child elements",
+ selector: '.pseudo-first-child-div2:first-child, .pseudo-first-child-div3:first-child',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: ':first-child pseudo-class selector, matching first-child of multiple elements',
+ selector: '#pseudo-first-child span:first-child',
+ expect: ['pseudo-first-child-span1', 'pseudo-first-child-span3', 'pseudo-first-child-span5'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :last-child (Level 3)
+ {
+ name: ':last-child pseudo-class selector, matching last child div element',
+ selector: '#pseudo-last-child div:last-child',
+ expect: ['pseudo-last-child-div3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ":last-child pseudo-class selector, doesn't match non-last-child elements",
+ selector: '.pseudo-last-child-div1:last-child, .pseudo-last-child-div2:first-child',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: ':last-child pseudo-class selector, matching first-child of multiple elements',
+ selector: '#pseudo-last-child span:last-child',
+ expect: ['pseudo-last-child-span2', 'pseudo-last-child-span4', 'pseudo-last-child-span6'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :only-child (Level 3)
+ {
+ name: ':pseudo-only-child pseudo-class selector, matching all only-child elements',
+ selector: '#pseudo-only :only-child',
+ expect: ['pseudo-only-span1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':pseudo-only-child pseudo-class selector, matching only-child em elements',
+ selector: '#pseudo-only em:only-child',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // - :only-of-type (Level 3)
+ {
+ name: ':pseudo-only-of-type pseudo-class selector, matching all elements with no siblings of the same type',
+ selector: '#pseudo-only :only-of-type',
+ expect: ['pseudo-only-span1', 'pseudo-only-em1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':pseudo-only-of-type pseudo-class selector, matching em elements with no siblings of the same type',
+ selector: '#pseudo-only em:only-of-type',
+ expect: ['pseudo-only-em1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :empty (Level 3)
+ {
+ name: ':empty pseudo-class selector, matching empty p elements',
+ selector: '#pseudo-empty p:empty',
+ expect: ['pseudo-empty-p1', 'pseudo-empty-p2'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':empty pseudo-class selector, matching all empty elements',
+ selector: '#pseudo-empty :empty',
+ expect: ['pseudo-empty-p1', 'pseudo-empty-p2', 'pseudo-empty-span1'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :link and :visited
+ // Implementations may treat all visited links as unvisited, so these cannot be tested separately.
+ // The only guarantee is that ":link,:visited" matches the set of all visited and unvisited links and that they are individually mutually exclusive sets.
+ {
+ name: ':link and :visited pseudo-class selectors, matching a and area elements with href attributes',
+ selector: '#pseudo-link :link, #pseudo-link :visited',
+ expect: ['pseudo-link-a1', 'pseudo-link-a2', 'pseudo-link-area1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':link and :visited pseudo-class selectors, matching no elements',
+ selector: '#head :link, #head :visited',
+ expect: [] /*no matches*/,
+ exclude: ['element', 'fragment', 'detached'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':link and :visited pseudo-class selectors, not matching link elements with href attributes',
+ selector: '#head :link, #head :visited',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 1,
+ testType: TEST_QSA,
+ },
+ {
+ name: ':link and :visited pseudo-class selectors, chained, mutually exclusive pseudo-classes match nothing',
+ selector: ':link:visited',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 1,
+ testType: TEST_QSA,
+ },
+
+ // - :target (Level 3)
+ {
+ name: ':target pseudo-class selector, matching the element referenced by the URL fragment identifier',
+ selector: ':target',
+ expect: [] /*no matches*/,
+ exclude: ['document', 'element'],
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: ':target pseudo-class selector, matching the element referenced by the URL fragment identifier',
+ selector: ':target',
+ expect: ['target'],
+ exclude: ['fragment', 'detached'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :lang()
+ {
+ name: ':lang pseudo-class selector, matching inherited language',
+ selector: '#pseudo-lang-div1:lang(en)',
+ expect: ['pseudo-lang-div1'],
+ exclude: ['detached', 'fragment'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':lang pseudo-class selector, not matching element with no inherited language',
+ selector: '#pseudo-lang-div1:lang(en)',
+ expect: [] /*no matches*/,
+ exclude: ['document', 'element'],
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: ':lang pseudo-class selector, matching specified language with exact value',
+ selector: '#pseudo-lang-div2:lang(fr)',
+ expect: ['pseudo-lang-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':lang pseudo-class selector, matching specified language with partial value',
+ selector: '#pseudo-lang-div3:lang(en)',
+ expect: ['pseudo-lang-div3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':lang pseudo-class selector, not matching incorrect language',
+ selector: '#pseudo-lang-div4:lang(es-AR)',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+
+ // - :enabled (Level 3)
+ {
+ name: ':enabled pseudo-class selector, matching all enabled form controls',
+ selector: '#pseudo-ui :enabled',
+ expect: [
+ 'pseudo-ui-input1',
+ 'pseudo-ui-input2',
+ 'pseudo-ui-input3',
+ 'pseudo-ui-input4',
+ 'pseudo-ui-input5',
+ 'pseudo-ui-input6',
+ 'pseudo-ui-input7',
+ 'pseudo-ui-input8',
+ 'pseudo-ui-input9',
+ 'pseudo-ui-textarea1',
+ 'pseudo-ui-button1',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':enabled pseudo-class selector, not matching link elements',
+ selector: '#pseudo-link :enabled',
+ expect: [] /*no matches*/,
+ unexpected: [
+ 'pseudo-link-a1',
+ 'pseudo-link-a2',
+ 'pseudo-link-a3',
+ 'pseudo-link-map1',
+ 'pseudo-link-area1',
+ 'pseudo-link-area2',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :disabled (Level 3)
+ {
+ name: ':disabled pseudo-class selector, matching all disabled form controls',
+ selector: '#pseudo-ui :disabled',
+ expect: [
+ 'pseudo-ui-input10',
+ 'pseudo-ui-input11',
+ 'pseudo-ui-input12',
+ 'pseudo-ui-input13',
+ 'pseudo-ui-input14',
+ 'pseudo-ui-input15',
+ 'pseudo-ui-input16',
+ 'pseudo-ui-input17',
+ 'pseudo-ui-input18',
+ 'pseudo-ui-textarea2',
+ 'pseudo-ui-button2',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':disabled pseudo-class selector, not matching link elements',
+ selector: '#pseudo-link :disabled',
+ expect: [] /*no matches*/,
+ unexpected: [
+ 'pseudo-link-a1',
+ 'pseudo-link-a2',
+ 'pseudo-link-a3',
+ 'pseudo-link-map1',
+ 'pseudo-link-area1',
+ 'pseudo-link-area2',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :checked (Level 3)
+ {
+ name: ':checked pseudo-class selector, matching checked radio buttons and checkboxes',
+ selector: '#pseudo-ui :checked',
+ expect: ['pseudo-ui-input4', 'pseudo-ui-input6', 'pseudo-ui-input13', 'pseudo-ui-input15'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :not(s) (Level 3)
+ {
+ name: ':not pseudo-class selector, matching ',
+ selector: '#not>:not(div)',
+ expect: ['not-p1', 'not-p2', 'not-p3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':not pseudo-class selector, matching ',
+ selector: '#not * :not(:first-child)',
+ expect: ['not-em1', 'not-em2', 'not-em3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: ':not pseudo-class selector, matching nothing',
+ selector: ':not(*)',
+ expect: [] /* no matches */,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: ':not pseudo-class selector, matching nothing',
+ selector: ':not(*|*)',
+ expect: [] /* no matches */,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: ':not pseudo-class selector argument surrounded by spaces, matching ',
+ selector: '#not>:not( div )',
+ expect: ['not-p1', 'not-p2', 'not-p3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // Pseudo-elements
+ // - ::first-line
+ {
+ name: ':first-line pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:first-line',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: '::first-line pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::first-line',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // - ::first-letter
+ {
+ name: ':first-letter pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:first-letter',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: '::first-letter pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::first-letter',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // - ::before
+ {
+ name: ':before pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:before',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: '::before pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::before',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // - ::after
+ {
+ name: ':after pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:after',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: '::after pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::after',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // Class Selectors
+ {
+ name: 'Class selector, matching element with specified class',
+ selector: '.class-p',
+ expect: ['class-p1', 'class-p2', 'class-p3'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, chained, matching only elements with all specified classes',
+ selector: '#class .apple.orange.banana',
+ expect: ['class-div1', 'class-div2', 'class-p4', 'class-div3', 'class-p6', 'class-div4'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Class Selector, chained, with type selector',
+ selector: 'div.apple.banana.orange',
+ expect: ['class-div1', 'class-div2', 'class-div3', 'class-div4'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching element with class value using non-ASCII characters (1)',
+ selector: '.\u53F0\u5317Ta\u0301ibe\u030Ci',
+ expect: ['class-span1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching multiple elements with class value using non-ASCII characters',
+ selector: '.\u53F0\u5317',
+ expect: ['class-span1', 'class-span2'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, chained, matching element with multiple class values using non-ASCII characters (1)',
+ selector: '.\u53F0\u5317Ta\u0301ibe\u030Ci.\u53F0\u5317',
+ expect: ['class-span1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching element with class with escaped character',
+ selector: '.foo\\:bar',
+ expect: ['class-span3'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching element with class with escaped character',
+ selector: '.test\\.foo\\[5\\]bar',
+ expect: ['class-span4'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // ID Selectors
+ {
+ name: 'ID selector, matching element with specified id',
+ selector: '#id #id-div1',
+ expect: ['id-div1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, chained, matching element with specified id',
+ selector: '#id-div1, #id-div1',
+ expect: ['id-div1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, chained, matching element with specified id',
+ selector: '#id-div1, #id-div2',
+ expect: ['id-div1', 'id-div2'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'ID Selector, chained, with type selector',
+ selector: 'div#id-div1, div#id-div2',
+ expect: ['id-div1', 'id-div2'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, not matching non-existent descendant',
+ selector: '#id #none',
+ expect: [] /*no matches*/,
+ level: 1,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'ID selector, not matching non-existent ancestor',
+ selector: '#none #id-div1',
+ expect: [] /*no matches*/,
+ level: 1,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'ID selector, matching multiple elements with duplicate id',
+ selector: '#id-li-duplicate',
+ expect: ['id-li-duplicate', 'id-li-duplicate', 'id-li-duplicate', 'id-li-duplicate'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ {
+ name: 'ID selector, matching id value using non-ASCII characters (1)',
+ selector: '#\u53F0\u5317Ta\u0301ibe\u030Ci',
+ expect: ['\u53F0\u5317Ta\u0301ibe\u030Ci'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, matching id value using non-ASCII characters (2)',
+ selector: '#\u53F0\u5317',
+ expect: ['\u53F0\u5317'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, matching id values using non-ASCII characters (1)',
+ selector: '#\u53F0\u5317Ta\u0301ibe\u030Ci, #\u53F0\u5317',
+ expect: ['\u53F0\u5317Ta\u0301ibe\u030Ci', '\u53F0\u5317'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // XXX runMatchesTest() in level2-lib.js can't handle this because obtaining the expected nodes requires escaping characters when generating the selector from 'expect' values
+ {
+ name: 'ID selector, matching element with id with escaped character',
+ selector: '#\\#foo\\:bar',
+ expect: ['#foo:bar'],
+ level: 1,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'ID selector, matching element with id with escaped character',
+ selector: '#test\\.foo\\[5\\]bar',
+ expect: ['test.foo[5]bar'],
+ level: 1,
+ testType: TEST_QSA,
+ },
+
+ // Namespaces
+ // XXX runMatchesTest() in level2-lib.js can't handle these because non-HTML elements don't have a recognised id
+ {
+ name: 'Namespace selector, matching element with any namespace',
+ selector: '#any-namespace *|div',
+ expect: [
+ 'any-namespace-div1',
+ 'any-namespace-div2',
+ 'any-namespace-div3',
+ 'any-namespace-div4',
+ ],
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Namespace selector, matching div elements in no namespace only',
+ selector: '#no-namespace |div',
+ expect: ['no-namespace-div3'],
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Namespace selector, matching any elements in no namespace only',
+ selector: '#no-namespace |*',
+ expect: ['no-namespace-div3'],
+ level: 3,
+ testType: TEST_QSA,
+ },
+
+ // Combinators
+ // - Descendant combinator ' '
+ {
+ name: 'Descendant combinator, matching element that is a descendant of an element with id',
+ selector: '#descendant div',
+ expect: ['descendant-div1', 'descendant-div2', 'descendant-div3', 'descendant-div4'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with id that is a descendant of an element',
+ selector: 'body #descendant-div1',
+ expect: ['descendant-div1'],
+ exclude: ['detached', 'fragment'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with id that is a descendant of an element',
+ selector: 'div #descendant-div1',
+ expect: ['descendant-div1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with id that is a descendant of an element with id',
+ selector: '#descendant #descendant-div2',
+ expect: ['descendant-div2'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with class that is a descendant of an element with id',
+ selector: '#descendant .descendant-div2',
+ expect: ['descendant-div2'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with class that is a descendant of an element with class',
+ selector: '.descendant-div1 .descendant-div3',
+ expect: ['descendant-div3'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, not matching element with id that is not a descendant of an element with id',
+ selector: '#descendant-div1 #descendant-div4',
+ expect: [] /*no matches*/,
+ level: 1,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Descendant combinator, whitespace characters',
+ selector: '#descendant\t\r\n#descendant-div2',
+ expect: ['descendant-div2'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ /* The future of this combinator is uncertain, see
+ * https://github.com/w3c/csswg-drafts/issues/641
+ * These tests are commented out until a final decision is made on whether to
+ * keep the feature in the spec.
+ */
+
+ // // - Descendant combinator '>>'
+ // {name: "Descendant combinator '>>', matching element that is a descendant of an element with id", selector: "#descendant>>div", expect: ["descendant-div1", "descendant-div2", "descendant-div3", "descendant-div4"], level: 1, testType: TEST_QSA | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with id that is a descendant of an element", selector: "body>>#descendant-div1", expect: ["descendant-div1"], exclude: ["detached", "fragment"], level: 1, testType: TEST_QSA | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with id that is a descendant of an element", selector: "div>>#descendant-div1", expect: ["descendant-div1"], level: 1, testType: TEST_QSA | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with id that is a descendant of an element with id", selector: "#descendant>>#descendant-div2", expect: ["descendant-div2"], level: 1, testType: TEST_QSA | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with class that is a descendant of an element with id", selector: "#descendant>>.descendant-div2", expect: ["descendant-div2"], level: 1, testType: TEST_QSA | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with class that is a descendant of an element with class", selector: ".descendant-div1>>.descendant-div3", expect: ["descendant-div3"], level: 1, testType: TEST_QSA | TEST_MATCH},
+ // {name: "Descendant combinator '>>', not matching element with id that is not a descendant of an element with id", selector: "#descendant-div1>>#descendant-div4", expect: [] /*no matches*/, level: 1, testType: TEST_QSA},
+
+ // - Child combinator '>'
+ {
+ name: 'Child combinator, matching element that is a child of an element with id',
+ selector: '#child>div',
+ expect: ['child-div1', 'child-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with id that is a child of an element',
+ selector: 'div>#child-div1',
+ expect: ['child-div1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with id that is a child of an element with id',
+ selector: '#child>#child-div1',
+ expect: ['child-div1'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with id that is a child of an element with class',
+ selector: '#child-div1>.child-div2',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with class that is a child of an element with class',
+ selector: '.child-div1>.child-div2',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, not matching element with id that is not a child of an element with id',
+ selector: '#child>#child-div3',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Child combinator, not matching element with id that is not a child of an element with class',
+ selector: '#child-div1>.child-div3',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Child combinator, not matching element with class that is not a child of an element with class',
+ selector: '.child-div1>.child-div3',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Child combinator, surrounded by whitespace',
+ selector: '#child-div1\t\r\n>\t\r\n#child-div2',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, whitespace after',
+ selector: '#child-div1>\t\r\n#child-div2',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, whitespace before',
+ selector: '#child-div1\t\r\n>#child-div2',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, no whitespace',
+ selector: '#child-div1>#child-div2',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - Adjacent sibling combinator '+'
+ {
+ name: 'Adjacent sibling combinator, matching element that is an adjacent sibling of an element with id',
+ selector: '#adjacent-div2+div',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with id that is an adjacent sibling of an element',
+ selector: 'div+#adjacent-div4',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with id that is an adjacent sibling of an element with id',
+ selector: '#adjacent-div2+#adjacent-div4',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with class that is an adjacent sibling of an element with id',
+ selector: '#adjacent-div2+.adjacent-div4',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with class that is an adjacent sibling of an element with class',
+ selector: '.adjacent-div2+.adjacent-div4',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching p element that is an adjacent sibling of a div element',
+ selector: '#adjacent div+p',
+ expect: ['adjacent-p2'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, not matching element with id that is not an adjacent sibling of an element with id',
+ selector: '#adjacent-div2+#adjacent-p2, #adjacent-div2+#adjacent-div1',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Adjacent sibling combinator, surrounded by whitespace',
+ selector: '#adjacent-p2\t\r\n+\t\r\n#adjacent-p3',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, whitespace after',
+ selector: '#adjacent-p2+\t\r\n#adjacent-p3',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, whitespace before',
+ selector: '#adjacent-p2\t\r\n+#adjacent-p3',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, no whitespace',
+ selector: '#adjacent-p2+#adjacent-p3',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - General sibling combinator ~ (Level 3)
+ {
+ name: 'General sibling combinator, matching element that is a sibling of an element with id',
+ selector: '#sibling-div2~div',
+ expect: ['sibling-div4', 'sibling-div6'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching element with id that is a sibling of an element',
+ selector: 'div~#sibling-div4',
+ expect: ['sibling-div4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching element with id that is a sibling of an element with id',
+ selector: '#sibling-div2~#sibling-div4',
+ expect: ['sibling-div4'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching element with class that is a sibling of an element with id',
+ selector: '#sibling-div2~.sibling-div',
+ expect: ['sibling-div4', 'sibling-div6'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching p element that is a sibling of a div element',
+ selector: '#sibling div~p',
+ expect: ['sibling-p2', 'sibling-p3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, not matching element with id that is not a sibling after a p element',
+ selector: '#sibling>p~div',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'General sibling combinator, not matching element with id that is not a sibling after an element with id',
+ selector: '#sibling-div2~#sibling-div3, #sibling-div2~#sibling-div1',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'General sibling combinator, surrounded by whitespace',
+ selector: '#sibling-p2\t\r\n~\t\r\n#sibling-p3',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, whitespace after',
+ selector: '#sibling-p2~\t\r\n#sibling-p3',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, whitespace before',
+ selector: '#sibling-p2\t\r\n~#sibling-p3',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, no whitespace',
+ selector: '#sibling-p2~#sibling-p3',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // Group of selectors (comma)
+ {
+ name: 'Syntax, group of selectors separator, surrounded by whitespace',
+ selector: '#group em\t\r \n,\t\r \n#group strong',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Syntax, group of selectors separator, whitespace after',
+ selector: '#group em,\t\r\n#group strong',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Syntax, group of selectors separator, whitespace before',
+ selector: '#group em\t\r\n,#group strong',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+ {
+ name: 'Syntax, group of selectors separator, no whitespace',
+ selector: '#group em,#group strong',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // ::slotted (shouldn't match anything, but is a valid selector)
+ {
+ name: 'Slotted selector',
+ selector: '::slotted(foo)',
+ expect: [],
+ level: 3,
+ testType: TEST_QSA,
+ },
+ {
+ name: 'Slotted selector (no matching closing paren)',
+ selector: '::slotted(foo',
+ expect: [],
+ level: 3,
+ testType: TEST_QSA,
+ },
+];
+
+/*
+ * These selectors are intended to be used with the find(), findAll() and matches() methods. Expected results
+ * should be determined under the assumption that :scope will be prepended to the selector where appropriate,
+ * in accordance with the specification.
+ *
+ * All of these should be valid relative selectors, expected to match zero or more elements in the document.
+ * None should throw any errors.
+ *
+ * name: A descriptive name of the selector being tested
+ *
+ * selector: The selector to test
+ *
+ * ctx: A selector to obtain the context object to use for tests invoking context.find(),
+ * and to use as a single reference node for tests invoking document.find().
+ * Note: context = root.querySelector(ctx);
+ *
+ * ref: A selector to obtain the reference nodes to be used for the selector.
+ * Note: If root is the document or an in-document element:
+ * refNodes = document.querySelectorAll(ref);
+ * Otherwise, if root is a fragment or detached element:
+ * refNodes = root.querySelectorAll(ref);
+ *
+ * expect: A list of IDs of the elements expected to be matched. List must be given in tree order.
+ *
+ * unexpected: A list of IDs of some elements that are not expected to match the given selector.
+ * This is used to verify that unexpected.matches(selector, refNode) does not match.
+ *
+ * exclude: An array of contexts to exclude from testing. The valid values are:
+ * ["document", "element", "fragment", "detached", "html", "xhtml"]
+ * The "html" and "xhtml" values represent the type of document being queried. These are useful
+ * for tests that are affected by differences between HTML and XML, such as case sensitivity.
+ *
+ * level: An integer indicating the CSS or Selectors level in which the selector being tested was introduced.
+ *
+ * testType: A bit-mapped flag indicating the type of test.
+ *
+ * The test function for these tests accepts a specified root node, on which the methods will be invoked during the tests.
+ *
+ * Based on whether either 'context' or 'refNodes', or both, are specified the tests will execute the following methods:
+ *
+ * Where testType is TEST_FIND:
+ *
+ * context.findAll(selector, refNodes)
+ * context.findAll(selector) // Only if refNodes is not specified
+ * root.findAll(selector, context) // Only if refNodes is not specified
+ * root.findAll(selector, refNodes) // Only if context is not specified
+ * root.findAll(selector) // Only if neither context nor refNodes is specified
+ *
+ * Where testType is TEST_QSA
+ *
+ * context.querySelectorAll(selector)
+ * root.querySelectorAll(selector) // Only if neither context nor refNodes is specified
+ *
+ * Equivalent tests will be run for .find() as well.
+ * Note: Do not specify a testType of TEST_QSA where either implied :scope or explicit refNodes
+ * are required.
+ *
+ * Where testType is TEST_MATCH:
+ * For each expected result given, within the specified root:
+ *
+ * expect.matches(selector, context) // Only where refNodes is not specified
+ * expect.matches(selector, refNodes)
+ * expect.matches(selector) // Only if neither context nor refNodes is specified
+ *
+ * The tests involving refNodes for both find(), findAll() and matches() will each be run by passing the
+ * collection as a NodeList, an Array and, if there is only a single element, an Element node.
+ *
+ * Note: Interactive pseudo-classes (:active :hover and :focus) have not been tested in this test suite.
+ */
+
+export const scopedSelectors: readonly Selector[] = [
+ //{name: "", selector: "", ctx: "", ref: "", expect: [], level: 1, testType: TEST_FIND | TEST_MATCH},
+
+ // Attribute Selectors
+ // - presence [att]
+ {
+ name: 'Attribute presence selector, matching align attribute with value',
+ selector: '.attr-presence-div1[align]',
+ ctx: '#attr-presence',
+ expect: ['attr-presence-div1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching align attribute with empty value',
+ selector: '.attr-presence-div2[align]',
+ ctx: '#attr-presence',
+ expect: ['attr-presence-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching title attribute, case insensitivity',
+ selector: '[TiTlE]',
+ ctx: '#attr-presence',
+ expect: ['attr-presence-a1', 'attr-presence-span1'],
+ exclude: ['xhtml'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, not matching title attribute, case sensitivity',
+ selector: '[TiTlE]',
+ ctx: '#attr-presence',
+ expect: [],
+ exclude: ['html'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching custom data-* attribute',
+ selector: '[data-attr-presence]',
+ ctx: '#attr-presence',
+ expect: ['attr-presence-pre1', 'attr-presence-blockquote1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, not matching attribute with similar name',
+ selector: '.attr-presence-div3[align], .attr-presence-div4[align]',
+ ctx: '#attr-presence',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute presence selector, matching attribute with non-ASCII characters',
+ selector: 'ul[data-中文]',
+ ctx: '#attr-presence',
+ expect: ['attr-presence-ul1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, not matching default option without selected attribute',
+ selector: '#attr-presence-select1 option[selected]',
+ ctx: '#attr-presence',
+ expect: [] /* no matches */,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute presence selector, matching option with selected attribute',
+ selector: '#attr-presence-select2 option[selected]',
+ ctx: '#attr-presence',
+ expect: ['attr-presence-select2-option4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute presence selector, matching multiple options with selected attributes',
+ selector: '#attr-presence-select3 option[selected]',
+ ctx: '#attr-presence',
+ expect: ['attr-presence-select3-option2', 'attr-presence-select3-option3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - value [att=val]
+ {
+ name: 'Attribute value selector, matching align attribute with value',
+ selector: '[align="center"]',
+ ctx: '#attr-value',
+ expect: ['attr-value-div1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, matching align attribute with empty value',
+ selector: '[align=""]',
+ ctx: '#attr-value',
+ expect: ['attr-value-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, not matching align attribute with partial value',
+ selector: '[align="c"]',
+ ctx: '#attr-value',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute value selector, not matching align attribute with incorrect value',
+ selector: '[align="centera"]',
+ ctx: '#attr-value',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute value selector, matching custom data-* attribute with unicode escaped value',
+ selector: '[data-attr-value="\\e9"]',
+ ctx: '#attr-value',
+ expect: ['attr-value-div3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, matching custom data-* attribute with escaped character',
+ selector: '[data-attr-value_foo="\\e9"]',
+ ctx: '#attr-value',
+ expect: ['attr-value-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector with single-quoted value, matching multiple inputs with type attributes',
+ selector: "input[type='hidden'],#attr-value input[type='radio']",
+ ctx: '#attr-value',
+ expect: [
+ 'attr-value-input3',
+ 'attr-value-input4',
+ 'attr-value-input6',
+ 'attr-value-input8',
+ 'attr-value-input9',
+ ],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector with double-quoted value, matching multiple inputs with type attributes',
+ selector: 'input[type="hidden"],#attr-value input[type=\'radio\']',
+ ctx: '#attr-value',
+ expect: [
+ 'attr-value-input3',
+ 'attr-value-input4',
+ 'attr-value-input6',
+ 'attr-value-input8',
+ 'attr-value-input9',
+ ],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector with unquoted value, matching multiple inputs with type attributes',
+ selector: 'input[type=hidden],#attr-value input[type=radio]',
+ ctx: '#attr-value',
+ expect: [
+ 'attr-value-input3',
+ 'attr-value-input4',
+ 'attr-value-input6',
+ 'attr-value-input8',
+ 'attr-value-input9',
+ ],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute value selector, matching attribute with value using non-ASCII characters',
+ selector: '[data-attr-value=中文]',
+ ctx: '#attr-value',
+ expect: ['attr-value-div5'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - whitespace-separated list [att~=val]
+ {
+ name: 'Attribute whitespace-separated list selector, matching class attribute with value',
+ selector: '[class~="div1"]',
+ ctx: '#attr-whitespace',
+ expect: ['attr-whitespace-div1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, not matching class attribute with empty value',
+ selector: '[class~=""]',
+ ctx: '#attr-whitespace',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, not matching class attribute with partial value',
+ selector: '[data-attr-whitespace~="div"]',
+ ctx: '#attr-whitespace',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, matching custom data-* attribute with unicode escaped value',
+ selector: '[data-attr-whitespace~="\\0000e9"]',
+ ctx: '#attr-whitespace',
+ expect: ['attr-whitespace-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, matching custom data-* attribute with escaped character',
+ selector: '[data-attr-whitespace_foo~="\\e9"]',
+ ctx: '#attr-whitespace',
+ expect: ['attr-whitespace-div5'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with single-quoted value, matching multiple links with rel attributes',
+ selector: "a[rel~='bookmark'], #attr-whitespace a[rel~='nofollow']",
+ ctx: '#attr-whitespace',
+ expect: [
+ 'attr-whitespace-a1',
+ 'attr-whitespace-a2',
+ 'attr-whitespace-a3',
+ 'attr-whitespace-a5',
+ 'attr-whitespace-a7',
+ ],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with double-quoted value, matching multiple links with rel attributes',
+ selector: 'a[rel~="bookmark"],#attr-whitespace a[rel~=\'nofollow\']',
+ ctx: '#attr-whitespace',
+ expect: [
+ 'attr-whitespace-a1',
+ 'attr-whitespace-a2',
+ 'attr-whitespace-a3',
+ 'attr-whitespace-a5',
+ 'attr-whitespace-a7',
+ ],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with unquoted value, matching multiple links with rel attributes',
+ selector: 'a[rel~=bookmark], #attr-whitespace a[rel~=nofollow]',
+ ctx: '#attr-whitespace',
+ expect: [
+ 'attr-whitespace-a1',
+ 'attr-whitespace-a2',
+ 'attr-whitespace-a3',
+ 'attr-whitespace-a5',
+ 'attr-whitespace-a7',
+ ],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector with double-quoted value, not matching value with space',
+ selector: 'a[rel~="book mark"]',
+ ctx: '#attr-whitespace',
+ expect: [] /* no matches */,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute whitespace-separated list selector, matching title attribute with value using non-ASCII characters',
+ selector: '[title~=中文]',
+ ctx: '#attr-whitespace',
+ expect: ['attr-whitespace-p1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - hyphen-separated list [att|=val]
+ {
+ name: 'Attribute hyphen-separated list selector, not matching unspecified lang attribute',
+ selector: '#attr-hyphen-div1[lang|="en"]',
+ ctx: '#attr-hyphen',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute hyphen-separated list selector, matching lang attribute with exact value',
+ selector: '#attr-hyphen-div2[lang|="fr"]',
+ ctx: '#attr-hyphen',
+ expect: ['attr-hyphen-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute hyphen-separated list selector, matching lang attribute with partial value',
+ selector: '#attr-hyphen-div3[lang|="en"]',
+ ctx: '#attr-hyphen',
+ expect: ['attr-hyphen-div3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute hyphen-separated list selector, not matching incorrect value',
+ selector: '#attr-hyphen-div4[lang|="es-AR"]',
+ ctx: '#attr-hyphen',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+
+ // - substring begins-with [att^=val] (Level 3)
+ {
+ name: 'Attribute begins with selector, matching href attributes beginning with specified substring',
+ selector: 'a[href^="http://www"]',
+ ctx: '#attr-begins',
+ expect: ['attr-begins-a1', 'attr-begins-a3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector, matching lang attributes beginning with specified substring, ',
+ selector: '[lang^="en-"]',
+ ctx: '#attr-begins',
+ expect: ['attr-begins-div2', 'attr-begins-div4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector, not matching class attribute with empty value',
+ selector: '[class^=""]',
+ ctx: '#attr-begins',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute begins with selector, not matching class attribute not beginning with specified substring',
+ selector: '[class^=apple]',
+ ctx: '#attr-begins',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute begins with selector with single-quoted value, matching class attribute beginning with specified substring',
+ selector: "[class^=' apple']",
+ ctx: '#attr-begins',
+ expect: ['attr-begins-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector with double-quoted value, matching class attribute beginning with specified substring',
+ selector: '[class^=" apple"]',
+ ctx: '#attr-begins',
+ expect: ['attr-begins-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute begins with selector with unquoted value, not matching class attribute not beginning with specified substring',
+ selector: '[class^= apple]',
+ ctx: '#attr-begins',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // - substring ends-with [att$=val] (Level 3)
+ {
+ name: 'Attribute ends with selector, matching href attributes ending with specified substring',
+ selector: 'a[href$=".org"]',
+ ctx: '#attr-ends',
+ expect: ['attr-ends-a1', 'attr-ends-a3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector, matching lang attributes ending with specified substring, ',
+ selector: '[lang$="-CH"]',
+ ctx: '#attr-ends',
+ expect: ['attr-ends-div2', 'attr-ends-div4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector, not matching class attribute with empty value',
+ selector: '[class$=""]',
+ ctx: '#attr-ends',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute ends with selector, not matching class attribute not ending with specified substring',
+ selector: '[class$=apple]',
+ ctx: '#attr-ends',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute ends with selector with single-quoted value, matching class attribute ending with specified substring',
+ selector: "[class$='apple ']",
+ ctx: '#attr-ends',
+ expect: ['attr-ends-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector with double-quoted value, matching class attribute ending with specified substring',
+ selector: '[class$="apple "]',
+ ctx: '#attr-ends',
+ expect: ['attr-ends-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute ends with selector with unquoted value, not matching class attribute not ending with specified substring',
+ selector: '[class$=apple ]',
+ ctx: '#attr-ends',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // - substring contains [att*=val] (Level 3)
+ {
+ name: 'Attribute contains selector, matching href attributes beginning with specified substring',
+ selector: 'a[href*="http://www"]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-a1', 'attr-contains-a3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching href attributes ending with specified substring',
+ selector: 'a[href*=".org"]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-a1', 'attr-contains-a2'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching href attributes containing specified substring',
+ selector: 'a[href*=".example."]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-a1', 'attr-contains-a3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching lang attributes beginning with specified substring, ',
+ selector: '[lang*="en-"]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-div2', 'attr-contains-div6'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, matching lang attributes ending with specified substring, ',
+ selector: '[lang*="-CH"]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-div3', 'attr-contains-div5'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector, not matching class attribute with empty value',
+ selector: '[class*=""]',
+ ctx: '#attr-contains',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Attribute contains selector with single-quoted value, matching class attribute beginning with specified substring',
+ selector: "[class*=' apple']",
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with single-quoted value, matching class attribute ending with specified substring',
+ selector: "[class*='orange ']",
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with single-quoted value, matching class attribute containing specified substring',
+ selector: "[class*='ple banana ora']",
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with double-quoted value, matching class attribute beginning with specified substring',
+ selector: '[class*=" apple"]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with double-quoted value, matching class attribute ending with specified substring',
+ selector: '[class*="orange "]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with double-quoted value, matching class attribute containing specified substring',
+ selector: '[class*="ple banana ora"]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with unquoted value, matching class attribute beginning with specified substring',
+ selector: '[class*= apple]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with unquoted value, matching class attribute ending with specified substring',
+ selector: '[class*=orange ]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Attribute contains selector with unquoted value, matching class attribute containing specified substring',
+ selector: '[class*= banana ]',
+ ctx: '#attr-contains',
+ expect: ['attr-contains-p1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // Pseudo-classes
+ // - :root (Level 3)
+ {
+ name: ':root pseudo-class selector, matching document root element',
+ selector: ':root',
+ expect: ['html'],
+ exclude: ['element', 'fragment', 'detached'],
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':root pseudo-class selector, not matching document root element',
+ selector: ':root',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':root pseudo-class selector, not matching document root element',
+ selector: ':root',
+ ctx: '#html',
+ expect: [] /*no matches*/,
+ exclude: ['fragment', 'detached'],
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // - :nth-child(n) (Level 3)
+ {
+ name: ':nth-child selector, matching the third child element',
+ selector: ':nth-child(3)',
+ ctx: '#pseudo-nth-table1',
+ expect: ['pseudo-nth-td3', 'pseudo-nth-td9', 'pseudo-nth-tr3', 'pseudo-nth-td15'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every third child element',
+ selector: 'li:nth-child(3n)',
+ ctx: '#pseudo-nth',
+ expect: ['pseudo-nth-li3', 'pseudo-nth-li6', 'pseudo-nth-li9', 'pseudo-nth-li12'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every second child element, starting from the fourth',
+ selector: 'li:nth-child(2n+4)',
+ ctx: '#pseudo-nth',
+ expect: [
+ 'pseudo-nth-li4',
+ 'pseudo-nth-li6',
+ 'pseudo-nth-li8',
+ 'pseudo-nth-li10',
+ 'pseudo-nth-li12',
+ ],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every second child element, starting from the fourth, with whitespace',
+ selector: 'li:nth-child(2n \t\r\n+ \t\r\n4)',
+ ctx: '#pseudo-nth',
+ expect: [
+ 'pseudo-nth-li4',
+ 'pseudo-nth-li6',
+ 'pseudo-nth-li8',
+ 'pseudo-nth-li10',
+ 'pseudo-nth-li12',
+ ],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every fourth child element, starting from the third',
+ selector: ':nth-child(4n-1)',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-em2', 'pseudo-nth-span3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector, matching every fourth child element, starting from the third, with whitespace',
+ selector: ':nth-child(4n \t\r\n- \t\r\n1)',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-em2', 'pseudo-nth-span3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-child selector used twice, matching ',
+ selector: ':nth-child(1) :nth-child(1)',
+ ctx: '#pseudo-nth',
+ expect: ['pseudo-nth-table1', 'pseudo-nth-tr1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :nth-last-child (Level 3)
+ {
+ name: ':nth-last-child selector, matching the third last child element',
+ selector: ':nth-last-child(3)',
+ ctx: '#pseudo-nth-table1',
+ expect: ['pseudo-nth-tr1', 'pseudo-nth-td4', 'pseudo-nth-td10', 'pseudo-nth-td16'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-child selector, matching every third child element from the end',
+ selector: 'li:nth-last-child(3n)',
+ ctx: 'pseudo-nth',
+ expect: ['pseudo-nth-li1', 'pseudo-nth-li4', 'pseudo-nth-li7', 'pseudo-nth-li10'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-child selector, matching every second child element from the end, starting from the fourth last',
+ selector: 'li:nth-last-child(2n+4)',
+ ctx: 'pseudo-nth',
+ expect: [
+ 'pseudo-nth-li1',
+ 'pseudo-nth-li3',
+ 'pseudo-nth-li5',
+ 'pseudo-nth-li7',
+ 'pseudo-nth-li9',
+ ],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-child selector, matching every fourth element from the end, starting from the third last',
+ selector: ':nth-last-child(4n-1)',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-span2', 'pseudo-nth-span4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :nth-of-type(n) (Level 3)
+ {
+ name: ':nth-of-type selector, matching the third em element',
+ selector: 'em:nth-of-type(3)',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-em3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-of-type selector, matching every second element of their type',
+ selector: ':nth-of-type(2n)',
+ ctx: '#pseudo-nth-p1',
+ expect: [
+ 'pseudo-nth-em2',
+ 'pseudo-nth-span2',
+ 'pseudo-nth-span4',
+ 'pseudo-nth-strong2',
+ 'pseudo-nth-em4',
+ ],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-of-type selector, matching every second elemetn of their type, starting from the first',
+ selector: 'span:nth-of-type(2n-1)',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-span1', 'pseudo-nth-span3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :nth-last-of-type(n) (Level 3)
+ {
+ name: ':nth-last-of-type selector, matching the third last em element',
+ selector: 'em:nth-last-of-type(3)',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-em2'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-of-type selector, matching every second last element of their type',
+ selector: ':nth-last-of-type(2n)',
+ ctx: '#pseudo-nth-p1',
+ expect: [
+ 'pseudo-nth-span1',
+ 'pseudo-nth-em1',
+ 'pseudo-nth-strong1',
+ 'pseudo-nth-em3',
+ 'pseudo-nth-span3',
+ ],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':nth-last-of-type selector, matching every second last element of their type, starting from the last',
+ selector: 'span:nth-last-of-type(2n-1)',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-span2', 'pseudo-nth-span4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :first-of-type (Level 3)
+ {
+ name: ':first-of-type selector, matching the first em element',
+ selector: 'em:first-of-type',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-em1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':first-of-type selector, matching the first of every type of element',
+ selector: ':first-of-type',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-span1', 'pseudo-nth-em1', 'pseudo-nth-strong1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':first-of-type selector, matching the first td element in each table row',
+ selector: 'tr :first-of-type',
+ ctx: '#pseudo-nth-table1',
+ expect: ['pseudo-nth-td1', 'pseudo-nth-td7', 'pseudo-nth-td13'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :last-of-type (Level 3)
+ {
+ name: ':last-of-type selector, matching the last em elemnet',
+ selector: 'em:last-of-type',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-em4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':last-of-type selector, matching the last of every type of element',
+ selector: ':last-of-type',
+ ctx: '#pseudo-nth-p1',
+ expect: ['pseudo-nth-span4', 'pseudo-nth-strong2', 'pseudo-nth-em4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':last-of-type selector, matching the last td element in each table row',
+ selector: 'tr :last-of-type',
+ ctx: '#pseudo-nth-table1',
+ expect: ['pseudo-nth-td6', 'pseudo-nth-td12', 'pseudo-nth-td18'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :first-child
+ {
+ name: ':first-child pseudo-class selector, matching first child div element',
+ selector: 'div:first-child',
+ ctx: '#pseudo-first-child',
+ expect: ['pseudo-first-child-div1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ":first-child pseudo-class selector, doesn't match non-first-child elements",
+ selector: '.pseudo-first-child-div2:first-child, .pseudo-first-child-div3:first-child',
+ ctx: '#pseudo-first-child',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':first-child pseudo-class selector, matching first-child of multiple elements',
+ selector: 'span:first-child',
+ ctx: '#pseudo-first-child',
+ expect: ['pseudo-first-child-span1', 'pseudo-first-child-span3', 'pseudo-first-child-span5'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :last-child (Level 3)
+ {
+ name: ':last-child pseudo-class selector, matching last child div element',
+ selector: 'div:last-child',
+ ctx: '#pseudo-last-child',
+ expect: ['pseudo-last-child-div3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ":last-child pseudo-class selector, doesn't match non-last-child elements",
+ selector: '.pseudo-last-child-div1:last-child, .pseudo-last-child-div2:first-child',
+ ctx: '#pseudo-last-child',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':last-child pseudo-class selector, matching first-child of multiple elements',
+ selector: 'span:last-child',
+ ctx: '#pseudo-last-child',
+ expect: ['pseudo-last-child-span2', 'pseudo-last-child-span4', 'pseudo-last-child-span6'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :only-child (Level 3)
+ {
+ name: ':pseudo-only-child pseudo-class selector, matching all only-child elements',
+ selector: ':only-child',
+ ctx: '#pseudo-only',
+ expect: ['pseudo-only-span1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':pseudo-only-child pseudo-class selector, matching only-child em elements',
+ selector: 'em:only-child',
+ ctx: '#pseudo-only',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // - :only-of-type (Level 3)
+ {
+ name: ':pseudo-only-of-type pseudo-class selector, matching all elements with no siblings of the same type',
+ selector: ' :only-of-type',
+ ctx: '#pseudo-only',
+ expect: ['pseudo-only-span1', 'pseudo-only-em1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':pseudo-only-of-type pseudo-class selector, matching em elements with no siblings of the same type',
+ selector: ' em:only-of-type',
+ ctx: '#pseudo-only',
+ expect: ['pseudo-only-em1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :empty (Level 3)
+ {
+ name: ':empty pseudo-class selector, matching empty p elements',
+ selector: 'p:empty',
+ ctx: '#pseudo-empty',
+ expect: ['pseudo-empty-p1', 'pseudo-empty-p2'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':empty pseudo-class selector, matching all empty elements',
+ selector: ':empty',
+ ctx: '#pseudo-empty',
+ expect: ['pseudo-empty-p1', 'pseudo-empty-p2', 'pseudo-empty-span1'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :link and :visited
+ // Implementations may treat all visited links as unvisited, so these cannot be tested separately.
+ // The only guarantee is that ":link,:visited" matches the set of all visited and unvisited links and that they are individually mutually exclusive sets.
+ {
+ name: ':link and :visited pseudo-class selectors, matching a and area elements with href attributes',
+ selector: ' :link, #pseudo-link :visited',
+ ctx: '#pseudo-link',
+ expect: ['pseudo-link-a1', 'pseudo-link-a2', 'pseudo-link-area1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':link and :visited pseudo-class selectors, matching no elements',
+ selector: ' :link, #head :visited',
+ ctx: '#head',
+ expect: [] /*no matches*/,
+ exclude: ['element', 'fragment', 'detached'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':link and :visited pseudo-class selectors, not matching link elements with href attributes',
+ selector: ' :link, #head :visited',
+ ctx: '#head',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 1,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':link and :visited pseudo-class selectors, chained, mutually exclusive pseudo-classes match nothing',
+ selector: ':link:visited',
+ ctx: '#html',
+ expect: [] /*no matches*/,
+ exclude: ['document'],
+ level: 1,
+ testType: TEST_FIND,
+ },
+
+ // XXX Figure out context or refNodes for this
+ // - :target (Level 3)
+ {
+ name: ':target pseudo-class selector, matching the element referenced by the URL fragment identifier',
+ selector: ':target',
+ ctx: '',
+ expect: [] /*no matches*/,
+ exclude: ['document', 'element'],
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':target pseudo-class selector, matching the element referenced by the URL fragment identifier',
+ selector: ':target',
+ ctx: '',
+ expect: ['target'],
+ exclude: ['fragment', 'detached'],
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // XXX Fix ctx in tests below
+
+ // - :lang()
+ {
+ name: ':lang pseudo-class selector, matching inherited language (1)',
+ selector: '#pseudo-lang-div1:lang(en)',
+ ctx: '',
+ expect: ['pseudo-lang-div1'],
+ exclude: ['detached', 'fragment'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':lang pseudo-class selector, not matching element with no inherited language',
+ selector: '#pseudo-lang-div1:lang(en)',
+ ctx: '',
+ expect: [] /*no matches*/,
+ exclude: ['document', 'element'],
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':lang pseudo-class selector, matching specified language with exact value (1)',
+ selector: '#pseudo-lang-div2:lang(fr)',
+ ctx: '',
+ expect: ['pseudo-lang-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':lang pseudo-class selector, matching specified language with partial value (1)',
+ selector: '#pseudo-lang-div3:lang(en)',
+ ctx: '',
+ expect: ['pseudo-lang-div3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':lang pseudo-class selector, not matching incorrect language',
+ selector: '#pseudo-lang-div4:lang(es-AR)',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+
+ // - :enabled (Level 3)
+ {
+ name: ':enabled pseudo-class selector, matching all enabled form controls (1)',
+ selector: '#pseudo-ui :enabled',
+ ctx: '',
+ expect: [
+ 'pseudo-ui-input1',
+ 'pseudo-ui-input2',
+ 'pseudo-ui-input3',
+ 'pseudo-ui-input4',
+ 'pseudo-ui-input5',
+ 'pseudo-ui-input6',
+ 'pseudo-ui-input7',
+ 'pseudo-ui-input8',
+ 'pseudo-ui-input9',
+ 'pseudo-ui-textarea1',
+ 'pseudo-ui-button1',
+ ],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':enabled pseudo-class selector, not matching link elements (1)',
+ selector: '#pseudo-link :enabled',
+ ctx: '',
+ expect: [] /*no matches*/,
+ unexpected: [
+ 'pseudo-link-a1',
+ 'pseudo-link-a2',
+ 'pseudo-link-a3',
+ 'pseudo-link-map1',
+ 'pseudo-link-area1',
+ 'pseudo-link-area2',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :disabled (Level 3)
+ {
+ name: ':disabled pseudo-class selector, matching all disabled form controls (1)',
+ selector: '#pseudo-ui :disabled',
+ ctx: '',
+ expect: [
+ 'pseudo-ui-input10',
+ 'pseudo-ui-input11',
+ 'pseudo-ui-input12',
+ 'pseudo-ui-input13',
+ 'pseudo-ui-input14',
+ 'pseudo-ui-input15',
+ 'pseudo-ui-input16',
+ 'pseudo-ui-input17',
+ 'pseudo-ui-input18',
+ 'pseudo-ui-textarea2',
+ 'pseudo-ui-button2',
+ ],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':disabled pseudo-class selector, not matching link elements (1)',
+ selector: '#pseudo-link :disabled',
+ ctx: '',
+ expect: [] /*no matches*/,
+ unexpected: [
+ 'pseudo-link-a1',
+ 'pseudo-link-a2',
+ 'pseudo-link-a3',
+ 'pseudo-link-map1',
+ 'pseudo-link-area1',
+ 'pseudo-link-area2',
+ ],
+ level: 3,
+ testType: TEST_QSA | TEST_MATCH,
+ },
+
+ // - :checked (Level 3)
+ {
+ name: ':checked pseudo-class selector, matching checked radio buttons and checkboxes (1)',
+ selector: '#pseudo-ui :checked',
+ ctx: '',
+ expect: ['pseudo-ui-input4', 'pseudo-ui-input6', 'pseudo-ui-input13', 'pseudo-ui-input15'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - :not(s) (Level 3)
+ {
+ name: ':not pseudo-class selector, matching (1)',
+ selector: '#not>:not(div)',
+ ctx: '',
+ expect: ['not-p1', 'not-p2', 'not-p3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':not pseudo-class selector, matching (1)',
+ selector: '#not * :not(:first-child)',
+ ctx: '',
+ expect: ['not-em1', 'not-em2', 'not-em3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: ':not pseudo-class selector, matching nothing',
+ selector: ':not(*)',
+ ctx: '',
+ expect: [] /* no matches */,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: ':not pseudo-class selector, matching nothing',
+ selector: ':not(*|*)',
+ ctx: '',
+ expect: [] /* no matches */,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // Pseudo-elements
+ // - ::first-line
+ {
+ name: ':first-line pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:first-line',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: '::first-line pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::first-line',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // - ::first-letter
+ {
+ name: ':first-letter pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:first-letter',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: '::first-letter pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::first-letter',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // - ::before
+ {
+ name: ':before pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:before',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: '::before pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::before',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // - ::after
+ {
+ name: ':after pseudo-element (one-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element:after',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: '::after pseudo-element (two-colon syntax) selector, not matching any elements',
+ selector: '#pseudo-element::after',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // Class Selectors
+ {
+ name: 'Class selector, matching element with specified class (1)',
+ selector: '.class-p',
+ ctx: '',
+ expect: ['class-p1', 'class-p2', 'class-p3'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, chained, matching only elements with all specified classes (1)',
+ selector: '#class .apple.orange.banana',
+ ctx: '',
+ expect: ['class-div1', 'class-div2', 'class-p4', 'class-div3', 'class-p6', 'class-div4'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Class Selector, chained, with type selector (1)',
+ selector: 'div.apple.banana.orange',
+ ctx: '',
+ expect: ['class-div1', 'class-div2', 'class-div3', 'class-div4'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching element with class value using non-ASCII characters (2)',
+ selector: '.\u53F0\u5317Ta\u0301ibe\u030Ci',
+ ctx: '',
+ expect: ['class-span1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching multiple elements with class value using non-ASCII characters (1)',
+ selector: '.\u53F0\u5317',
+ ctx: '',
+ expect: ['class-span1', 'class-span2'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, chained, matching element with multiple class values using non-ASCII characters (2)',
+ selector: '.\u53F0\u5317Ta\u0301ibe\u030Ci.\u53F0\u5317',
+ ctx: '',
+ expect: ['class-span1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching element with class with escaped character (1)',
+ selector: '.foo\\:bar',
+ ctx: '',
+ expect: ['class-span3'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Class selector, matching element with class with escaped character (1)',
+ selector: '.test\\.foo\\[5\\]bar',
+ ctx: '',
+ expect: ['class-span4'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // ID Selectors
+ {
+ name: 'ID selector, matching element with specified id (1)',
+ selector: '#id #id-div1',
+ ctx: '',
+ expect: ['id-div1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, chained, matching element with specified id (1)',
+ selector: '#id-div1, #id-div1',
+ ctx: '',
+ expect: ['id-div1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, chained, matching element with specified id (1)',
+ selector: '#id-div1, #id-div2',
+ ctx: '',
+ expect: ['id-div1', 'id-div2'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'ID Selector, chained, with type selector (1)',
+ selector: 'div#id-div1, div#id-div2',
+ ctx: '',
+ expect: ['id-div1', 'id-div2'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, not matching non-existent descendant',
+ selector: '#id #none',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 1,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'ID selector, not matching non-existent ancestor',
+ selector: '#none #id-div1',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 1,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'ID selector, matching multiple elements with duplicate id (1)',
+ selector: '#id-li-duplicate',
+ ctx: '',
+ expect: ['id-li-duplicate', 'id-li-duplicate', 'id-li-duplicate', 'id-li-duplicate'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ {
+ name: 'ID selector, matching id value using non-ASCII characters (3)',
+ selector: '#\u53F0\u5317Ta\u0301ibe\u030Ci',
+ ctx: '',
+ expect: ['\u53F0\u5317Ta\u0301ibe\u030Ci'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, matching id value using non-ASCII characters (4)',
+ selector: '#\u53F0\u5317',
+ ctx: '',
+ expect: ['\u53F0\u5317'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'ID selector, matching id values using non-ASCII characters (2)',
+ selector: '#\u53F0\u5317Ta\u0301ibe\u030Ci, #\u53F0\u5317',
+ ctx: '',
+ expect: ['\u53F0\u5317Ta\u0301ibe\u030Ci', '\u53F0\u5317'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // XXX runMatchesTest() in level2-lib.js can't handle this because obtaining the expected nodes requires escaping characters when generating the selector from 'expect' values
+ {
+ name: 'ID selector, matching element with id with escaped character',
+ selector: '#\\#foo\\:bar',
+ ctx: '',
+ expect: ['#foo:bar'],
+ level: 1,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'ID selector, matching element with id with escaped character',
+ selector: '#test\\.foo\\[5\\]bar',
+ ctx: '',
+ expect: ['test.foo[5]bar'],
+ level: 1,
+ testType: TEST_FIND,
+ },
+
+ // Namespaces
+ // XXX runMatchesTest() in level2-lib.js can't handle these because non-HTML elements don't have a recognised id
+ {
+ name: 'Namespace selector, matching element with any namespace',
+ selector: '#any-namespace *|div',
+ ctx: '',
+ expect: [
+ 'any-namespace-div1',
+ 'any-namespace-div2',
+ 'any-namespace-div3',
+ 'any-namespace-div4',
+ ],
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Namespace selector, matching div elements in no namespace only',
+ selector: '#no-namespace |div',
+ ctx: '',
+ expect: ['no-namespace-div3'],
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Namespace selector, matching any elements in no namespace only',
+ selector: '#no-namespace |*',
+ ctx: '',
+ expect: ['no-namespace-div3'],
+ level: 3,
+ testType: TEST_FIND,
+ },
+
+ // Combinators
+ // - Descendant combinator ' '
+ {
+ name: 'Descendant combinator, matching element that is a descendant of an element with id (1)',
+ selector: '#descendant div',
+ ctx: '',
+ expect: ['descendant-div1', 'descendant-div2', 'descendant-div3', 'descendant-div4'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with id that is a descendant of an element (1)',
+ selector: 'body #descendant-div1',
+ ctx: '',
+ expect: ['descendant-div1'],
+ exclude: ['detached', 'fragment'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with id that is a descendant of an element (1)',
+ selector: 'div #descendant-div1',
+ ctx: '',
+ expect: ['descendant-div1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with id that is a descendant of an element with id (1)',
+ selector: '#descendant #descendant-div2',
+ ctx: '',
+ expect: ['descendant-div2'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with class that is a descendant of an element with id (1)',
+ selector: '#descendant .descendant-div2',
+ ctx: '',
+ expect: ['descendant-div2'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, matching element with class that is a descendant of an element with class (1)',
+ selector: '.descendant-div1 .descendant-div3',
+ ctx: '',
+ expect: ['descendant-div3'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Descendant combinator, not matching element with id that is not a descendant of an element with id',
+ selector: '#descendant-div1 #descendant-div4',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 1,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Descendant combinator, whitespace characters (1)',
+ selector: '#descendant\t\r\n#descendant-div2',
+ ctx: '',
+ expect: ['descendant-div2'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // // - Descendant combinator '>>'
+ // {name: "Descendant combinator '>>', matching element that is a descendant of an element with id (1)", selector: "#descendant>>div", ctx: "", expect: ["descendant-div1", "descendant-div2", "descendant-div3", "descendant-div4"], level: 1, testType: TEST_FIND | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with id that is a descendant of an element (1)", selector: "body>>#descendant-div1", ctx: "", expect: ["descendant-div1"], exclude: ["detached", "fragment"], level: 1, testType: TEST_FIND | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with id that is a descendant of an element (1)", selector: "div>>#descendant-div1", ctx: "", expect: ["descendant-div1"], level: 1, testType: TEST_FIND | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with id that is a descendant of an element with id (1)", selector: "#descendant>>#descendant-div2", ctx: "", expect: ["descendant-div2"], level: 1, testType: TEST_FIND | TEST_MATCH},
+ // {name: "Descendant combinator '>>', matching element with class that is a descendant of an element with id (1)", selector: "#descendant>>.descendant-div2", ctx: "", expect: ["descendant-div2"], level: 1, testType: TEST_FIND | TEST_MATCH},
+ // {name: "Descendant combinator, '>>', matching element with class that is a descendant of an element with class (1)", selector: ".descendant-div1>>.descendant-div3", ctx: "", expect: ["descendant-div3"], level: 1, testType: TEST_FIND | TEST_MATCH},
+ // {name: "Descendant combinator '>>', not matching element with id that is not a descendant of an element with id", selector: "#descendant-div1>>#descendant-div4", ctx: "", expect: [] /*no matches*/, level: 1, testType: TEST_FIND},
+
+ // - Child combinator '>'
+ {
+ name: 'Child combinator, matching element that is a child of an element with id (1)',
+ selector: '#child>div',
+ ctx: '',
+ expect: ['child-div1', 'child-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with id that is a child of an element (1)',
+ selector: 'div>#child-div1',
+ ctx: '',
+ expect: ['child-div1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with id that is a child of an element with id (1)',
+ selector: '#child>#child-div1',
+ ctx: '',
+ expect: ['child-div1'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with id that is a child of an element with class (1)',
+ selector: '#child-div1>.child-div2',
+ ctx: '',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, matching element with class that is a child of an element with class (1)',
+ selector: '.child-div1>.child-div2',
+ ctx: '',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, not matching element with id that is not a child of an element with id',
+ selector: '#child>#child-div3',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Child combinator, not matching element with id that is not a child of an element with class',
+ selector: '#child-div1>.child-div3',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Child combinator, not matching element with class that is not a child of an element with class',
+ selector: '.child-div1>.child-div3',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Child combinator, surrounded by whitespace (1)',
+ selector: '#child-div1\t\r\n>\t\r\n#child-div2',
+ ctx: '',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, whitespace after (1)',
+ selector: '#child-div1>\t\r\n#child-div2',
+ ctx: '',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, whitespace before (1)',
+ selector: '#child-div1\t\r\n>#child-div2',
+ ctx: '',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Child combinator, no whitespace (1)',
+ selector: '#child-div1>#child-div2',
+ ctx: '',
+ expect: ['child-div2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - Adjacent sibling combinator '+'
+ {
+ name: 'Adjacent sibling combinator, matching element that is an adjacent sibling of an element with id (1)',
+ selector: '#adjacent-div2+div',
+ ctx: '',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with id that is an adjacent sibling of an element (1)',
+ selector: 'div+#adjacent-div4',
+ ctx: '',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with id that is an adjacent sibling of an element with id (1)',
+ selector: '#adjacent-div2+#adjacent-div4',
+ ctx: '',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with class that is an adjacent sibling of an element with id (1)',
+ selector: '#adjacent-div2+.adjacent-div4',
+ ctx: '',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching element with class that is an adjacent sibling of an element with class (1)',
+ selector: '.adjacent-div2+.adjacent-div4',
+ ctx: '',
+ expect: ['adjacent-div4'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, matching p element that is an adjacent sibling of a div element (1)',
+ selector: '#adjacent div+p',
+ ctx: '',
+ expect: ['adjacent-p2'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, not matching element with id that is not an adjacent sibling of an element with id',
+ selector: '#adjacent-div2+#adjacent-p2, #adjacent-div2+#adjacent-div1',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 2,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'Adjacent sibling combinator, surrounded by whitespace (1)',
+ selector: '#adjacent-p2\t\r\n+\t\r\n#adjacent-p3',
+ ctx: '',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, whitespace after (1)',
+ selector: '#adjacent-p2+\t\r\n#adjacent-p3',
+ ctx: '',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, whitespace before (1)',
+ selector: '#adjacent-p2\t\r\n+#adjacent-p3',
+ ctx: '',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Adjacent sibling combinator, no whitespace (1)',
+ selector: '#adjacent-p2+#adjacent-p3',
+ ctx: '',
+ expect: ['adjacent-p3'],
+ level: 2,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // - General sibling combinator ~ (Level 3)
+ {
+ name: 'General sibling combinator, matching element that is a sibling of an element with id (1)',
+ selector: '#sibling-div2~div',
+ ctx: '',
+ expect: ['sibling-div4', 'sibling-div6'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching element with id that is a sibling of an element (1)',
+ selector: 'div~#sibling-div4',
+ ctx: '',
+ expect: ['sibling-div4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching element with id that is a sibling of an element with id (1)',
+ selector: '#sibling-div2~#sibling-div4',
+ ctx: '',
+ expect: ['sibling-div4'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching element with class that is a sibling of an element with id (1)',
+ selector: '#sibling-div2~.sibling-div',
+ ctx: '',
+ expect: ['sibling-div4', 'sibling-div6'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, matching p element that is a sibling of a div element (1)',
+ selector: '#sibling div~p',
+ ctx: '',
+ expect: ['sibling-p2', 'sibling-p3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, not matching element with id that is not a sibling after a p element (1)',
+ selector: '#sibling>p~div',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'General sibling combinator, not matching element with id that is not a sibling after an element with id',
+ selector: '#sibling-div2~#sibling-div3, #sibling-div2~#sibling-div1',
+ ctx: '',
+ expect: [] /*no matches*/,
+ level: 3,
+ testType: TEST_FIND,
+ },
+ {
+ name: 'General sibling combinator, surrounded by whitespace (1)',
+ selector: '#sibling-p2\t\r\n~\t\r\n#sibling-p3',
+ ctx: '',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, whitespace after (1)',
+ selector: '#sibling-p2~\t\r\n#sibling-p3',
+ ctx: '',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, whitespace before (1)',
+ selector: '#sibling-p2\t\r\n~#sibling-p3',
+ ctx: '',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'General sibling combinator, no whitespace (1)',
+ selector: '#sibling-p2~#sibling-p3',
+ ctx: '',
+ expect: ['sibling-p3'],
+ level: 3,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+
+ // Group of selectors (comma)
+ {
+ name: 'Syntax, group of selectors separator, surrounded by whitespace (1)',
+ selector: '#group em\t\r \n,\t\r \n#group strong',
+ ctx: '',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Syntax, group of selectors separator, whitespace after (1)',
+ selector: '#group em,\t\r\n#group strong',
+ ctx: '',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Syntax, group of selectors separator, whitespace before (1)',
+ selector: '#group em\t\r\n,#group strong',
+ ctx: '',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+ {
+ name: 'Syntax, group of selectors separator, no whitespace (1)',
+ selector: '#group em,#group strong',
+ ctx: '',
+ expect: ['group-em1', 'group-strong1'],
+ level: 1,
+ testType: TEST_FIND | TEST_MATCH,
+ },
+];
diff --git a/packages/web-platform-tests/src/compatibility/element/selectors/fixtures.ts b/packages/web-platform-tests/src/compatibility/element/selectors/fixtures.ts
new file mode 100644
index 0000000..8196500
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors/fixtures.ts
@@ -0,0 +1,754 @@
+// WPT dom/nodes/ParentNode-querySelector-All-content.{html,xht} at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+// Unmodified upstream fixture markup. Each case receives a fresh document.
+export const html = `
+
+
+
+ Selectors-API Test Suite: HTML with Selectors Level 2 using TestHarness: Test Document
+
+
+
+
+
+
+
+
+
+
+
+
+
Universal selector tests inside element with id="universal".
+
+
Some preformatted text with some embedded code
+
This is a normal link: W3C
+
Some more nested elements code hyperlink
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A
+ B
+ C
+ D
+
+
+ A
+ B
+ C
+ D
+
+
+ A
+ B
+ C
+ D
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ span1
+ em1
+
+ em2
+ span2
+ strong1
+ em3
+ span3
+ span4
+ strong2
+ em4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enabled
+
+
+
+
+
+
+
+
+
+
+
+ Disabled
+
+
+
+
+
All pseudo-element tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+export const xhtml = `
+
+
+ Selectors-API Test Suite: HTML with Selectors Level 2 using TestHarness: Test Document
+
+
+
+
+
+
+
+
+
+
+
+
Universal selector tests inside element with id="universal".
+
+
Some preformatted text with some embedded code
+
This is a normal link: W3C
+
Some more nested elements code hyperlink
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A
+ B
+ C
+ D
+
+
+ A
+ B
+ C
+ D
+
+
+ A
+ B
+ C
+ D
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ span1
+ em1
+
+ em2
+ span2
+ strong1
+ em3
+ span3
+ span4
+ strong2
+ em4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enabled
+
+
+
+
+
+
+
+
+
+
+
+ Disabled
+
+
+
+
+
All pseudo-element tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/web-platform-tests/src/compatibility/element/selectors/grammar.ts b/packages/web-platform-tests/src/compatibility/element/selectors/grammar.ts
new file mode 100644
index 0000000..ba512d8
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors/grammar.ts
@@ -0,0 +1,1874 @@
+// Adapted from web-platform-tests at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. CSS escape cases by Domenic
+// Denicola and bellbind; :has cases by Byungwoo Lee. BSD-3-Clause;
+// see ../../../../test/wpt/LICENSE. Original source paths are listed below.
+import type { CompatibilityContext, DOMFixture } from '../../context.ts';
+import type { DOMElement } from '../../contracts/nodes.ts';
+
+function prepare(fixture: DOMFixture, markup: string): Document {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div');
+ root.innerHTML = markup;
+ fixture.connect(root as unknown as DOMElement);
+ return document;
+}
+
+export const sourceFiles = [
+ 'dom/nodes/ParentNode-querySelector-All-multiple-not.html',
+ 'dom/nodes/ParentNode-querySelector-basic-has.html',
+ 'dom/nodes/ParentNode-querySelector-empty-attribute-value.html',
+ 'dom/nodes/ParentNode-querySelector-hyphen-prefix.html',
+ 'dom/nodes/ParentNode-querySelector-invalid-comment-like.html',
+ 'dom/nodes/ParentNode-querySelector-is-nested-pseudo-class.html',
+ 'dom/nodes/ParentNode-querySelector-is-not.html',
+ 'dom/nodes/ParentNode-querySelector-last-child-combinator.html',
+ 'dom/nodes/ParentNode-querySelector-matches.html',
+ 'dom/nodes/ParentNode-querySelector-nested-nth-child.html',
+ 'dom/nodes/ParentNode-querySelector-nested.html',
+ 'dom/nodes/ParentNode-querySelector-nth-of-type-complex.html',
+ 'dom/nodes/ParentNode-querySelectorAll-case-insensitive-attribute-flag.html',
+ 'dom/nodes/ParentNode-querySelectorAll-nested-not-has.html',
+ 'dom/nodes/ParentNode-querySelectorAll-not-descendant.html',
+ 'dom/nodes/ParentNode-querySelectors-space-and-dash-attribute-value.html',
+ 'dom/nodes/querySelector-empty-id.html',
+ 'dom/nodes/querySelector-id-nth-child.html',
+ 'dom/nodes/ParentNode-querySelector-escapes.html',
+ 'dom/nodes/querySelector-mixed-case.html',
+ 'css/selectors/is-where-basic.html',
+ 'css/selectors/has-basic.html',
+] as const;
+
+/** Registers every assertion from the listed pinned WPT selector regression files. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ function arrayEquals(
+ actual: ArrayLike,
+ options: { expected: ArrayLike; message?: string },
+ ): void {
+ expect(actual.length, options.message).toBe(options.expected.length);
+ for (let i = 0; i < actual.length; i++)
+ expect(actual[i], options.message).toBe(options.expected[i]);
+ }
+ function syntaxError(fixture: DOMFixture, operation: () => unknown): void {
+ let caught: unknown;
+ try {
+ operation();
+ } catch (error) {
+ caught = error;
+ }
+ expect(caught).toBeInstanceOf(fixture.globals('DOMException'));
+ expect(caught).toMatchObject({ name: 'SyntaxError', code: 12 });
+ }
+
+ context.test(
+ 'WPT ParentNode-querySelector-All-multiple-not.html: :not() with multiple selectors in the list',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n',
+ );
+
+ const container = document.getElementById('container')!;
+ expect(container.querySelectorAll(':not(svg, svg *)').length).toBe(4);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-basic-has.html: Basic :has() support in querySelector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n 123 \n
\n
',
+ );
+
+ const expectedP = document.getElementById('p-with-span')!;
+ expect(document.querySelector('p:has(span)')!).toBe(expectedP);
+ },
+ );
+
+ context.test(
+ "WPT ParentNode-querySelector-empty-attribute-value.html: [title=''] matches element with empty title attribute",
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n
\n
has empty title
\n
has non-empty title
\n
',
+ );
+
+ const container = document.getElementById('container')!;
+ expect(container.querySelectorAll("div[title='']").length).toBe(1);
+ },
+ );
+
+ context.test(
+ "WPT ParentNode-querySelector-empty-attribute-value.html: Compound [title][title=''] matches element with empty title attribute",
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n
\n
has empty title
\n
has non-empty title
\n
',
+ );
+
+ const container = document.getElementById('container')!;
+ expect(container.querySelectorAll("div[title][title='']").length).toBe(1);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-hyphen-prefix.html: ID selector with hyphen-prefix followed by letters',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n
',
+ );
+
+ const element = document.querySelector('#-abc')!;
+ expect(element).not.toBe(null);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-hyphen-prefix.html: Class selector with hyphen-prefix followed by letters',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n
',
+ );
+
+ const element = document.querySelector('.-xyz')!;
+ expect(element).not.toBe(null);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-hyphen-prefix.html: ID selector with hyphen-prefix followed by numbers',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n
',
+ );
+
+ syntaxError(fixture, () => {
+ document.querySelector('#-123')!;
+ });
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-hyphen-prefix.html: Class selector with hyphen-prefix followed by numbers',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n
',
+ );
+
+ syntaxError(fixture, () => {
+ document.querySelector('.-123')!;
+ });
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-invalid-comment-like.html: querySelector throws SyntaxError for comment-like selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ const selector =
+ ' //MAIN MENU - (used to keep mobile menu options hidden) // #tncms-region-nav-main-nav-right-nav';
+
+ syntaxError(fixture, () => document.querySelector(selector)!);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-invalid-comment-like.html: querySelectorAll throws SyntaxError for comment-like selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ const selector =
+ ' //MAIN MENU - (used to keep mobile menu options hidden) // #tncms-region-nav-main-nav-right-nav';
+
+ syntaxError(fixture, () => document.querySelectorAll(selector));
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-invalid-comment-like.html: matches throws SyntaxError for comment-like selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ const selector =
+ ' //MAIN MENU - (used to keep mobile menu options hidden) // #tncms-region-nav-main-nav-right-nav';
+
+ syntaxError(fixture, () => document.body.matches(selector));
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-is-nested-pseudo-class.html: Simple nested :is selector should match all inputs and buttons',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n Button 1 \n \n \n Button 2 \n Button 3 \n
',
+ );
+
+ const elements = document.querySelectorAll(':is(:is(input), button)');
+ const elementIds = Array.from(elements).map((el) => el.id);
+
+ const expectedIds = ['button1', 'input1', 'input2', 'button2', 'button3'];
+ arrayEquals(elementIds, { expected: expectedIds });
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-is-nested-pseudo-class.html: Complex nested :is selector with attribute and :not selectors should work',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n Button 1 \n \n \n Button 2 \n Button 3 \n
',
+ );
+
+ const selector = ':is(:is(button, input)[type=submit], button:not([type])):not([disabled])';
+ const elements = document.querySelectorAll(selector);
+ const elementIds = Array.from(elements).map((el) => el.id);
+
+ const expectedIds = ['button1', 'input2', 'button2'];
+ arrayEquals(elementIds, { expected: expectedIds });
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-is-not.html: querySelector with :is():not() combined',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ expect(document.querySelector(':is([a],b):not(.c)')!, 'does not throw').toBe(null);
+ expect(document.querySelector(':is(a,[b]):not(.c)')!, 'does not throw').toBe(null);
+ expect(document.querySelector(':is(a,b):not(.c)')!, 'does not throw').toBe(null);
+ expect(document.querySelector(':is(a,b):not([c])')!, 'does not throw').toBe(null);
+ expect(document.querySelector(':is(a):not([b],.c)')!, 'does not throw').toBe(null);
+ expect(document.querySelector(':is(a,[b])')!, 'does not throw').toBe(null);
+ expect(document.querySelector(':is(a,[b]):first-child')!, 'does not throw').toBe(null);
+ expect(document.querySelector(':is([b]):not(.c)')!, 'does not throw').toBe(null);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-last-child-combinator.html: div:last-child > span[title] should match',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n',
+ );
+
+ const container = document.getElementById('container')!;
+ const span = container.querySelector('div:last-child > span[title]')!;
+ expect(span).not.toBe(null);
+ expect(span.textContent).toBe('text');
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-matches.html: matches with ancestor ID and descendant type selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ const el = document.querySelector('#main .foo')!;
+ expect(el.matches('#main p')).toBe(true);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-matches.html: matches returns false for non-matching ID selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ const el = document.querySelector('#main .foo')!;
+ expect(el.matches('#nonexistent')).toBe(false);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-matches.html: matches with :not() pseudo-class',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ const el = document.querySelector('#main p:not(.foo)')!;
+ expect(el.matches('#main p:not(.foo)')).toBe(true);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-matches.html: matches with simple type selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '',
+ );
+
+ const el = document.querySelector('#main .foo')!;
+ expect(el.matches('p')).toBe(true);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-nested-nth-child.html: querySelector with nested :nth-child() without tag names',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n \n ',
+ );
+
+ const selector = 'main > :nth-child(1) > :nth-child(1) a';
+ const expectedLink = document.getElementById('targetLink')!;
+ expect(document.querySelector(selector)!).toBe(expectedLink);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-nested.html: querySelector on multi-level query selecting',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n',
+ );
+
+ const target = document.getElementById('span2')!;
+ const anchor = document.querySelector('a')!;
+ const span = anchor.querySelector('span')!;
+ expect(span).toBe(target);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelector-nth-of-type-complex.html: querySelector with complex :nth-of-type selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n
Sibling to ensure div.clearfix is :nth-of-type(1) among divs
\n
\n
\n \n Row 1 Data \n \n \n Target Text \n \n \n \n
\n
\n
\n \n
\n
',
+ );
+
+ const selector = '.team-wrap .clearfix:nth-of-type(1) tr:nth-of-type(2) .u-hyphenate';
+ const expectedElement = document.getElementById('targetElement')!;
+ expect(document.querySelector(selector)!).toBe(expectedElement);
+ },
+ );
+
+ context.test(
+ "WPT ParentNode-querySelectorAll-case-insensitive-attribute-flag.html: Case-insensitive attribute selector with lowercase flag 'i' should work",
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n
Test div with CasE class
\n
',
+ );
+
+ const elements = document.querySelectorAll('div[class=CasE i]');
+ expect(elements.length, "Selector with lowercase 'i' flag should match the element").toBe(1);
+ },
+ );
+
+ context.test(
+ "WPT ParentNode-querySelectorAll-case-insensitive-attribute-flag.html: Case-insensitive attribute selector with uppercase flag 'I' should work",
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n
Test div with CasE class
\n
',
+ );
+
+ const elements = document.querySelectorAll('div[class=CasE I]');
+ expect(elements.length, "Selector with uppercase 'I' flag should match the element").toBe(1);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelectorAll-nested-not-has.html: querySelectorAll with :not(:has(..))',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n\n A: \n A \n B: \n \n B\n \n C: \n C \n D: \n D \n \n \n E: \n E \n ',
+ );
+
+ const selector1 = 'dd:not(:has(dd))';
+ const selector2 = 'dd:not(dd:has(dd))';
+ const expectedElements = [
+ document.getElementById('dd-a')!,
+ document.getElementById('dd-c')!,
+ document.getElementById('dd-d')!,
+ document.getElementById('dd-e')!,
+ ];
+
+ const result1 = document.querySelectorAll(selector1);
+ arrayEquals(result1, { expected: expectedElements, message: selector1 });
+
+ const result2 = document.querySelectorAll(selector2);
+ arrayEquals(result2, { expected: expectedElements, message: selector2 });
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelectorAll-not-descendant.html: querySelectorAll with :not() pseudo-class and descendant selector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\n',
+ );
+
+ const container = document.getElementById('container')!;
+ const result = container.querySelectorAll('p:not(table p)');
+
+ // Browsers parse the invalid HTML `
`
+ // such that the first
is closed before the
,
+ // and the closing tag creates a *new* empty element after the table.
+ // Thus, the query should match p#p1, the empty p after the table, and p#p3.
+ const resultIDs = Array.from(result, (el) => el.id);
+ arrayEquals(resultIDs, { expected: ['p1', '', 'p3'] });
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelectors-space-and-dash-attribute-value.html: querySelector',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\nTest One ',
+ );
+
+ const el = document.getElementById('testme')!;
+
+ expect(document.querySelector("a[title='test with - dash and space']")!).toBe(el);
+ },
+ );
+
+ context.test(
+ 'WPT ParentNode-querySelectors-space-and-dash-attribute-value.html: querySelectorAll',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '\n\nTest One ',
+ );
+
+ const el = document.getElementById('testme')!;
+
+ arrayEquals(document.querySelectorAll("a[title='test with - dash and space']"), {
+ expected: [el],
+ });
+ },
+ );
+
+ context.test(
+ 'WPT querySelector-empty-id.html: querySelector attribute matching empty id',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
',
+ );
+ const root = document.getElementById('root')!;
+
+ expect(root.querySelectorAll('[id]').length).toBe(2);
+ expect(root.querySelectorAll("[id='']").length).toBe(2);
+ },
+ );
+
+ context.test(
+ 'WPT querySelector-id-nth-child.html: querySelector matching #id + constant nth-child()',
+ (fixture) => {
+ const document = prepare(fixture, '');
+ const target = document.getElementById('target')!;
+
+ expect(document.querySelector('#target:nth-child(1)')!).toBe(target);
+ expect(document.querySelector('#target:nth-child(4)')!).toBe(null);
+ },
+ );
+
+ context.test(
+ 'WPT querySelector-id-nth-child.html: querySelectorAll matching #id + constant nth-child()',
+ (fixture) => {
+ const document = prepare(fixture, '');
+
+ expect(document.querySelectorAll('#target:nth-child(1)').length).toBe(1);
+ expect(document.querySelectorAll('#target:nth-child(4)').length).toBe(0);
+ },
+ );
+
+ function escapeCase(id: string, options: { selector: string; matched: boolean }): void {
+ const { selector, matched } = options;
+ context.test(
+ 'WPT ParentNode-querySelector-escapes.html: ' +
+ JSON.stringify(id) +
+ (matched ? ' should match with ' : ' should never match with ') +
+ JSON.stringify(selector),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const container = document.createElement('div');
+ const child = document.createElement('span');
+ child.id = id;
+ container.appendChild(child);
+ expect(container.querySelector(selector)).toBe(matched ? child : null);
+ },
+ );
+ }
+ // 4.3.7 from https://drafts.csswg.org/css-syntax/#consume-escaped-code-point
+ escapeCase('nonescaped', { selector: '#nonescaped', matched: true });
+
+ // - escape hex digit
+ escapeCase('0nextIsWhiteSpace', { selector: '#\\30 nextIsWhiteSpace', matched: true });
+ escapeCase('0nextIsNotHexLetters', { selector: '#\\30nextIsNotHexLetters', matched: true });
+ escapeCase('0connectHexMoreThan6Hex', {
+ selector: '#\\000030connectHexMoreThan6Hex',
+ matched: true,
+ });
+ escapeCase('0spaceMoreThan6Hex', { selector: '#\\000030 spaceMoreThan6Hex', matched: true });
+
+ // - hex digit special replacement
+ // 1. zero points
+ escapeCase('zero\u{fffd}', { selector: '#zero\\0', matched: true });
+ escapeCase('zero\u{0}', { selector: '#zero\\0', matched: false });
+ escapeCase('zero\u{fffd}', { selector: '#zero\\000000', matched: true });
+ escapeCase('zero\u{0}', { selector: '#zero\\000000', matched: false });
+ // 2. surrogate points
+ escapeCase('\u{fffd}surrogateFirst', { selector: '#\\d83d surrogateFirst', matched: true });
+ escapeCase('\ud83dsurrogateFirst', { selector: '#\\d83d surrogateFirst', matched: false });
+ escapeCase('surrogateSecond\u{fffd}', { selector: '#surrogateSecond\\dd11', matched: true });
+ escapeCase('surrogateSecond\udd11', { selector: '#surrogateSecond\\dd11', matched: false });
+ escapeCase('surrogatePair\u{fffd}\u{fffd}', {
+ selector: '#surrogatePair\\d83d\\dd11',
+ matched: true,
+ });
+ escapeCase('surrogatePair\u{1f511}', { selector: '#surrogatePair\\d83d\\dd11', matched: false });
+ // 3. out of range points
+ escapeCase('outOfRange\u{fffd}', { selector: '#outOfRange\\110000', matched: true });
+ escapeCase('outOfRange\u{fffd}', { selector: '#outOfRange\\110030', matched: true });
+ escapeCase('outOfRange\u{30}', { selector: '#outOfRange\\110030', matched: false });
+ escapeCase('outOfRange\u{fffd}', { selector: '#outOfRange\\555555', matched: true });
+ escapeCase('outOfRange\u{fffd}', { selector: '#outOfRange\\ffffff', matched: true });
+
+ // - escape EOF
+ escapeCase('eof\\', { selector: '#eof\\', matched: false });
+
+ // - escape anythong else
+ escapeCase('.comma', { selector: '#\\.comma', matched: true });
+ escapeCase('-minus', { selector: '#\\-minus', matched: true });
+ escapeCase('g', { selector: '#\\g', matched: true });
+
+ // non edge cases
+ escapeCase('aBMPRegular', { selector: '#\\61 BMPRegular', matched: true });
+ escapeCase('\u{1f511}nonBMP', { selector: '#\\1f511 nonBMP', matched: true });
+ escapeCase('00continueEscapes', { selector: '#\\30\\30 continueEscapes', matched: true });
+ escapeCase('00continueEscapes', { selector: '#\\30 \\30 continueEscapes', matched: true });
+ escapeCase('continueEscapes00', { selector: '#continueEscapes\\30 \\30 ', matched: true });
+ escapeCase('continueEscapes00', { selector: '#continueEscapes\\30 \\30', matched: true });
+ escapeCase('continueEscapes00', { selector: '#continueEscapes\\30\\30 ', matched: true });
+ escapeCase('continueEscapes00', { selector: '#continueEscapes\\30\\30', matched: true });
+
+ // ident tests case from CSS tests of chromium source: https://goo.gl/3Cxdov
+ escapeCase('hello', { selector: '#hel\\6Co', matched: true });
+ escapeCase('&B', { selector: '#\\26 B', matched: true });
+ escapeCase('hello', { selector: '#hel\\6C o', matched: true });
+ escapeCase('spaces', { selector: '#spac\\65\r\ns', matched: true });
+ escapeCase('spaces', { selector: '#sp\\61\tc\\65\fs', matched: true });
+ escapeCase('test\u{D799}', { selector: '#test\\D799', matched: true });
+ escapeCase('\u{E000}', { selector: '#\\E000', matched: true });
+ escapeCase('test', { selector: '#te\\s\\t', matched: true });
+ escapeCase('spaces in\tident', { selector: '#spaces\\ in\\\tident', matched: true });
+ escapeCase('.,:!', { selector: '#\\.\\,\\:\\!', matched: true });
+ escapeCase('null\u{fffd}', { selector: '#null\\0', matched: true });
+ escapeCase('null\u{fffd}', { selector: '#null\\0000', matched: true });
+ escapeCase('large\u{fffd}', { selector: '#large\\110000', matched: true });
+ escapeCase('large\u{fffd}', { selector: '#large\\23456a', matched: true });
+ escapeCase('surrogate\u{fffd}', { selector: '#surrogate\\D800', matched: true });
+ escapeCase('surrogate\u{fffd}', { selector: '#surrogate\\0DBAC', matched: true });
+ escapeCase('\u{fffd}surrogate', { selector: '#\\00DFFFsurrogate', matched: true });
+ escapeCase('\u{10ffff}', { selector: '#\\10fFfF', matched: true });
+ escapeCase('\u{10ffff}0', { selector: '#\\10fFfF0', matched: true });
+ escapeCase('\u{100000}00', { selector: '#\\10000000', matched: true });
+ escapeCase('eof\u{fffd}', { selector: '#eof\\', matched: true });
+
+ escapeCase('simple-ident', { selector: '#simple-ident', matched: true });
+ escapeCase('testing123', { selector: '#testing123', matched: true });
+ escapeCase('_underscore', { selector: '#_underscore', matched: true });
+ escapeCase('-text', { selector: '#-text', matched: true });
+ escapeCase('-m', { selector: '#-\\6d', matched: true });
+ escapeCase('--abc', { selector: '#--abc', matched: true });
+ escapeCase('--', { selector: '#--', matched: true });
+ escapeCase('--11', { selector: '#--11', matched: true });
+ escapeCase('---', { selector: '#---', matched: true });
+ escapeCase('\u{2003}', { selector: '#\u{2003}', matched: true });
+ escapeCase('\u{A0}', { selector: '#\u{A0}', matched: true });
+ escapeCase('\u{1234}', { selector: '#\u{1234}', matched: true });
+ escapeCase('\u{12345}', { selector: '#\u{12345}', matched: true });
+ escapeCase('\u{fffd}', { selector: '#\u{0}', matched: true });
+ escapeCase('ab\u{fffd}c', { selector: '#ab\u{0}c', matched: true });
+
+ context.test(
+ 'WPT querySelector-mixed-case.html: Mixed HTML/SVG/MathML tree with various mixed-case attributes',
+ (fixture) => {
+ const document = prepare(fixture, '
');
+ const container = document.getElementById('test-container')!;
+ function buildTestTree(): HTMLDivElement {
+ // Build entire DOM tree structure first (without attributes)
+ const html1 = document.createElement('div');
+ html1.id = 'html1';
+
+ const svg1 = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg1.id = 'svg1';
+
+ const svg2 = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ svg2.id = 'svg2';
+
+ const svg3 = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ svg3.id = 'svg3';
+
+ const html2 = document.createElement('div');
+ html2.id = 'html2';
+
+ const math1 = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'math');
+ math1.id = 'math1';
+
+ const math2 = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'mi');
+ math2.id = 'math2';
+
+ const svg4 = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg4.id = 'svg4';
+
+ const svg5 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ svg5.id = 'svg5';
+
+ const html3 = document.createElement('div');
+ html3.id = 'html3';
+
+ // Create foreignObject with HTML inside SVG (will go in svg1)
+ const foreign1 = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
+ foreign1.id = 'foreign1';
+
+ const html4 = document.createElement('div');
+ html4.id = 'html4';
+
+ const html5 = document.createElement('span');
+ html5.id = 'html5';
+
+ // Create nested: HTML > SVG inside html2
+ const svg6 = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg6.id = 'svg6';
+
+ const foreign2 = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
+ foreign2.id = 'foreign2';
+
+ const html6 = document.createElement('div');
+ html6.id = 'html6';
+
+ const svg7 = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg7.id = 'svg7';
+
+ const svg8 = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ svg8.id = 'svg8';
+
+ // Build tree structure
+ svg2.appendChild(svg3);
+ svg1.appendChild(svg2);
+
+ // Add foreignObject with HTML to svg1
+ html4.appendChild(html5);
+ foreign1.appendChild(html4);
+ svg1.appendChild(foreign1);
+
+ html1.appendChild(svg1);
+
+ math1.appendChild(math2);
+ html2.appendChild(math1);
+
+ // Add deeply nested SVG > foreignObject > HTML > SVG to html2
+ svg7.appendChild(svg8);
+ html6.appendChild(svg7);
+ foreign2.appendChild(html6);
+ svg6.appendChild(foreign2);
+ html2.appendChild(svg6);
+
+ html1.appendChild(html2);
+
+ svg4.appendChild(svg5);
+
+ // Create wrapper container for all trees
+ const wrapper = document.createElement('div');
+ wrapper.appendChild(html1);
+ wrapper.appendChild(svg4);
+ wrapper.appendChild(html3);
+
+ // NOW set all attributes
+ html1.setAttribute('viewBox', 'html-val');
+ html1.setAttribute('dataIndex', '0');
+ html1.setAttribute('testAttr', 'alpha');
+
+ svg1.setAttribute('viewBox', 'svg-val');
+ svg1.setAttribute('dataIndex', '1');
+ svg1.setAttribute('testAttr', 'beta');
+
+ svg2.setAttribute('mixedCase', 'foo');
+ svg2.setAttribute('dataValue', 'first');
+
+ svg3.setAttribute('innerAttr', 'found');
+
+ html2.setAttribute('viewBox', 'nested');
+ html2.setAttribute('mathVariant', 'bold');
+
+ math1.setAttribute('mathVariant', 'italic');
+ math1.setAttribute('displayStyle', 'true');
+
+ math2.setAttribute('testIndex', '5');
+ math2.setAttribute('dataValue', 'second');
+
+ svg5.setAttribute('viewBox', 'rect-val');
+ svg5.setAttribute('testIndex', '10');
+
+ html3.setAttribute('MixedCase', 'bar');
+ html3.setAttribute('TestIndex', '20');
+
+ // Set attributes on foreignObject tree in svg1
+ foreign1.setAttribute('foreignAttr', 'foreign-val');
+
+ html4.setAttribute('ForeignHTML', 'inside-foreign');
+ html4.setAttribute('DataType', 'html-in-svg');
+
+ html5.setAttribute('NestedCase', 'span-val');
+
+ // Set attributes on deeply nested tree in html2
+ svg6.setAttribute('DeepSVG', 'middle-svg');
+ svg6.setAttribute('CaseSensitive', 'nested');
+
+ foreign2.setAttribute('SecondForeign', 'deep-foreign');
+
+ html6.setAttribute('InnerHTML', 'deep-html');
+ html6.setAttribute('DataType', 'nested');
+
+ svg7.setAttribute('ReEnteredSVG', 'back-to-svg');
+
+ svg8.setAttribute('DeepestAttr', 'circle-val');
+ svg8.setAttribute('CaseSensitive', 'deepest');
+
+ return wrapper;
+ }
+ function runTests(root: Element): void {
+ // DOM tree structure with attributes:
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+
+ // Test 1: viewBox with mixed case - HTML is case-insensitive, SVG is case-sensitive
+ let results = root.querySelectorAll('[viewBox]');
+ expect(results.length, '[viewBox] should match 2 HTML elements + 2 SVG elements').toBe(4);
+ let ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html1', 'html2', 'svg1', 'svg5'],
+ message: '[viewBox] should match correct elements',
+ });
+
+ // Test 2: viewbox lowercase - should only match HTML elements
+ results = root.querySelectorAll('[viewbox]');
+ expect(results.length, '[viewbox] should only match HTML elements').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html1', 'html2'],
+ message: '[viewbox] should only match HTML divs',
+ });
+
+ // Test 3: VIEWBOX uppercase - should only match HTML elements
+ results = root.querySelectorAll('[VIEWBOX]');
+ expect(results.length, '[VIEWBOX] should only match HTML elements').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html1', 'html2'],
+ message: '[VIEWBOX] should only match HTML divs',
+ });
+
+ // Test 4: mathVariant - HTML is case-insensitive, MathML is case-sensitive
+ results = root.querySelectorAll('[mathVariant]');
+ expect(results.length, '[mathVariant] should match HTML + MathML').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html2', 'math1'],
+ message: '[mathVariant] should match correct elements',
+ });
+
+ // Test 5: mathvariant lowercase - should only match HTML
+ results = root.querySelectorAll('[mathvariant]');
+ expect(results.length, '[mathvariant] should only match HTML').toBe(1);
+ expect(results[0]!.id, '[mathvariant] should match html2').toBe('html2');
+
+ // Test 6: dataIndex exact case
+ results = root.querySelectorAll('[dataIndex]');
+ expect(results.length, '[dataIndex] should match HTML and SVG').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html1', 'svg1'],
+ message: '[dataIndex] should match html1 and svg1',
+ });
+
+ // Test 7: dataindex lowercase - should only match HTML
+ results = root.querySelectorAll('[dataindex]');
+ expect(results.length, '[dataindex] should only match HTML').toBe(1);
+ expect(results[0]!.id, '[dataindex] should match html1').toBe('html1');
+
+ // Test 8: testIndex mixed case
+ // HTML elements: selector lowercased → [testindex] → matches html3
+ // SVG/MathML: selector used as-is → [testIndex] → matches math2, svg5 (case-sensitive)
+ results = root.querySelectorAll('[testIndex]');
+ expect(results.length, '[testIndex] should match 3 elements').toBe(3);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html3', 'math2', 'svg5'],
+ message: '[testIndex] should match html3, math2, and svg5',
+ });
+
+ // Test 9: TestIndex with capital T
+ // HTML elements: selector lowercased → [testindex] → matches html3
+ // SVG/MathML: selector used as-is → [TestIndex] → no match (testIndex != TestIndex)
+ results = root.querySelectorAll('[TestIndex]');
+ expect(results.length, '[TestIndex] should only match HTML element').toBe(1);
+ expect(results[0]!.id, '[TestIndex] should match html3').toBe('html3');
+
+ // Test 10: testindex all lowercase
+ // HTML elements: selector lowercased → [testindex] → matches html3
+ // SVG/MathML: selector used as-is → [testindex] → no match (testIndex != testindex)
+ results = root.querySelectorAll('[testindex]');
+ expect(results.length, '[testindex] should only match HTML element').toBe(1);
+ expect(results[0]!.id, '[testindex] should match html3').toBe('html3');
+
+ // Test 11: mixedCase exact
+ // HTML elements: selector lowercased → [mixedcase] → matches html3
+ // SVG: selector used as-is → [mixedCase] → matches svg2 (case-sensitive)
+ results = root.querySelectorAll('[mixedCase]');
+ expect(results.length, '[mixedCase] should match 2 elements').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html3', 'svg2'],
+ message: '[mixedCase] should match html3 and svg2',
+ });
+
+ // Test 12: MixedCase capital M
+ // HTML elements: selector lowercased → [mixedcase] → matches html3
+ // SVG: selector used as-is → [MixedCase] → no match (mixedCase != MixedCase)
+ results = root.querySelectorAll('[MixedCase]');
+ expect(results.length, '[MixedCase] should only match HTML element').toBe(1);
+ expect(results[0]!.id, '[MixedCase] should match html3').toBe('html3');
+
+ // Test 13: mixedcase all lowercase
+ // HTML elements: selector lowercased → [mixedcase] → matches html3
+ // SVG: selector used as-is → [mixedcase] → no match (mixedCase != mixedcase)
+ results = root.querySelectorAll('[mixedcase]');
+ expect(results.length, '[mixedcase] should only match HTML element').toBe(1);
+ expect(results[0]!.id, '[mixedcase] should match html3').toBe('html3');
+
+ // Test 14: testAttr exact case
+ results = root.querySelectorAll('[testAttr]');
+ expect(results.length, '[testAttr] should match HTML and SVG').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html1', 'svg1'],
+ message: '[testAttr] should match html1 and svg1',
+ });
+
+ // Test 15: Attribute value matching with prefix
+ results = root.querySelectorAll('[viewBox^="svg"]');
+ expect(results.length, '[viewBox^="svg"] should match 1 SVG element').toBe(1);
+ expect(results[0]!.id, '[viewBox^="svg"] should match svg1').toBe('svg1');
+
+ // Test 16: Attribute value matching with exact match
+ results = root.querySelectorAll('[dataIndex="0"]');
+ expect(results.length, '[dataIndex="0"] should match 1 element').toBe(1);
+ expect(results[0]!.id, '[dataIndex="0"] should match html1').toBe('html1');
+
+ // Test 17: Nested element query
+ results = root.querySelectorAll('[innerAttr]');
+ expect(results.length, '[innerAttr] should match nested circle').toBe(1);
+ expect(results[0]!.id, '[innerAttr] should match svg3').toBe('svg3');
+
+ // Test 18: displayStyle on MathML
+ results = root.querySelectorAll('[displayStyle]');
+ expect(results.length, '[displayStyle] should match MathML').toBe(1);
+ expect(results[0]!.id, '[displayStyle] should match math1').toBe('math1');
+
+ // Test 19: displaystyle lowercase - should not match anything (no HTML element has it)
+ results = root.querySelectorAll('[displaystyle]');
+ expect(results.length, '[displaystyle] should not match anything').toBe(0);
+
+ // Test 20: Case-sensitive value matching with 's' flag
+ results = root.querySelectorAll('[testAttr="alpha" s]');
+ expect(results.length, '[testAttr="alpha" s] should match 1 element').toBe(1);
+ expect(results[0]!.id, '[testAttr="alpha" s] should match html1').toBe('html1');
+
+ // Test 21: Case-insensitive value matching with 'i' flag
+ results = root.querySelectorAll('[testAttr="ALPHA" i]');
+ expect(results.length, '[testAttr="ALPHA" i] should match 1 element').toBe(1);
+ expect(results[0]!.id, '[testAttr="ALPHA" i] should match html1').toBe('html1');
+
+ // Test 22: ForeignHTML - HTML inside foreignObject should be case-insensitive
+ results = root.querySelectorAll('[ForeignHTML]');
+ expect(results.length, '[ForeignHTML] should match HTML in foreignObject').toBe(1);
+ expect(results[0]!.id, '[ForeignHTML] should match html4').toBe('html4');
+
+ // Test 23: foreignhtml lowercase - should match HTML element in foreignObject
+ results = root.querySelectorAll('[foreignhtml]');
+ expect(results.length, '[foreignhtml] should match HTML in foreignObject').toBe(1);
+ expect(results[0]!.id, '[foreignhtml] should match html4').toBe('html4');
+
+ // Test 24: DataType - should match both HTML elements (case-insensitive)
+ results = root.querySelectorAll('[DataType]');
+ expect(results.length, '[DataType] should match 2 HTML elements').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html4', 'html6'],
+ message: '[DataType] should match html4 and html6',
+ });
+
+ // Test 25: datatype lowercase - should match both HTML elements
+ results = root.querySelectorAll('[datatype]');
+ expect(results.length, '[datatype] should match 2 HTML elements').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['html4', 'html6'],
+ message: '[datatype] should match html4 and html6',
+ });
+
+ // Test 26: CaseSensitive on SVG elements - case-sensitive
+ results = root.querySelectorAll('[CaseSensitive]');
+ expect(results.length, '[CaseSensitive] should match 2 SVG elements').toBe(2);
+ ids = Array.from(results)
+ .map((el) => el.id)
+ .toSorted();
+ arrayEquals(ids, {
+ expected: ['svg6', 'svg8'],
+ message: '[CaseSensitive] should match svg6, svg8',
+ });
+
+ // Test 27: casesensitive lowercase - should NOT match SVG elements
+ results = root.querySelectorAll('[casesensitive]');
+ expect(results.length, '[casesensitive] should not match SVG elements').toBe(0);
+
+ // Test 28: NestedCase - HTML span inside foreignObject should be case-insensitive
+ results = root.querySelectorAll('[NestedCase]');
+ expect(results.length, '[NestedCase] should match HTML span in foreignObject').toBe(1);
+ expect(results[0]!.id, '[NestedCase] should match html5').toBe('html5');
+
+ // Test 29: nestedcase lowercase - should match HTML span
+ results = root.querySelectorAll('[nestedcase]');
+ expect(results.length, '[nestedcase] should match HTML span in foreignObject').toBe(1);
+ expect(results[0]!.id, '[nestedcase] should match html5').toBe('html5');
+
+ // Test 30: ReEnteredSVG - SVG inside HTML inside foreignObject should be case-sensitive
+ results = root.querySelectorAll('[ReEnteredSVG]');
+ expect(
+ results.length,
+ '[ReEnteredSVG] should match SVG nested in HTML in foreignObject',
+ ).toBe(1);
+ expect(results[0]!.id, '[ReEnteredSVG] should match svg7').toBe('svg7');
+
+ // Test 31: reenteredsvg lowercase - should NOT match (SVG is case-sensitive)
+ results = root.querySelectorAll('[reenteredsvg]');
+ expect(results.length, '[reenteredsvg] should not match SVG element').toBe(0);
+
+ // Test 32: DeepestAttr - deeply nested SVG circle should be case-sensitive
+ results = root.querySelectorAll('[DeepestAttr]');
+ expect(results.length, '[DeepestAttr] should match deeply nested circle').toBe(1);
+ expect(results[0]!.id, '[DeepestAttr] should match svg8').toBe('svg8');
+
+ // Test 33: deepestattr lowercase - should NOT match
+ results = root.querySelectorAll('[deepestattr]');
+ expect(results.length, '[deepestattr] should not match SVG circle').toBe(0);
+
+ // Test 34: foreignAttr on foreignObject element - case-sensitive
+ results = root.querySelectorAll('[foreignAttr]');
+ expect(results.length, '[foreignAttr] should match foreignObject').toBe(1);
+ expect(results[0]!.id, '[foreignAttr] should match foreign1').toBe('foreign1');
+
+ // Test 35: foreignattr lowercase - should NOT match foreignObject (SVG namespace)
+ results = root.querySelectorAll('[foreignattr]');
+ expect(results.length, '[foreignattr] should not match foreignObject').toBe(0);
+
+ // Test 36: InnerHTML on HTML element inside foreignObject - case-insensitive
+ results = root.querySelectorAll('[InnerHTML]');
+ expect(results.length, '[InnerHTML] should match HTML in foreignObject').toBe(1);
+ expect(results[0]!.id, '[InnerHTML] should match html6').toBe('html6');
+
+ // Test 37: innerhtml lowercase - should match HTML in foreignObject
+ results = root.querySelectorAll('[innerhtml]');
+ expect(results.length, '[innerhtml] should match HTML in foreignObject').toBe(1);
+ expect(results[0]!.id, '[innerhtml] should match html6').toBe('html6');
+
+ // Test 38: DeepSVG on SVG element - case-sensitive
+ results = root.querySelectorAll('[DeepSVG]');
+ expect(results.length, '[DeepSVG] should match SVG element').toBe(1);
+ expect(results[0]!.id, '[DeepSVG] should match svg6').toBe('svg6');
+
+ // Test 39: deepsvg lowercase - should NOT match SVG
+ results = root.querySelectorAll('[deepsvg]');
+ expect(results.length, '[deepsvg] should not match SVG element').toBe(0);
+
+ // Test 40: SecondForeign on foreignObject element - case-sensitive
+ results = root.querySelectorAll('[SecondForeign]');
+ expect(results.length, '[SecondForeign] should match foreignObject').toBe(1);
+ expect(results[0]!.id, '[SecondForeign] should match foreign2').toBe('foreign2');
+
+ // Test 41: secondforeign lowercase - should NOT match foreignObject
+ results = root.querySelectorAll('[secondforeign]');
+ expect(results.length, '[secondforeign] should not match foreignObject').toBe(0);
+ }
+
+ const tree = buildTestTree();
+
+ // Test on disconnected tree first
+ runTests(tree);
+
+ // Now append to document and test again
+ container.appendChild(tree);
+ runTests(container);
+
+ container.innerHTML = '';
+ },
+ ['element.namespaces'],
+ );
+
+ context.test('WPT is-where-basic.html: :is() matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const actual = Array.from(main.querySelectorAll(':is()'));
+ const expected: Element[] = [];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT is-where-basic.html: :is(#a) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const actual = Array.from(main.querySelectorAll(':is(#a)'));
+ const expected: Element[] = [a];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT is-where-basic.html: :is(#a, #f) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll(':is(#a, #f)'));
+ const expected: Element[] = [a, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test(
+ 'WPT is-where-basic.html: :is(#a, #c) :where(#a #d, #c #f) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const d = document.getElementById('d')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll(':is(#a, #c) :where(#a #d, #c #f)'));
+ const expected: Element[] = [d, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT is-where-basic.html: #c > :is(#c > #f) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll('#c > :is(#c > #f)'));
+ const expected: Element[] = [f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT is-where-basic.html: #c > :is(#b > #f) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const actual = Array.from(main.querySelectorAll('#c > :is(#b > #f)'));
+ const expected: Element[] = [];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test('WPT is-where-basic.html: #a div:is(#d) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const d = document.getElementById('d')!;
+ const actual = Array.from(main.querySelectorAll('#a div:is(#d)'));
+ const expected: Element[] = [d];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT is-where-basic.html: :is(div) > div matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const d = document.getElementById('d')!;
+ const e = document.getElementById('e')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll(':is(div) > div'));
+ const expected: Element[] = [d, e, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT is-where-basic.html: :is(*) > div matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const d = document.getElementById('d')!;
+ const b = document.getElementById('b')!;
+ const e = document.getElementById('e')!;
+ const c = document.getElementById('c')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll(':is(*) > div'));
+ const expected: Element[] = [a, b, c, d, e, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT is-where-basic.html: :is(*) div matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const d = document.getElementById('d')!;
+ const b = document.getElementById('b')!;
+ const e = document.getElementById('e')!;
+ const c = document.getElementById('c')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll(':is(*) div'));
+ const expected: Element[] = [a, b, c, d, e, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test(
+ 'WPT is-where-basic.html: div > :where(#e, #f) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const e = document.getElementById('e')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll('div > :where(#e, #f)'));
+ const expected: Element[] = [e, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test('WPT is-where-basic.html: div > :where(*) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const d = document.getElementById('d')!;
+ const e = document.getElementById('e')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll('div > :where(*)'));
+ const expected: Element[] = [d, e, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test(
+ 'WPT is-where-basic.html: :is(*) > :where(*) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const d = document.getElementById('d')!;
+ const b = document.getElementById('b')!;
+ const e = document.getElementById('e')!;
+ const c = document.getElementById('c')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll(':is(*) > :where(*)'));
+ const expected: Element[] = [a, b, c, d, e, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT is-where-basic.html: :is(#a + #b) + :is(#c) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const c = document.getElementById('c')!;
+ const actual = Array.from(main.querySelectorAll(':is(#a + #b) + :is(#c)'));
+ const expected: Element[] = [c];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT is-where-basic.html: :is(#a, #b) + div matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n \n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const b = document.getElementById('b')!;
+ const c = document.getElementById('c')!;
+ const actual = Array.from(main.querySelectorAll(':is(#a, #b) + div'));
+ const expected: Element[] = [b, c];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test('WPT has-basic.html: :has(#a) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const actual = Array.from(main.querySelectorAll(':has(#a)'));
+ const expected: Element[] = [];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT has-basic.html: :has(.ancestor) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const actual = Array.from(main.querySelectorAll(':has(.ancestor)'));
+ const expected: Element[] = [a];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT has-basic.html: :has(.target) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const b = document.getElementById('b')!;
+ const f = document.getElementById('f')!;
+ const h = document.getElementById('h')!;
+ const actual = Array.from(main.querySelectorAll(':has(.target)'));
+ const expected: Element[] = [a, b, f, h];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT has-basic.html: :has(.descendant) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const b = document.getElementById('b')!;
+ const c = document.getElementById('c')!;
+ const f = document.getElementById('f')!;
+ const h = document.getElementById('h')!;
+ const j = document.getElementById('j')!;
+ const actual = Array.from(main.querySelectorAll(':has(.descendant)'));
+ const expected: Element[] = [a, b, c, f, h, j];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT has-basic.html: .parent:has(.target) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const b = document.getElementById('b')!;
+ const f = document.getElementById('f')!;
+ const h = document.getElementById('h')!;
+ const actual = Array.from(main.querySelectorAll('.parent:has(.target)'));
+ const expected: Element[] = [b, f, h];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test(
+ 'WPT has-basic.html: :has(.sibling ~ .target) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const b = document.getElementById('b')!;
+ const actual = Array.from(main.querySelectorAll(':has(.sibling ~ .target)'));
+ const expected: Element[] = [a, b];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT has-basic.html: .parent:has(.sibling ~ .target) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const b = document.getElementById('b')!;
+ const actual = Array.from(main.querySelectorAll('.parent:has(.sibling ~ .target)'));
+ const expected: Element[] = [b];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT has-basic.html: :has(:is(.target ~ .sibling .descendant)) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const h = document.getElementById('h')!;
+ const j = document.getElementById('j')!;
+ const actual = Array.from(main.querySelectorAll(':has(:is(.target ~ .sibling .descendant))'));
+ const expected: Element[] = [a, h, j];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT has-basic.html: .parent:has(:is(.target ~ .sibling .descendant)) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const h = document.getElementById('h')!;
+ const actual = Array.from(
+ main.querySelectorAll('.parent:has(:is(.target ~ .sibling .descendant))'),
+ );
+ const expected: Element[] = [h];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test(
+ 'WPT has-basic.html: .sibling:has(.descendant) ~ .target matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const e = document.getElementById('e')!;
+ const actual = Array.from(main.querySelectorAll('.sibling:has(.descendant) ~ .target'));
+ const expected: Element[] = [e];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test('WPT has-basic.html: :has(> .parent) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const actual = Array.from(main.querySelectorAll(':has(> .parent)'));
+ const expected: Element[] = [a];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT has-basic.html: :has(> .target) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const b = document.getElementById('b')!;
+ const f = document.getElementById('f')!;
+ const h = document.getElementById('h')!;
+ const actual = Array.from(main.querySelectorAll(':has(> .target)'));
+ const expected: Element[] = [b, f, h];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test(
+ 'WPT has-basic.html: :has(> .parent, > .target) matches expected elements',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const a = document.getElementById('a')!;
+ const b = document.getElementById('b')!;
+ const f = document.getElementById('f')!;
+ const h = document.getElementById('h')!;
+ const actual = Array.from(main.querySelectorAll(':has(> .parent, > .target)'));
+ const expected: Element[] = [a, b, f, h];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ },
+ );
+
+ context.test('WPT has-basic.html: :has(+ #h) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll(':has(+ #h)'));
+ const expected: Element[] = [f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test('WPT has-basic.html: .parent:has(~ #h) matches expected elements', (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const b = document.getElementById('b')!;
+ const f = document.getElementById('f')!;
+ const actual = Array.from(main.querySelectorAll('.parent:has(~ #h)'));
+ const expected: Element[] = [b, f];
+ expect(
+ actual
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ ).toBe(
+ expected
+ .map((element) => element.id)
+ .toSorted()
+ .join(),
+ );
+ });
+
+ context.test(
+ 'WPT has-basic.html: .sibling:has(.descendant) matches expected element',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const main = document.getElementById('main')!;
+ const c = document.getElementById('c')!;
+ expect(main.querySelector('.sibling:has(.descendant)')).toBe(c);
+ },
+ );
+
+ context.test(
+ 'WPT has-basic.html: closest(.ancestor:has(.descendant)) returns expected element',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const h = document.getElementById('h')!;
+ const k = document.getElementById('k')!;
+ expect(k.closest('.ancestor:has(.descendant)')).toBe(h);
+ },
+ );
+
+ context.test(
+ 'WPT has-basic.html: :has(.target ~ .sibling .descendant) matches expectedly',
+ (fixture) => {
+ const document = prepare(
+ fixture,
+ '
\n \n ',
+ );
+ const h = document.getElementById('h')!;
+ expect(h.matches(':has(.target ~ .sibling .descendant)')).toBe(true);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/element/selectors/matching.ts b/packages/web-platform-tests/src/compatibility/element/selectors/matching.ts
new file mode 100644
index 0000000..4ec0a6f
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors/matching.ts
@@ -0,0 +1,268 @@
+// Adapted from the WPT files listed below at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+import type { CompatibilityContext, DOMFixture } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const sourceFiles = [
+ 'dom/nodes/Element-closest-null-selectors.html',
+ 'dom/nodes/Element-closest.html',
+ 'dom/nodes/Element-matches-complex-nth-last-of-type.html',
+ 'dom/nodes/Element-matches-disabled-pseudo-class.html',
+ 'dom/nodes/Element-matches-active-pseudo-class.html',
+] as const;
+
+function html(fixture: DOMFixture, markup: string): Document {
+ const document = fixture.document as unknown as Document;
+ const container = document.createElement('div');
+ container.innerHTML = markup;
+ for (const child of Array.from(container.children))
+ fixture.connect(child as unknown as DOMElementContract);
+ return document;
+}
+
+const closestMarkup = `
+
+
`;
+
+const closestCases = [
+ ['select', 'test12', 'test3'],
+ ['fieldset', 'test13', 'test2'],
+ ['div', 'test13', 'test6'],
+ ['body', 'test3', 'body'],
+ ['[default]', 'test4', 'test4'],
+ ['[selected]', 'test4', ''],
+ ['[selected]', 'test11', 'test11'],
+ ['[name="form-a"]', 'test12', 'test5'],
+ ['form[name="form-a"]', 'test13', 'test5'],
+ ['input[required]', 'test9', 'test9'],
+ ['select[required]', 'test9', ''],
+ ['div:not(.div1)', 'test13', 'test7'],
+ ['div.div3', 'test6', 'test8'],
+ ['div#test7', 'test1', 'test7'],
+ ['.div3 > .div2', 'test12', 'test7'],
+ ['.div3 > .div1', 'test12', ''],
+ ['form > input[required]', 'test9', ''],
+ ['fieldset > select[required]', 'test12', 'test3'],
+ ['input + fieldset', 'test6', ''],
+ ['form + form', 'test3', 'test5'],
+ ['form + form', 'test5', 'test5'],
+ [':empty', 'test10', 'test10'],
+ [':last-child', 'test11', 'test2'],
+ [':first-child', 'test12', 'test3'],
+ [':invalid', 'test11', 'test2'],
+ [':scope', 'test4', 'test4'],
+ ['select > :scope', 'test4', 'test4'],
+ ['div > :scope', 'test4', ''],
+ [':has(> :scope)', 'test4', 'test3'],
+] as const;
+
+const disabledMarkup = `
+
+
+
+ Button
+
+
+
+
+
+ Button
+
+
+
+
+
A Legend `;
+
+/** Registers every assertion from the pinned closest and matches regressions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const selector of ['#null', '.null']) {
+ context.test(
+ `WPT Element-closest-null-selectors.html: element.closest('${selector}') should return null`,
+ (fixture) => {
+ const document = html(fixture, '
Some text
');
+ expect(document.getElementById('testP')!.closest(selector)).toBeNull();
+ },
+ );
+ }
+ for (const [selector, elementId, targetId] of closestCases) {
+ context.test(
+ `WPT Element-closest.html: Element.closest with context node '${elementId}' and selector '${selector}'`,
+ (fixture) => {
+ const document = html(fixture, closestMarkup);
+ const oldId = document.body.getAttribute('id');
+ document.body.id = 'body';
+ fixture.cleanup(() => {
+ if (oldId === null) document.body.removeAttribute('id');
+ else document.body.id = oldId;
+ });
+ const result = document.getElementById(elementId)!.closest(selector);
+ if (result === null) expect(targetId).toBe('');
+ else expect(result.id).toBe(targetId);
+ },
+ selector === ':invalid'
+ ? ['elements.form.validation', 'elements.input.validation', 'elements.select.validation']
+ : [],
+ );
+ }
+ context.test(
+ 'WPT Element-matches-complex-nth-last-of-type.html: element.matches() with complex :nth-last-of-type selector',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const div = document.createElement('div');
+ div.innerHTML = '
';
+ const c = div.querySelector('.c')!;
+ expect(c.matches('.a:nth-last-of-type(1) .b .c')).toBe(false);
+ },
+ );
+ for (const [id, title] of [
+ [
+ 'input-in-disabled-fieldset',
+ 'Input in disabled fieldset should match :disabled pseudo-class',
+ ],
+ [
+ 'select-in-disabled-fieldset',
+ 'Select in disabled fieldset should match :disabled pseudo-class',
+ ],
+ [
+ 'button-in-disabled-fieldset',
+ 'Button in disabled fieldset should match :disabled pseudo-class',
+ ],
+ [
+ 'textarea-in-disabled-fieldset',
+ 'Textarea in disabled fieldset should match :disabled pseudo-class',
+ ],
+ ['disabled-fieldset', 'Disabled fieldset itself should match :disabled pseudo-class'],
+ ] as const) {
+ context.test(
+ `WPT Element-matches-disabled-pseudo-class.html: ${title}`,
+ (fixture) => {
+ const document = html(fixture, disabledMarkup);
+ expect(document.getElementById(id)!.matches(':disabled')).toBe(true);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ }
+ context.test(
+ 'WPT Element-matches-disabled-pseudo-class.html: Input in enabled fieldset should match :enabled pseudo-class',
+ (fixture) => {
+ const document = html(fixture, disabledMarkup);
+ const input = document.getElementById('input-in-enabled-fieldset')!;
+ expect(input.matches(':enabled')).toBe(true);
+ expect(input.matches(':disabled')).toBe(false);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ context.test(
+ "WPT Element-matches-disabled-pseudo-class.html: querySelectorAll(':disabled') should select all disabled elements in DOM order",
+ (fixture) => {
+ const document = html(fixture, disabledMarkup);
+ expect(
+ Array.from(document.querySelectorAll(':disabled')).map((element) => element.id),
+ ).toEqual([
+ 'disabled-fieldset',
+ 'input-in-disabled-fieldset',
+ 'select-in-disabled-fieldset',
+ 'button-in-disabled-fieldset',
+ 'textarea-in-disabled-fieldset',
+ 'disabled-input',
+ ]);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ context.test(
+ "WPT Element-matches-disabled-pseudo-class.html: querySelectorAll(':enabled') should not select elements in disabled fieldset",
+ (fixture) => {
+ const document = html(fixture, disabledMarkup);
+ const ids = Array.from(document.querySelectorAll(':enabled')).map((element) => element.id);
+ for (const id of [
+ 'enabled-fieldset',
+ 'input-in-enabled-fieldset',
+ 'select-in-enabled-fieldset',
+ 'button-in-enabled-fieldset',
+ 'textarea-in-enabled-fieldset',
+ 'enabled-input',
+ ])
+ expect(ids.includes(id)).toBe(true);
+ for (const id of [
+ 'disabled-fieldset',
+ 'input-in-disabled-fieldset',
+ 'select-in-disabled-fieldset',
+ 'button-in-disabled-fieldset',
+ 'textarea-in-disabled-fieldset',
+ 'disabled-input',
+ ])
+ expect(ids.includes(id)).toBe(false);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ context.test(
+ "WPT Element-matches-disabled-pseudo-class.html: querySelector('input:enabled') should return the first enabled input",
+ (fixture) => {
+ const document = html(fixture, disabledMarkup);
+ expect(document.querySelector('input:enabled')!.id).toBe('input-in-enabled-fieldset');
+ },
+ ['elements.fieldset.disabled'],
+ );
+ context.test(
+ "WPT Element-matches-disabled-pseudo-class.html: querySelectorAll('input:disabled') should select inputs with disabled attribute and in disabled fieldset",
+ (fixture) => {
+ const document = html(fixture, disabledMarkup);
+ expect(
+ Array.from(document.querySelectorAll('input:disabled')).map((input) => input.id),
+ ).toEqual(['input-in-disabled-fieldset', 'disabled-input']);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ context.test(
+ 'WPT Element-matches-disabled-pseudo-class.html: Fieldset with legend should not be considered disabled by default',
+ (fixture) => {
+ const document = html(fixture, disabledMarkup);
+ const fieldset = document.getElementById('fieldset-with-legend')!;
+ expect(fieldset.matches(':disabled')).toBe(false);
+ expect(fieldset.matches(':enabled')).toBe(true);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ for (const [id, title] of [
+ ['test', 'Regular div should not match :active by default'],
+ ['button', 'Button should not match :active by default'],
+ ['link', 'Link should not match :active by default'],
+ ] as const) {
+ context.test(`WPT Element-matches-active-pseudo-class.html: ${title}`, (fixture) => {
+ const document = html(
+ fixture,
+ '
Test element
\n
Button element \n
Link element ',
+ );
+ expect(document.getElementById(id)!.matches(':active')).toBe(false);
+ });
+ }
+ context.test(
+ 'WPT Element-matches-active-pseudo-class.html: No elements should match :active by default via querySelectorAll',
+ (fixture) => {
+ const document = html(
+ fixture,
+ '
Test element
\n
Button element \n
Link element ',
+ );
+ expect(document.querySelectorAll(':active').length).toBe(0);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/element/selectors/matrix.ts b/packages/web-platform-tests/src/compatibility/element/selectors/matrix.ts
new file mode 100644
index 0000000..d2c6458
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors/matrix.ts
@@ -0,0 +1,336 @@
+// Adapted from the WPT files in sourceFiles at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+import type { CompatibilityContext, DOMFixture } from '../../context.ts';
+import type { DOMFeaturePath } from '../../features.ts';
+import {
+ invalidSelectors,
+ validSelectors,
+ scopedSelectors,
+ TEST_QSA,
+ TEST_MATCH,
+ type Selector,
+} from './data.ts';
+import * as content from './fixtures.ts';
+
+export const sourceFiles = [
+ 'dom/nodes/selectors.js',
+ 'dom/nodes/ParentNode-querySelector-All.js',
+ 'dom/nodes/ParentNode-querySelector-All.html',
+ 'dom/nodes/ParentNode-querySelector-All-xht.xht',
+ 'dom/nodes/ParentNode-querySelector-All-content.html',
+ 'dom/nodes/ParentNode-querySelector-All-content.xht',
+ 'dom/nodes/Element-matches.js',
+ 'dom/nodes/Element-matches-init.js',
+ 'dom/nodes/Element-matches.html',
+] as const;
+
+type Root = Document | Element | DocumentFragment;
+type Kind = 'document' | 'detached' | 'fragment' | 'element' | 'empty';
+const roots = {
+ document: 'Document',
+ detached: 'Detached Element',
+ fragment: 'Fragment',
+ element: 'In-document Element',
+} as const;
+const htmlNamespace = 'http://www.w3.org/1999/xhtml';
+const exampleNamespace = 'http://www.example.org/ns';
+
+/** Walk without selector queries so expected tree order is independent of the tested API. */
+function elements(root: Node): Element[] {
+ const result: Element[] = [];
+ function visit(node: Node): void {
+ if (node.nodeType === 1) result.push(node as Element);
+ for (let child = node.firstChild; child; child = child.nextSibling) visit(child);
+ }
+ visit(root);
+ return result;
+}
+
+/** The original programmatic additions to both HTML and XHTML content documents. */
+function specialElements(document: Document, parent: Element): void {
+ parent.appendChild(document.createElement('null'));
+ parent.appendChild(document.createElement('undefined'));
+ for (const prefix of ['any-namespace', 'no-namespace']) {
+ const container = document.createElement('div');
+ container.id = prefix;
+ const children = [
+ document.createElement('div'),
+ document.createElementNS(htmlNamespace, 'div'),
+ document.createElementNS('', 'div'),
+ document.createElementNS(exampleNamespace, 'div'),
+ ];
+ children.forEach((child, index) => {
+ child.setAttribute('id', `${prefix}-div${index + 1}`);
+ container.appendChild(child);
+ });
+ parent.appendChild(container);
+ }
+ document.getElementById('attr-presence-i1')!.setAttributeNS(exampleNamespace, 'title', '');
+}
+
+function prepare(fixture: DOMFixture, kind: Kind): Root {
+ const document = fixture.document as unknown as Document;
+ const element = document.getElementById('root')!;
+ specialElements(document, element);
+ if (kind === 'document') return document;
+ if (kind === 'empty') return document.createElement('div');
+ if (kind === 'detached') return element.cloneNode(true) as Element;
+ if (kind === 'fragment') {
+ const fragment = document.createDocumentFragment();
+ fragment.appendChild(element.cloneNode(true));
+ return fragment;
+ }
+ // The upstream connected-element queries must exclude this indistinguishable sibling tree.
+ const clone = element.cloneNode(true);
+ for (const descendant of elements(clone)) descendant.setAttribute('data-clone', '');
+ document.body.appendChild(clone);
+ return element;
+}
+
+function insertionParent(root: Root): Node {
+ return root.nodeType === 9 ? (root as Document).body : root;
+}
+
+function prepareQueryMutations(fixture: DOMFixture, root: Root): void {
+ const document = fixture.document as unknown as Document;
+ const connected = document.getElementById('root')!;
+ // Both connected mutations precede valid queries in the upstream wrapper.
+ document.body.appendChild(document.createElement('div'));
+ connected.appendChild(document.createElement('div'));
+ if (root !== document && root !== connected) root.appendChild(document.createElement('div'));
+}
+
+function prerequisites(kind: Kind, xml: boolean): DOMFeaturePath[] {
+ return [
+ 'element.namespaces',
+ 'document.lookup',
+ ...(kind === 'detached' || kind === 'element' || kind === 'fragment'
+ ? ['node.cloning' as const]
+ : []),
+ ...(kind === 'fragment' ? ['node.fragments' as const] : []),
+ ...(xml ? ['document.xml' as const] : []),
+ ];
+}
+
+function permitted(row: Selector, context: { kind: Kind; xml: boolean; mask: number }): boolean {
+ return (
+ Boolean(row.testType & context.mask) &&
+ !row.exclude?.includes(context.kind) &&
+ !row.exclude?.includes(context.xml ? 'xhtml' : 'html')
+ );
+}
+
+/** Retains the original matrix, including API masks and HTML/XML/root exclusions. */
+export function register(context: CompatibilityContext): void {
+ for (const xml of [false, true]) registerQueries(context, xml);
+ registerMatches(context);
+}
+
+function registerQueries(context: CompatibilityContext, xml: boolean): void {
+ const { expect } = context.runner;
+ const source = xml ? 'ParentNode-querySelector-All-xht.xht' : 'ParentNode-querySelector-All.html';
+ function test(
+ options: { kind: Kind; name: string },
+ run: (fixture: DOMFixture, root: Root) => void,
+ ): void {
+ context.test(
+ `WPT ${source}: ${options.name}`,
+ (fixture) => run(fixture, prepare(fixture, options.kind)),
+ {
+ requires: prerequisites(options.kind, xml),
+ document: {
+ contentType: xml ? 'application/xhtml+xml' : 'text/html',
+ content: xml ? content.xhtml : content.html,
+ fragment: 'target',
+ },
+ },
+ );
+ }
+ function syntaxError(fixture: DOMFixture, operation: () => unknown): void {
+ try {
+ operation();
+ expect.fail('Expected SyntaxError');
+ } catch (error) {
+ expect(error).toBeInstanceOf(fixture.globals('DOMException'));
+ expect(error).toMatchObject({ name: 'SyntaxError', code: 12 });
+ }
+ }
+ for (const [kind, label] of Object.entries(roots) as [Exclude
, string][]) {
+ for (const method of ['querySelector', 'querySelectorAll'] as const) {
+ test({ kind, name: `${label} supports ${method}` }, (_fixture, root) =>
+ expect(typeof root[method]).toBe('function'),
+ );
+ }
+ test({ kind, name: `${label}.querySelectorAll returns NodeList instance` }, (fixture, root) => {
+ expect(root.querySelectorAll('div')).toBeInstanceOf(fixture.globals('NodeList'));
+ });
+ for (const method of ['querySelectorAll', 'querySelector'] as const) {
+ for (const value of [null, undefined]) {
+ test({ kind, name: `${label}.${method} ${String(value)}` }, (_fixture, root) => {
+ const result = Reflect.apply(root[method], root, [value]);
+ if (method === 'querySelectorAll') expect(result.length).toBe(1);
+ else {
+ if (value === null) expect(result).not.toBeNull();
+ else expect(result).not.toBeUndefined();
+ expect(result.tagName.toUpperCase()).toBe(String(value).toUpperCase());
+ }
+ });
+ }
+ test({ kind, name: `${label}.${method} no parameter` }, (fixture, root) => {
+ expect(() => Reflect.apply(root[method], root, [])).toThrow(fixture.globals('TypeError'));
+ });
+ }
+ test({ kind, name: `${label}.querySelectorAll tree order` }, (_fixture, root) => {
+ const result = root.querySelectorAll('*');
+ elements(root)
+ .filter((element) => element !== root)
+ .forEach((element, index) => expect(result[index]).toBe(element));
+ });
+ for (const variant of ['static NodeList', 'new NodeList']) {
+ test({ kind, name: `${label}: ${variant}` }, (fixture, root) => {
+ const before = root.querySelectorAll('div');
+ const beforeLength = before.length;
+ insertionParent(root).appendChild(
+ (fixture.document as unknown as Document).createElement('div'),
+ );
+ if (variant === 'static NodeList') expect(before.length).toBe(beforeLength);
+ else expect(root.querySelectorAll('div').length).toBe(beforeLength + 1);
+ });
+ }
+ for (const row of validSelectors) {
+ if (!permitted(row, { kind, xml, mask: TEST_QSA })) continue;
+ for (const method of ['querySelectorAll', 'querySelector'] as const) {
+ test(
+ { kind, name: `${label}.${method}: ${row.name}: ${row.selector}` },
+ (fixture, root) => {
+ // verifyStaticList precedes valid queries in WPT; preserve that mutation in each fresh case.
+ prepareQueryMutations(fixture, root);
+ const all = root.querySelectorAll(row.selector);
+ if (method === 'querySelectorAll') {
+ expect(all).not.toBeNull();
+ expect(all.length).toBe(row.expect.length);
+ row.expect.forEach((id, index) => {
+ expect(all[index]).not.toBeNull();
+ expect(all[index]!.getAttribute('id')).toBe(id);
+ expect(all[index]!.hasAttribute('data-clone')).toBe(false);
+ });
+ } else {
+ const found = root.querySelector(row.selector);
+ if (row.expect.length) {
+ expect(found).not.toBeNull();
+ expect(found!.getAttribute('id')).toBe(row.expect[0]);
+ expect(found).toBe(all[0]);
+ expect(found!.hasAttribute('data-clone')).toBe(false);
+ } else expect(found).toBeNull();
+ }
+ },
+ );
+ }
+ }
+ }
+ for (const [kind, label] of Object.entries({ ...roots, empty: 'Empty Element' }) as [
+ Kind,
+ string,
+ ][]) {
+ for (const row of invalidSelectors) {
+ for (const method of ['querySelector', 'querySelectorAll'] as const) {
+ test({ kind, name: `${label}.${method}: ${row.name}: ${row.selector}` }, (fixture, root) =>
+ syntaxError(fixture, () =>
+ method === 'querySelector'
+ ? root.querySelector(row.selector)
+ : root.querySelectorAll(row.selector),
+ ),
+ );
+ }
+ }
+ }
+}
+
+function registerMatches(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ function test(
+ options: { kind: Kind; name: string },
+ run: (fixture: DOMFixture, root: Root) => void,
+ ): void {
+ context.test(
+ `WPT Element-matches.html: ${options.name}`,
+ (fixture) => run(fixture, prepare(fixture, options.kind)),
+ {
+ requires: prerequisites(options.kind, false),
+ document: { contentType: 'text/html', content: content.html, fragment: 'target' },
+ },
+ );
+ }
+ for (const [kind, label] of Object.entries(roots) as [Exclude, string][]) {
+ const isElement = kind === 'element' || kind === 'detached';
+ test(
+ { kind, name: `${label} ${isElement ? 'supports' : 'should not support'} matches` },
+ (_fixture, root) => {
+ expect('matches' in root).toBe(isElement);
+ if (isElement) expect(Object.hasOwn(root, 'matches')).toBe(false);
+ },
+ );
+ if (isElement)
+ for (const row of invalidSelectors) {
+ test({ kind, name: `${label}.matches: ${row.name}: ${row.selector}` }, (fixture, root) => {
+ try {
+ (root as Element).matches(row.selector);
+ expect.fail('Expected SyntaxError');
+ } catch (error) {
+ expect(error).toBeInstanceOf(fixture.globals('DOMException'));
+ expect(error).toMatchObject({ name: 'SyntaxError', code: 12 });
+ }
+ });
+ }
+ }
+ for (const tag of ['div', 'null', 'undefined']) {
+ for (const value of [null, undefined, 'missing'] as const) {
+ const suffix = value === 'missing' ? ' no parameter' : `(${String(value)})`;
+ test(
+ { kind: 'document', name: `${tag.toUpperCase()} Element.matches${suffix}` },
+ (fixture, root) => {
+ const element =
+ tag === 'div'
+ ? (root as Document).getElementById('root')!
+ : (root as Document).createElement(tag);
+ if (value === 'missing')
+ expect(() => Reflect.apply(element.matches, element, [])).toThrow(
+ fixture.globals('TypeError'),
+ );
+ else expect(Reflect.apply(element.matches, element, [value])).toBe(tag === String(value));
+ },
+ );
+ }
+ }
+ for (const [kind, label] of [
+ ['document', 'In-document'],
+ ['detached', 'Detached'],
+ ['fragment', 'Fragment'],
+ ] as const) {
+ const rows = kind === 'document' ? [...validSelectors, ...scopedSelectors] : validSelectors;
+ for (const row of rows) {
+ if (!permitted(row, { kind, xml: false, mask: TEST_MATCH })) continue;
+ const reference = row.ctx ? 'refNode Element' : 'no refNodes';
+ test(
+ {
+ kind,
+ name: `${label} Element.matches: ${row.name} (with ${reference}): ${row.selector}`,
+ },
+ (_fixture, root) => {
+ for (const [ids, expected] of [
+ [row.expect, true],
+ [row.unexpected ?? [], false],
+ ] as const) {
+ for (const id of ids) {
+ const element = root.querySelector(`#${id}`)!;
+ const args = row.ctx ? [row.selector, root.querySelector(row.ctx)] : [row.selector];
+ expect(Reflect.apply(element.matches, element, args), `#${id}: ${row.selector}`).toBe(
+ expected,
+ );
+ }
+ }
+ },
+ );
+ }
+ }
+}
diff --git a/packages/web-platform-tests/src/compatibility/element/selectors/namespaces.ts b/packages/web-platform-tests/src/compatibility/element/selectors/namespaces.ts
new file mode 100644
index 0000000..b6064a6
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors/namespaces.ts
@@ -0,0 +1,454 @@
+// Adapted from the WPT files listed below at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+import type { CompatibilityContext, DOMFixture } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const sourceFiles = [
+ 'dom/nodes/Element-matches-namespaced-elements.html',
+ 'dom/nodes/ParentNode-querySelector-case-insensitive.html',
+ 'dom/nodes/ParentNode-querySelector-multiple-attributes-xml.xhtml',
+ 'dom/nodes/ParentNode-querySelector-namespace-tagname-attribute.html',
+ 'dom/nodes/ParentNode-querySelector-scope-numeric-id.xhtml',
+ 'dom/nodes/ParentNode-querySelector-svg-not-root.html',
+ 'dom/nodes/ParentNode-querySelector-tag-case.html',
+ 'dom/nodes/ParentNode-querySelector-underscore-local-name.xhtml',
+ 'dom/nodes/ParentNode-querySelector-xml-capitalization.window.js',
+ 'dom/nodes/ParentNode-querySelectorAll-namespace.html',
+ 'dom/nodes/ParentNode-querySelectorAll-svg-append.html',
+ 'dom/nodes/ParentNode-querySelectorAll-svg-class.html',
+ 'dom/nodes/ParentNode-querySelectors-namespaces.html',
+ 'dom/nodes/svg-template-querySelector.html',
+] as const;
+
+const xmlRequirements = ['document.xml', 'document.parsing'] as const;
+
+function html(fixture: DOMFixture, markup: string): Document {
+ const document = fixture.document as unknown as Document;
+ const container = document.createElement('div');
+ container.innerHTML = markup;
+ for (const child of Array.from(container.children))
+ fixture.connect(child as unknown as DOMElementContract);
+ return document;
+}
+
+function xml(
+ fixture: DOMFixture,
+ options: { markup: string; type?: DOMParserSupportedType },
+): Document {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ return new DOMParser().parseFromString(options.markup, options.type ?? 'application/xml');
+}
+
+function xhtml(fixture: DOMFixture, markup: string): Document {
+ return xml(fixture, {
+ markup: `${markup}`,
+ type: 'application/xhtml+xml',
+ });
+}
+
+const propertiesMarkup = `
+
+
+ `;
+
+const tagCaseMarkup = `
+
+
+
Paragraph 1
+
Paragraph 2
+
+
+
`;
+
+const soapMarkup = `
+
+
+ 1234554321
+ ManagedCustomerService
+ get
+ 1
+ 115
+
+
+
+
+
+ 2
+ ManagedCustomerPage
+
+ Test1
+ 1234566789
+
+
+ Test2
+ 987654321
+
+
+
+
+ `;
+
+/** Registers standard-API assertions from the pinned namespace, XML, and case regressions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const method of ['matches'] as const) {
+ for (const { namespace, name, selector, title } of [
+ {
+ namespace: '',
+ name: 'element',
+ selector: 'element',
+ title: `empty string namespace, ${method}`,
+ },
+ { namespace: 'urn:ns', name: 'h', selector: 'h', title: `has a namespace, ${method}` },
+ { namespace: 'urn:ns', name: 'h', selector: '*|h', title: `has a namespace, *|, ${method}` },
+ ]) {
+ context.test(
+ `WPT Element-matches-namespaced-elements.html: ${title}`,
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const element = document.createElementNS(namespace, name);
+ expect(element[method](selector)).toBe(true);
+ },
+ ['element.namespaces'],
+ );
+ }
+ }
+ for (const method of ['querySelector', 'querySelectorAll'] as const) {
+ context.test(`WPT ParentNode-querySelector-case-insensitive.html: ${method}`, (fixture) => {
+ const document = html(fixture, ' ');
+ const input = document.getElementById('testInput');
+ if (method === 'querySelector')
+ expect(document.querySelector('input[name*=user i]')).toBe(input);
+ else expect(Array.from(document.querySelectorAll('input[name*=user i]'))).toEqual([input]);
+ });
+ }
+ context.test(
+ 'WPT ParentNode-querySelector-multiple-attributes-xml.xhtml: querySelector with multiple attribute selectors in XML',
+ (fixture) => {
+ const document = xhtml(
+ fixture,
+ `
+
+
+
+
+
+
+
+
+ `,
+ );
+ const target = document.getElementById('targetFilter');
+ expect(
+ document.querySelector(
+ 'mlt > tractor[id="tractor0"] > filter[mlt_service="greyscale"][track="0"]',
+ ),
+ ).toBe(target);
+ },
+ xmlRequirements,
+ );
+ for (const { selector, title, expected } of [
+ { selector: 'coreProperties', title: 'match selector without prefix', expected: 'target' },
+ {
+ selector: '*|coreProperties',
+ title: 'match namespace selector with universal prefix',
+ expected: 'target',
+ },
+ {
+ selector: 'cp|coreProperties',
+ title: 'throws on specified namespace selector',
+ expected: 'throw',
+ },
+ {
+ selector: ':is(cp|coreProperties)',
+ title: 'does not throw on specified namespace selector if forgiven',
+ expected: 'null',
+ },
+ {
+ selector: '[lang]',
+ title: 'does not match attriubte selector without prefix',
+ expected: 'null',
+ },
+ {
+ selector: '[*|lang]',
+ title: 'match namespace attriubte selector with universal prefix',
+ expected: 'target',
+ },
+ {
+ selector: '[xml|lang]',
+ title: 'throws on specified namespace attriubte selector',
+ expected: 'throw',
+ },
+ {
+ selector: ':is([xml|lang])',
+ title: 'does not throw on specified namespace attriubte selector if forgiven',
+ expected: 'null',
+ },
+ ]) {
+ context.test(
+ `WPT ParentNode-querySelector-namespace-tagname-attribute.html: ${title}`,
+ (fixture) => {
+ const document = xml(fixture, { markup: propertiesMarkup });
+ if (expected === 'throw')
+ expect(() => document.querySelector(selector)).toThrow(
+ expect.objectContaining({ name: 'SyntaxError' }),
+ );
+ else
+ expect(document.querySelector(selector)).toBe(
+ expected === 'null' ? null : document.getElementById('target'),
+ );
+ },
+ xmlRequirements,
+ );
+ }
+ context.test(
+ 'WPT ParentNode-querySelector-scope-numeric-id.xhtml: querySelector with :scope on element with numeric ID',
+ (fixture) => {
+ const document = xhtml(fixture, '
');
+ const scope = document.getElementById('9a');
+ expect(scope).not.toBeNull();
+ const result = scope!.querySelector(':scope > b');
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('target_b');
+ },
+ xmlRequirements,
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-svg-not-root.html: svg:not(:root) in a HTML document',
+ (fixture) => {
+ const document = html(
+ fixture,
+ `
+
+
+
+ `,
+ );
+ const svg = document.getElementById('the-svg');
+ expect(svg).not.toBeNull();
+ expect(document.querySelector('svg:not(:root)')).toBe(svg);
+ },
+ ['parsing.namespaces'],
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-tag-case.html: The same element should be found with both lowercase and uppercase div selectors',
+ (fixture) => {
+ const document = html(fixture, tagCaseMarkup);
+ const box = document.getElementById('container')!.firstElementChild;
+ const lower = document.querySelector('div.box'),
+ upper = document.querySelector('DIV.box');
+ expect(lower).not.toBeNull();
+ expect(upper).not.toBeNull();
+ expect(lower).toBe(upper);
+ expect(lower).toBe(box);
+ },
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-tag-case.html: The same paragraph should be found with both lowercase and uppercase p selectors',
+ (fixture) => {
+ const document = html(fixture, tagCaseMarkup);
+ const lower = document.querySelector('#container p#p1'),
+ upper = document.querySelector('#container P#p1');
+ expect(lower).not.toBeNull();
+ expect(upper).not.toBeNull();
+ expect(lower).toBe(upper);
+ expect(lower).toBe(document.getElementById('p1'));
+ },
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-tag-case.html: The same NodeList should be returned for querySelectorAll with lowercase and uppercase p selectors',
+ (fixture) => {
+ const document = html(fixture, tagCaseMarkup);
+ const lower = Array.from(document.querySelectorAll('div.box p')),
+ upper = Array.from(document.querySelectorAll('div.box P'));
+ expect(lower).toEqual(upper);
+ expect(lower).toEqual([document.getElementById('p1'), document.getElementById('p2')]);
+ },
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-tag-case.html: Mixed case selectors should work correctly',
+ (fixture) => {
+ const document = html(fixture, tagCaseMarkup);
+ expect(document.querySelectorAll('DIV.box p').length).toBe(2);
+ const mixed = document.querySelector('div#container DIV.box P#p2');
+ expect(mixed).not.toBeNull();
+ expect(mixed!.id).toBe('p2');
+ },
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-tag-case.html: Tag name case-insensitivity with child combinator (>) dynamically created',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const div = document.createElement('div'),
+ ul = document.createElement('ul'),
+ li = document.createElement('li');
+ ul.appendChild(li);
+ div.appendChild(ul);
+ expect(Array.from(div.querySelectorAll('ul'))).toEqual([ul]);
+ expect(Array.from(div.querySelectorAll('UL'))).toEqual([ul]);
+ expect(Array.from(div.querySelectorAll('ul > li'))).toEqual([li]);
+ expect(Array.from(div.querySelectorAll('UL > LI'))).toEqual([li]);
+ },
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-tag-case.html: Case-insensitivity with non-standard tag names',
+ (fixture) => {
+ const document = html(fixture, tagCaseMarkup);
+ const container = document.getElementById('container-for-3750')!;
+ const custom = document.createElement('myElement');
+ custom.textContent = 'Hello world';
+ container.appendChild(custom);
+ expect(container.querySelector('myelement')).toBe(custom);
+ expect(container.querySelector('myElement')).toBe(custom);
+ },
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-underscore-local-name.xhtml: Element local name with an underscore in querySelector',
+ (fixture) => {
+ const document = xhtml(fixture, '<_g>hey ');
+ expect(document.querySelector('data > _g > b')!.textContent).toBe('hey');
+ },
+ xmlRequirements,
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-xml-capitalization.window.js: Descendant combinator',
+ (fixture) => {
+ const document = xml(fixture, { markup: 'baz ' });
+ expect(document.querySelector('Foo')).toBe(document.documentElement);
+ expect(document.querySelector('bar')).toBe(document.documentElement.firstChild);
+ expect(document.querySelector('Foo bar')).toBe(document.documentElement.firstChild);
+ },
+ xmlRequirements,
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-xml-capitalization.window.js: Descendant combinator with *',
+ (fixture) => {
+ const document = xml(fixture, {
+ markup: `
+
+
+
+
+
+
+
+
+`,
+ });
+ const c = document.documentElement.children[0]!.firstElementChild,
+ e = document.documentElement.children[1]!.firstElementChild;
+ expect(document.querySelector('c')).not.toBeNull();
+ expect(document.querySelector('aB *')).toBe(c);
+ expect(document.querySelector('e')).not.toBeNull();
+ expect(document.querySelector('cd *')).toBe(e);
+ },
+ xmlRequirements,
+ );
+ context.test(
+ 'WPT ParentNode-querySelector-xml-capitalization.window.js: Child combinator',
+ (fixture) => {
+ const document = xml(fixture, {
+ markup: `
+
+ content
+ abc
+
+ `,
+ type: 'text/xml',
+ });
+ expect(document.querySelector('Test')).not.toBeNull();
+ expect(document.querySelector('test')).toBeNull();
+ expect(document.querySelector('my-tag')).not.toBeNull();
+ expect(document.querySelector('Test>my-tag')).not.toBeNull();
+ },
+ xmlRequirements,
+ );
+ context.test(
+ 'WPT ParentNode-querySelectorAll-namespace.html: querySelectorAll with namespaced XML elements',
+ (fixture) => {
+ const document = xml(fixture, { markup: soapMarkup, type: 'text/xml' });
+ expect(document.querySelectorAll('rval > entries').length).toBe(2);
+ },
+ xmlRequirements,
+ );
+ context.test(
+ 'WPT ParentNode-querySelectorAll-svg-append.html: Sanity check with div',
+ (fixture) => {
+ const document = html(fixture, ' ');
+ const div = document.getElementsByTagName('div')[0]!;
+ expect(div.querySelectorAll('div p').length).toBe(1);
+ },
+ ['parsing.namespaces'],
+ );
+ context.test(
+ 'WPT ParentNode-querySelectorAll-svg-append.html: Append child nodes to svg',
+ (fixture) => {
+ const document = html(fixture, ' ');
+ const svg = document.getElementsByTagName('svg')[0]!;
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ svg.append(g);
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ g.append(circle);
+ expect(g.querySelectorAll('g circle').length).toBe(1);
+ expect(g.querySelectorAll('g>circle').length).toBe(1);
+ expect(g.querySelectorAll('circle').length).toBe(1);
+ },
+ ['parsing.namespaces', 'element.namespaces'],
+ );
+ context.test(
+ 'WPT ParentNode-querySelectorAll-svg-class.html: Selecting a svg element with a class selector',
+ (fixture) => {
+ const document = html(fixture, '\n
\n\n ');
+ expect(document.querySelectorAll('svg.test').length).toBe(1);
+ },
+ ['parsing.namespaces'],
+ );
+ context.test(
+ 'WPT ParentNode-querySelectors-namespaces.html: querySelectorAll must work with namespace attribute selectors on SVG',
+ (fixture) => {
+ const document = html(fixture, ' ');
+ const element = document.getElementById('thesvg');
+ expect(document.querySelector('[*|href]')).toBe(element);
+ expect(Array.from(document.querySelectorAll('[*|href]'))).toEqual([element]);
+ },
+ ['parsing.namespaces'],
+ );
+ for (const { id, selector, nested, title } of [
+ {
+ id: 'template1',
+ selector: 'div',
+ nested: false,
+ title: 'querySelector works on template contents fragments with HTML elements (sanity check)',
+ },
+ {
+ id: 'template2',
+ selector: 'svg',
+ nested: false,
+ title: 'querySelector works on template contents fragments with SVG elements',
+ },
+ {
+ id: 'template3',
+ selector: 'svg',
+ nested: true,
+ title: 'querySelector works on template contents fragments with nested SVG elements',
+ },
+ ]) {
+ context.test(
+ `WPT svg-template-querySelector.html: ${title}`,
+ (fixture) => {
+ const document = html(
+ fixture,
+ '
\n \n
',
+ );
+ const fragment = (document.getElementById(id) as HTMLTemplateElement).content;
+ const scope = nested ? (fragment.firstChild as Element) : fragment;
+ expect(scope.querySelector(selector)).not.toBeNull();
+ },
+ ['templates.parsing', 'parsing.namespaces'],
+ );
+ }
+}
diff --git a/packages/web-platform-tests/src/compatibility/element/selectors/roots.ts b/packages/web-platform-tests/src/compatibility/element/selectors/roots.ts
new file mode 100644
index 0000000..d2e5523
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/element/selectors/roots.ts
@@ -0,0 +1,578 @@
+// Adapted from the WPT files listed in sourceFiles at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+// No individual authors are declared in these source files.
+import type { CompatibilityContext, DOMFixture } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const sourceFiles = [
+ 'dom/nodes/DocumentFragment-querySelectorAll-after-modification.html',
+ 'dom/nodes/Element-querySelectorAll-context-element.html',
+ 'dom/nodes/ParentNode-querySelector-All-comma-selector.html',
+ 'dom/nodes/ParentNode-querySelector-All-scope.html',
+ 'dom/nodes/ParentNode-querySelector-argument-stringification.html',
+ 'dom/nodes/ParentNode-querySelector-attribute-change.html',
+ 'dom/nodes/ParentNode-querySelector-class-change.html',
+ 'dom/nodes/ParentNode-querySelector-dupe-id.html',
+ 'dom/nodes/ParentNode-querySelector-innerHTML.html',
+ 'dom/nodes/ParentNode-querySelector-orphan-scope.html',
+ 'dom/nodes/ParentNode-querySelector-parent-context.html',
+ 'dom/nodes/ParentNode-querySelector-scope-in-not.html',
+ 'dom/nodes/ParentNode-querySelector-scope.html',
+ 'dom/nodes/ParentNode-querySelector-host-pseudo-class.html',
+ 'dom/nodes/ParentNode-querySelector-shadowroot-nth-child.html',
+ 'dom/nodes/ParentNode-querySelectorAll-removed-elements.html',
+ 'dom/nodes/ParentNode-querySelectorAll-scope-change.html',
+ 'dom/nodes/ParentNode-querySelectorAll-scope-colon-id-class.html',
+ 'dom/nodes/ParentNode-querySelectors-exclusive.html',
+] as const;
+
+function title(source: string, name: string): string {
+ return `WPT ${source}: ${name}`;
+}
+
+function mount(fixture: DOMFixture, markup: string): HTMLElement {
+ const document = fixture.document as unknown as Document;
+ const container = document.createElement('main');
+ container.innerHTML = markup;
+ fixture.connect(container as unknown as DOMElementContract);
+ return container;
+}
+
+/** Registers every case in the listed pinned WPT selector files. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ title(
+ 'DocumentFragment-querySelectorAll-after-modification.html',
+ 'querySelectorAll should still work on DocumentFragments after they are modified',
+ ),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const frag = document.createDocumentFragment();
+ frag.appendChild(document.createElement('div'));
+ expect(Array.from(frag.querySelectorAll('img')), 'before modification').toEqual([]);
+ frag.appendChild(document.createElement('div'));
+ expect(Array.from(frag.querySelectorAll('img')), 'after modification').toEqual([]);
+ },
+ ['node.fragments'],
+ );
+
+ for (const [name, selector, fromDocument] of [
+ ['Query from document', '.root table tbody tr', true],
+ ['Query without root class', 'table tbody tr', false],
+ ['Query with root class', '.root table tbody tr', false],
+ ] as const) {
+ context.test(title('Element-querySelectorAll-context-element.html', name), (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const container = mount(
+ fixture,
+ '',
+ );
+ const root = container.firstElementChild!.firstElementChild!;
+ const target = root.firstElementChild!.firstElementChild!.firstElementChild!;
+ expect(Array.from((fromDocument ? document : root).querySelectorAll(selector))).toEqual([
+ target,
+ ]);
+ });
+ }
+
+ context.test(
+ title('ParentNode-querySelector-All-comma-selector.html', 'Basic comma-separated selector'),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ mount(fixture, 'Hello world Goodbye, world ');
+ expect(document.querySelectorAll('h1,h2').length).toBe(2);
+ },
+ );
+ context.test(
+ title(
+ 'ParentNode-querySelector-All-comma-selector.html',
+ 'Repeated identical querySelectorAll calls with repeated selectors should work on empty elements',
+ ),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const el = document.createElement('div');
+ const selector = '.foo, .foo, .bar';
+ const results1 = el.querySelectorAll(selector);
+ expect(results1.length).toBe(0);
+ const results2 = el.querySelectorAll(selector);
+ expect(results2.length).toBe(0);
+ },
+ );
+
+ const scopeMarkup =
+ '' +
+ '' +
+ '';
+ for (const name of [
+ 'querySelector',
+ 'querySelectorAll',
+ 'querySelector comma separated',
+ 'querySelectorAll comma separated',
+ ]) {
+ context.test(title('ParentNode-querySelector-All-scope.html', name), (fixture) => {
+ const container = mount(fixture, scopeMarkup);
+ const div = container.firstElementChild!;
+ const [p, p2, div2] = Array.from(div.children);
+ if (name === 'querySelector') {
+ expect(div.querySelector(':scope > p')).toBe(p);
+ expect(div.querySelector(':scope > span')).toBeNull();
+ } else if (name === 'querySelectorAll') {
+ expect(Array.from(div.querySelectorAll(':scope > p'))).toEqual([p, p2]);
+ expect(Array.from(div.querySelectorAll(':scope > span'))).toEqual([]);
+ } else if (name === 'querySelector comma separated') {
+ expect(div.querySelector(':scope > p, :scope > div')).toBe(p);
+ } else {
+ expect(Array.from(div.querySelectorAll(':scope > p, :scope > div'))).toEqual([p, p2, div2]);
+ }
+ });
+ }
+ context.test(
+ title(
+ 'ParentNode-querySelector-All-scope.html',
+ 'querySelector with :scope > child should not match descendants',
+ ),
+ (fixture) => {
+ const scopeDiv = mount(fixture, scopeMarkup).children[1]!;
+ const result = scopeDiv.querySelector(':scope > span');
+ expect(result, ':scope > span should not match descendant span').toBeNull();
+ },
+ );
+ context.test(
+ title(
+ 'ParentNode-querySelector-All-scope.html',
+ 'querySelectorAll with :scope > child should only match direct children',
+ ),
+ (fixture) => {
+ const ul = mount(fixture, scopeMarkup).children[2]!;
+ const result = ul.querySelectorAll(':scope > li');
+ const resultIDs = Array.from(result, (el) => el.id);
+ expect(resultIDs, ':scope > li should only match direct children').toEqual(['alpha', 'beta']);
+ },
+ );
+ context.test(
+ title(
+ 'ParentNode-querySelector-All-scope.html',
+ 'querySelectorAll with :scope in unattached svg document',
+ ),
+ (fixture) => {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const doc = new DOMParser().parseFromString(
+ ' ',
+ 'image/svg+xml',
+ );
+ const svg = doc.documentElement;
+ const g = svg.children[1]!;
+ const result = svg.querySelectorAll('g');
+ expect(Array.from(result), ':scope descend match descendants').toEqual([g]);
+ },
+ ['document.parsing', 'document.xml'],
+ );
+ context.test(
+ title(
+ 'ParentNode-querySelector-All-scope.html',
+ 'querySelectorAll with :scope in document where element moved',
+ ),
+ (fixture) => {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const xml = ' ';
+ const doc = new DOMParser().parseFromString(xml, 'text/xml');
+ const theBar = doc.documentElement.children[0]!;
+ const childBar = doc.documentElement.children[1]!;
+ const bars = Array.from(doc.querySelectorAll('bar'));
+ for (const bar of bars) {
+ if (!bar.parentElement) continue;
+ bar.parentElement.querySelector(':scope > child-bars')!.appendChild(bar);
+ }
+ expect(theBar.parentNode === childBar).toBe(true);
+ },
+ ['document.parsing', 'document.xml', 'node.append'],
+ );
+
+ for (const name of [
+ 'querySelector stringifies Array argument via toString()',
+ 'querySelectorAll stringifies Array argument via toString()',
+ 'querySelector stringifies object with custom toString()',
+ ]) {
+ context.test(
+ title('ParentNode-querySelector-argument-stringification.html', name),
+ (fixture) => {
+ const container = mount(
+ fixture,
+ 'bold italic 1 italic 2 paragraph
',
+ ).firstElementChild!;
+ if (name.startsWith('querySelectorAll')) {
+ const results = container.querySelectorAll(['strong', 'em'] as unknown as string);
+ expect(
+ results.length,
+ "Array.toString() produces 'strong,em' matching both element types",
+ ).toBe(3);
+ } else if (name.includes('Array')) {
+ const result = container.querySelector(['strong'] as unknown as string);
+ expect(result!.textContent).toBe('bold');
+ } else {
+ const result = container.querySelector({
+ toString() {
+ return 'p';
+ },
+ } as unknown as string);
+ expect(result!.textContent).toBe('paragraph');
+ }
+ },
+ );
+ }
+
+ const attributeMarkup =
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '';
+ for (const [index, attribute] of [
+ 'id',
+ 'class',
+ 'hidden',
+ 'style',
+ 'readonly',
+ 'width',
+ 'height',
+ ].entries()) {
+ context.test(
+ title('ParentNode-querySelector-attribute-change.html', `${attribute} attribute`),
+ (fixture) => {
+ const div = mount(fixture, attributeMarkup).children[index]!;
+ const node1 = div.firstElementChild!,
+ node2 = div.lastElementChild!;
+ expect(div.querySelector(`[${attribute}]`), 'initial').toBe(node1);
+ expect(div.querySelector(`:not([${attribute}])`), 'initial not').toBe(node2);
+ node1.removeAttribute(attribute);
+ expect(div.querySelector(`[${attribute}]`), 'change attribute').toBeNull();
+ expect(div.querySelector(`:not([${attribute}])`), 'change attribute not').toBe(node1);
+ },
+ );
+ }
+
+ for (const [name, selector, isSVG, nextClass] of [
+ ['svg simple class', '.a', true, 'b'],
+ ['svg compound class', 'g.a', true, 'b'],
+ ['svg complex class', 'svg g.a', true, 'b'],
+ ['html simple class', '.one', false, 'b'],
+ ['html compound class', 'span.one', false, 'two'],
+ ['html complex class', 'div span.one', false, 'two'],
+ ] as const) {
+ context.test(
+ title('ParentNode-querySelector-class-change.html', name),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const container = mount(
+ fixture,
+ '
',
+ );
+ const node = container.children[isSVG ? 0 : 1]!.firstElementChild!;
+ fixture.cleanup(() => {
+ if (isSVG) node.setAttributeNS(null, 'class', 'a');
+ else node.setAttribute('class', 'one');
+ });
+ expect(document.querySelector(selector), 'initial').toBe(node);
+ if (isSVG) node.setAttributeNS(null, 'class', nextClass);
+ else node.setAttribute('class', nextClass);
+ expect(
+ document.querySelector(selector),
+ name === 'html simple class' ? 'querySelectorchange class' : 'change class',
+ ).toBeNull();
+ },
+ isSVG ? ['parsing.namespaces', 'element.namespaces'] : [],
+ );
+ }
+
+ for (const connected of [false, true]) {
+ context.test(
+ title(
+ 'ParentNode-querySelector-dupe-id.html',
+ connected
+ ? 'Returns a non-null value for connected cloned node with duplicated ID'
+ : 'Sanity: returns a non-null value',
+ ),
+ (fixture) => {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const html = '';
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const root = doc.documentElement;
+ const test1 = doc.body.firstElementChild!;
+ const test1Clone = test1.cloneNode(true) as Element;
+ const expected = test1Clone.firstElementChild!;
+ if (connected) root.appendChild(test1Clone);
+ const test2 = test1Clone.querySelector('#test2');
+ expect(test2).not.toBeNull();
+ expect(test2).toBe(expected);
+ expect(
+ test1Clone.querySelector('[id=test2]'),
+ 'ID selector and attribute selector points to the same node',
+ ).toBe(expected);
+ if (!connected)
+ expect(
+ test1Clone.querySelectorAll('#test2')[0],
+ 'querySelector and querySelectorAll[0] points to the same node',
+ ).toBe(expected);
+ },
+ ['document.parsing', 'node.cloning'],
+ );
+ }
+
+ context.test(
+ title(
+ 'ParentNode-querySelector-innerHTML.html',
+ 'querySelector matches nodes inserted by innerHTML',
+ ),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const div = document.createElement('div');
+ expect(div.innerHTML, 'initial').toBe('');
+ div.innerHTML = '
';
+ const expectedDiv = div.firstElementChild!,
+ expectedSpan = expectedDiv.firstElementChild!;
+ expect(div.querySelector('div'), 'inner div').toBe(expectedDiv);
+ expect(div.querySelector('span'), 'inner span').toBe(expectedSpan);
+ },
+ );
+
+ for (const self of [true, false]) {
+ context.test(
+ title(
+ 'ParentNode-querySelector-orphan-scope.html',
+ self
+ ? 'querySelectorAll on orphaned element does not return the element itself'
+ : 'querySelectorAll descendant selector works on orphaned elements',
+ ),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div');
+ parent.id = 'fuz';
+ const child = document.createElement('span');
+ child.id = 'buz';
+ parent.appendChild(child);
+ if (self)
+ expect(
+ parent.querySelectorAll('#fuz').length,
+ 'should not return the base element itself',
+ ).toBe(0);
+ else {
+ const results = parent.querySelectorAll('#fuz #buz');
+ expect(results.length).toBe(1);
+ expect(results[0]).toBe(child);
+ }
+ },
+ );
+ }
+
+ context.test(
+ title(
+ 'ParentNode-querySelector-parent-context.html',
+ 'querySelector with ancestor context in selector',
+ ),
+ (fixture) => {
+ const container = mount(fixture, '
').firstElementChild!;
+ container.innerHTML = '
';
+ const section = container.firstElementChild!.firstElementChild!;
+ const expectedSpan = section.firstElementChild!;
+ expect(section.querySelector('div section span')).toBe(expectedSpan);
+ },
+ );
+
+ context.test(
+ title(
+ 'ParentNode-querySelector-scope-in-not.html',
+ ':scope nested in :not should select one element per [data-main] scope',
+ ),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ mount(
+ fixture,
+ '',
+ );
+ const mainAttribute = 'data-main',
+ itemAttribute = 'data-item',
+ sideAttribute = 'data-side';
+ const result = [];
+ const foundElements = document.querySelectorAll(`[${mainAttribute}]`);
+ for (const element of foundElements) result.push(element);
+ for (const element of result) {
+ const selector = `:scope [${sideAttribute}]:not(:scope [${itemAttribute}] [${itemAttribute}] [${sideAttribute}])`;
+ const results = element.querySelectorAll(selector);
+ expect(results.length).toBe(1);
+ }
+ },
+ );
+
+ for (const name of [
+ 'querySelector with :scope',
+ 'querySelector with id and sibling',
+ 'querySelectorAll with :scope',
+ 'querySelectorAll with id and sibling',
+ ]) {
+ context.test(title('ParentNode-querySelector-scope.html', name), (fixture) => {
+ const div = mount(
+ fixture,
+ '',
+ ).firstElementChild!;
+ const p = div.children[1]!;
+ if (name === 'querySelector with :scope') {
+ expect(div.querySelector(':scope > p')).toBe(p);
+ expect(div.querySelector(':scope > span')).toBeNull();
+ } else if (name === 'querySelector with id and sibling') {
+ expect(div.querySelector('#test + p')).toBe(p);
+ expect(p.querySelector('#test + p')).toBeNull();
+ } else if (name === 'querySelectorAll with :scope') {
+ expect(Array.from(div.querySelectorAll(':scope > p'))).toEqual([p]);
+ expect(Array.from(div.querySelectorAll(':scope > span'))).toEqual([]);
+ } else {
+ expect(Array.from(div.querySelectorAll('#test + p'))).toEqual([p]);
+ expect(Array.from(p.querySelectorAll('#test + p'))).toEqual([]);
+ }
+ });
+ }
+
+ context.test(
+ title(
+ 'ParentNode-querySelector-host-pseudo-class.html',
+ 'Using :host pseudo-class selector in shadow DOM',
+ ),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const section = document.createElement('section');
+ section.id = 'test-container';
+ fixture.connect(section as unknown as DOMElementContract);
+ const article = document.createElement('article');
+ const shadowSection = section.attachShadow({ mode: 'open' });
+ shadowSection.appendChild(article);
+ const normalQuery = shadowSection.querySelector('article');
+ expect(normalQuery, 'Simple querySelector for article should find the element').toBe(article);
+ const hostQuery = shadowSection.querySelector(':host > article');
+ expect(hostQuery, 'querySelector with :host pseudo-class should find the element').toBe(
+ article,
+ );
+ },
+ ['shadow.roots.attachment'],
+ );
+
+ context.test(
+ title(
+ 'ParentNode-querySelector-shadowroot-nth-child.html',
+ 'div:nth-child(2) should select #first when preceded by a style inside shadow root',
+ ),
+ (fixture) => {
+ const container = mount(
+ fixture,
+ '
hello
',
+ );
+ const hostElement = container.children[1]!;
+ const shadowRoot = hostElement.attachShadow({ mode: 'open' });
+ shadowRoot.innerHTML =
+ 'hello
';
+ const el = shadowRoot.querySelector('div:nth-child(2)');
+ expect(el!.id).toBe('first');
+ },
+ ['shadow.roots.attachment'],
+ );
+
+ context.test(
+ title(
+ 'ParentNode-querySelectorAll-removed-elements.html',
+ 'querySelectorAll must not return removed elements',
+ ),
+ (fixture) => {
+ const container = mount(fixture, '
').firstElementChild!;
+ const getIDs = (): string[] => [...container.querySelectorAll('a.test')].map((el) => el.id);
+ container.innerHTML = 'a link ';
+ expect(getIDs(), 'Sanity check: initial setup').toEqual(['link-a']);
+ container.innerHTML = ' ';
+ expect(getIDs(), 'After replacement').toEqual(['link-b']);
+ container.innerHTML = 'a link ';
+ expect(getIDs(), 'After changing back to the original HTML').toEqual(['link-a']);
+ },
+ );
+
+ context.test(
+ title('ParentNode-querySelectorAll-scope-change.html', 'Change the scope of querySelectorAll'),
+ (fixture) => {
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const data =
+ 'PayloadType Configuration PayloadContent FullScreen ';
+ const xmlDocument = new DOMParser().parseFromString(data, 'application/xml');
+ const expectedRoot = xmlDocument.documentElement.firstElementChild!;
+ const expectedTarget1 = expectedRoot.children[0]!;
+ const expectedTarget2 = expectedRoot.children[3]!.children[0]!.children[0]!;
+ const rootCollection = xmlDocument.querySelector(':root > dict')!;
+ const keysInRootCollection = rootCollection.querySelectorAll(':scope > key');
+ expect(keysInRootCollection.item(0), 'first item').toBe(expectedTarget1);
+ const subCollection = rootCollection.children[3]!.children[0]!;
+ const keysInSubCollection = subCollection.querySelectorAll(':scope > key');
+ const clonedSubCollection = subCollection.cloneNode(true) as Element;
+ const keysInClonedSubCollection = clonedSubCollection.querySelectorAll(':scope > key');
+ expect(subCollection.outerHTML, 'outerHTML').toBe(clonedSubCollection.outerHTML);
+ expect(keysInSubCollection.length, 'length').toBe(keysInClonedSubCollection.length);
+ expect(keysInSubCollection.item(0), 'second item').toBe(expectedTarget2);
+ },
+ ['document.parsing', 'document.xml', 'node.cloning', 'parsing.serialization'],
+ );
+
+ context.test(
+ title(
+ 'ParentNode-querySelectorAll-scope-colon-id-class.html',
+ ':scope child selector on element with colon-prefixed class',
+ ),
+ (fixture) => {
+ const container = mount(
+ fixture,
+ '
',
+ );
+ const div = container.firstElementChild!;
+ const expectedSpan = div.firstElementChild!;
+ const result = div.querySelectorAll(':scope > span');
+ expect(Array.from(result)).toEqual([expectedSpan]);
+ },
+ );
+ context.test(
+ title(
+ 'ParentNode-querySelectorAll-scope-colon-id-class.html',
+ ':scope selector on element with colon-prefixed ID',
+ ),
+ (fixture) => {
+ const container = mount(
+ fixture,
+ '
',
+ );
+ const div = container.children[1]!;
+ const result = div.querySelector(':scope');
+ expect(result).toBeNull();
+ },
+ );
+
+ context.test(
+ title(
+ 'ParentNode-querySelectors-exclusive.html',
+ 'querySelector/querySelectorAll should not include their thisArg',
+ ),
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const button = document.createElement('button');
+ for (const stage of ['before modification', 'after modification']) {
+ if (stage === 'after modification') button.innerHTML = 'text';
+ for (const selector of ['*', 'button', 'button, span']) {
+ expect(
+ button.querySelector(selector),
+ `querySelector, '${selector}', ${stage}`,
+ ).toBeNull();
+ }
+ for (const selector of ['*', 'button', 'button, span']) {
+ expect(
+ Array.from(button.querySelectorAll(selector)),
+ `querySelectorAll, '${selector}', ${stage}`,
+ ).toEqual([]);
+ }
+ }
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/elements/button/activation.ts b/packages/web-platform-tests/src/compatibility/elements/button/activation.ts
new file mode 100644
index 0000000..1eebfcf
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/button/attributes.ts b/packages/web-platform-tests/src/compatibility/elements/button/attributes.ts
new file mode 100644
index 0000000..24ffc28
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/button/form-association.ts b/packages/web-platform-tests/src/compatibility/elements/button/form-association.ts
new file mode 100644
index 0000000..8d54169
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/datalist.ts b/packages/web-platform-tests/src/compatibility/elements/datalist.ts
new file mode 100644
index 0000000..ad74de4
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/details/exclusive-groups.ts b/packages/web-platform-tests/src/compatibility/elements/details/exclusive-groups.ts
new file mode 100644
index 0000000..d24e515
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/details/open.ts b/packages/web-platform-tests/src/compatibility/elements/details/open.ts
new file mode 100644
index 0000000..7bc5af1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/details/toggle-events.ts b/packages/web-platform-tests/src/compatibility/elements/details/toggle-events.ts
new file mode 100644
index 0000000..76557b8
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/dialog/close.ts b/packages/web-platform-tests/src/compatibility/elements/dialog/close.ts
new file mode 100644
index 0000000..c82ade6
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/dialog/focus.ts b/packages/web-platform-tests/src/compatibility/elements/dialog/focus.ts
new file mode 100644
index 0000000..9c84579
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/dialog/form-submission.ts b/packages/web-platform-tests/src/compatibility/elements/dialog/form-submission.ts
new file mode 100644
index 0000000..8d96cc4
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/dialog/modal.ts b/packages/web-platform-tests/src/compatibility/elements/dialog/modal.ts
new file mode 100644
index 0000000..45c218f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/dialog/request-close.ts b/packages/web-platform-tests/src/compatibility/elements/dialog/request-close.ts
new file mode 100644
index 0000000..53b7bc1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/dialog/show.ts b/packages/web-platform-tests/src/compatibility/elements/dialog/show.ts
new file mode 100644
index 0000000..46e67ad
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/fieldset/controls.ts b/packages/web-platform-tests/src/compatibility/elements/fieldset/controls.ts
new file mode 100644
index 0000000..ba9f730
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/fieldset/disabled.ts b/packages/web-platform-tests/src/compatibility/elements/fieldset/disabled.ts
new file mode 100644
index 0000000..19bff5e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/form/controls.ts b/packages/web-platform-tests/src/compatibility/elements/form/controls.ts
new file mode 100644
index 0000000..0e4455e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/form/data-events.ts b/packages/web-platform-tests/src/compatibility/elements/form/data-events.ts
new file mode 100644
index 0000000..3cf55dc
--- /dev/null
+++ b/packages/web-platform-tests/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 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/web-platform-tests/src/compatibility/elements/form/form-data.ts b/packages/web-platform-tests/src/compatibility/elements/form/form-data.ts
new file mode 100644
index 0000000..233e617
--- /dev/null
+++ b/packages/web-platform-tests/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 =
+ 'submit ';
+ 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/web-platform-tests/src/compatibility/elements/form/named-controls.ts b/packages/web-platform-tests/src/compatibility/elements/form/named-controls.ts
new file mode 100644
index 0000000..87dc112
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/form/reset.ts b/packages/web-platform-tests/src/compatibility/elements/form/reset.ts
new file mode 100644
index 0000000..f85e718
--- /dev/null
+++ b/packages/web-platform-tests/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 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/web-platform-tests/src/compatibility/elements/form/submission.ts b/packages/web-platform-tests/src/compatibility/elements/form/submission.ts
new file mode 100644
index 0000000..dd1e1b1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/form/validation.ts b/packages/web-platform-tests/src/compatibility/elements/form/validation.ts
new file mode 100644
index 0000000..8032aa4
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/activation.ts b/packages/web-platform-tests/src/compatibility/elements/input/activation.ts
new file mode 100644
index 0000000..0b65855
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/checkedness.ts b/packages/web-platform-tests/src/compatibility/elements/input/checkedness.ts
new file mode 100644
index 0000000..b0f892b
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/cloning.ts b/packages/web-platform-tests/src/compatibility/elements/input/cloning.ts
new file mode 100644
index 0000000..c14dbdf
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/color-value.ts b/packages/web-platform-tests/src/compatibility/elements/input/color-value.ts
new file mode 100644
index 0000000..6c46b3a
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/date-value.ts b/packages/web-platform-tests/src/compatibility/elements/input/date-value.ts
new file mode 100644
index 0000000..b9ac90f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/form-association.ts b/packages/web-platform-tests/src/compatibility/elements/input/form-association.ts
new file mode 100644
index 0000000..ab357e0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/labels.ts b/packages/web-platform-tests/src/compatibility/elements/input/labels.ts
new file mode 100644
index 0000000..b251f42
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/numeric-value.ts b/packages/web-platform-tests/src/compatibility/elements/input/numeric-value.ts
new file mode 100644
index 0000000..bf3ffea
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/radio-groups.ts b/packages/web-platform-tests/src/compatibility/elements/input/radio-groups.ts
new file mode 100644
index 0000000..b81dee1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/range-text.ts b/packages/web-platform-tests/src/compatibility/elements/input/range-text.ts
new file mode 100644
index 0000000..2a5b15e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/sanitization.ts b/packages/web-platform-tests/src/compatibility/elements/input/sanitization.ts
new file mode 100644
index 0000000..d0af00b
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/selection.ts b/packages/web-platform-tests/src/compatibility/elements/input/selection.ts
new file mode 100644
index 0000000..abb3e0b
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/stepping.ts b/packages/web-platform-tests/src/compatibility/elements/input/stepping.ts
new file mode 100644
index 0000000..8215964
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/validation.ts b/packages/web-platform-tests/src/compatibility/elements/input/validation.ts
new file mode 100644
index 0000000..52802fa
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/input/value.ts b/packages/web-platform-tests/src/compatibility/elements/input/value.ts
new file mode 100644
index 0000000..665ac8f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/label/activation.ts b/packages/web-platform-tests/src/compatibility/elements/label/activation.ts
new file mode 100644
index 0000000..af0e187
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/label/association.ts b/packages/web-platform-tests/src/compatibility/elements/label/association.ts
new file mode 100644
index 0000000..b0277d4
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/legend.ts b/packages/web-platform-tests/src/compatibility/elements/legend.ts
new file mode 100644
index 0000000..4b43a16
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/meter.ts b/packages/web-platform-tests/src/compatibility/elements/meter.ts
new file mode 100644
index 0000000..37110a3
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/optgroup.ts b/packages/web-platform-tests/src/compatibility/elements/optgroup.ts
new file mode 100644
index 0000000..1fb8b4a
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/option/attributes.ts b/packages/web-platform-tests/src/compatibility/elements/option/attributes.ts
new file mode 100644
index 0000000..c9a541c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/option/selectedness.ts b/packages/web-platform-tests/src/compatibility/elements/option/selectedness.ts
new file mode 100644
index 0000000..faa6c13
--- /dev/null
+++ b/packages/web-platform-tests/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.
+
+/** 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/web-platform-tests/src/compatibility/elements/option/text.ts b/packages/web-platform-tests/src/compatibility/elements/option/text.ts
new file mode 100644
index 0000000..fa3c6fe
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/output/html-for.ts b/packages/web-platform-tests/src/compatibility/elements/output/html-for.ts
new file mode 100644
index 0000000..18b08cb
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/output/reset.ts b/packages/web-platform-tests/src/compatibility/elements/output/reset.ts
new file mode 100644
index 0000000..47e83a7
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/output/value.ts b/packages/web-platform-tests/src/compatibility/elements/output/value.ts
new file mode 100644
index 0000000..14ca27d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/progress.ts b/packages/web-platform-tests/src/compatibility/elements/progress.ts
new file mode 100644
index 0000000..393cc6f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/select/cloning.ts b/packages/web-platform-tests/src/compatibility/elements/select/cloning.ts
new file mode 100644
index 0000000..d0ad61b
--- /dev/null
+++ b/packages/web-platform-tests/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 = 'A B ';
+ 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/web-platform-tests/src/compatibility/elements/select/indexed-options.ts b/packages/web-platform-tests/src/compatibility/elements/select/indexed-options.ts
new file mode 100644
index 0000000..57ebcd0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/select/mutation.ts b/packages/web-platform-tests/src/compatibility/elements/select/mutation.ts
new file mode 100644
index 0000000..9d7fad3
--- /dev/null
+++ b/packages/web-platform-tests/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 = 'A B ';
+ 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/web-platform-tests/src/compatibility/elements/select/options.ts b/packages/web-platform-tests/src/compatibility/elements/select/options.ts
new file mode 100644
index 0000000..dd7692d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/select/selection.ts b/packages/web-platform-tests/src/compatibility/elements/select/selection.ts
new file mode 100644
index 0000000..4264700
--- /dev/null
+++ b/packages/web-platform-tests/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 = 'A B ';
+ 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/web-platform-tests/src/compatibility/elements/select/validation.ts b/packages/web-platform-tests/src/compatibility/elements/select/validation.ts
new file mode 100644
index 0000000..9dcf1bb
--- /dev/null
+++ b/packages/web-platform-tests/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 = 'Choose X ';
+ 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/web-platform-tests/src/compatibility/elements/selectedcontent.ts b/packages/web-platform-tests/src/compatibility/elements/selectedcontent.ts
new file mode 100644
index 0000000..2859082
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/summary/activation.ts b/packages/web-platform-tests/src/compatibility/elements/summary/activation.ts
new file mode 100644
index 0000000..e07b2de
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/textarea/cloning.ts b/packages/web-platform-tests/src/compatibility/elements/textarea/cloning.ts
new file mode 100644
index 0000000..86dfb1c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/textarea/form-association.ts b/packages/web-platform-tests/src/compatibility/elements/textarea/form-association.ts
new file mode 100644
index 0000000..358bfd0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/textarea/labels.ts b/packages/web-platform-tests/src/compatibility/elements/textarea/labels.ts
new file mode 100644
index 0000000..69ec30c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/textarea/range-text.ts b/packages/web-platform-tests/src/compatibility/elements/textarea/range-text.ts
new file mode 100644
index 0000000..d106ec1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/textarea/selection.ts b/packages/web-platform-tests/src/compatibility/elements/textarea/selection.ts
new file mode 100644
index 0000000..3eb1d8c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/textarea/validation.ts b/packages/web-platform-tests/src/compatibility/elements/textarea/validation.ts
new file mode 100644
index 0000000..706d8cc
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/elements/textarea/value.ts b/packages/web-platform-tests/src/compatibility/elements/textarea/value.ts
new file mode 100644
index 0000000..994b68a
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/data/custom.ts b/packages/web-platform-tests/src/compatibility/events/data/custom.ts
new file mode 100644
index 0000000..a6ca4af
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/data/focus.ts b/packages/web-platform-tests/src/compatibility/events/data/focus.ts
new file mode 100644
index 0000000..ec28da9
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/data/input.ts b/packages/web-platform-tests/src/compatibility/events/data/input.ts
new file mode 100644
index 0000000..af264e1
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/data/keyboard.ts b/packages/web-platform-tests/src/compatibility/events/data/keyboard.ts
new file mode 100644
index 0000000..9bdb6e6
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/data/mouse.ts b/packages/web-platform-tests/src/compatibility/events/data/mouse.ts
new file mode 100644
index 0000000..648d126
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/bubbling.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/bubbling.ts
new file mode 100644
index 0000000..147ee0c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/cancellation.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/cancellation.ts
new file mode 100644
index 0000000..2937938
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/capture.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/capture.ts
new file mode 100644
index 0000000..67a71b6
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/path-snapshot.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/path-snapshot.ts
new file mode 100644
index 0000000..17a861a
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/reentrancy.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/reentrancy.ts
new file mode 100644
index 0000000..5b9349d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/reuse.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/reuse.ts
new file mode 100644
index 0000000..96ee830
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/stop-immediate-propagation.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/stop-immediate-propagation.ts
new file mode 100644
index 0000000..76d1e8f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/stop-propagation.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/stop-propagation.ts
new file mode 100644
index 0000000..bffe56e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/dispatch/target.ts b/packages/web-platform-tests/src/compatibility/events/dispatch/target.ts
new file mode 100644
index 0000000..742c8bc
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/listeners/abort-signal.ts b/packages/web-platform-tests/src/compatibility/events/listeners/abort-signal.ts
new file mode 100644
index 0000000..e170010
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/listeners/exception-reporting.ts b/packages/web-platform-tests/src/compatibility/events/listeners/exception-reporting.ts
new file mode 100644
index 0000000..4f70b39
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/listeners/objects.ts b/packages/web-platform-tests/src/compatibility/events/listeners/objects.ts
new file mode 100644
index 0000000..c354c24
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/listeners/once.ts b/packages/web-platform-tests/src/compatibility/events/listeners/once.ts
new file mode 100644
index 0000000..76148d9
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/listeners/passive.ts b/packages/web-platform-tests/src/compatibility/events/listeners/passive.ts
new file mode 100644
index 0000000..219d035
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/listeners/registration.ts b/packages/web-platform-tests/src/compatibility/events/listeners/registration.ts
new file mode 100644
index 0000000..69edbc3
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/events/listeners/snapshots.ts b/packages/web-platform-tests/src/compatibility/events/listeners/snapshots.ts
new file mode 100644
index 0000000..3fa47b0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/features.ts b/packages/web-platform-tests/src/compatibility/features.ts
new file mode 100644
index 0000000..dd42c4d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/fixture.ts b/packages/web-platform-tests/src/compatibility/fixture.ts
new file mode 100644
index 0000000..1270a85
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/fixture.ts
@@ -0,0 +1,102 @@
+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';
+
+/** Explicit source document requested by a compatibility case. */
+export interface DOMDocumentOptions {
+ contentType: 'text/html' | 'application/xhtml+xml';
+ content: string;
+ /** URL fragment without the leading #, used by selectors such as :target. */
+ fragment?: string;
+}
+
+export interface DOMFixtureOptions {
+ /** Construct a fresh document and honor any explicit source/MIME/fragment request. */
+ create(options?: DOMDocumentOptions): D | Promise;
+ 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 & { document?: DOMDocumentOptions },
+ run: (fixture: DOMFixture) => void | Promise,
+): Promise {
+ const subject = await options.create(options.document);
+ 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/web-platform-tests/src/compatibility/focus/eligibility.ts b/packages/web-platform-tests/src/compatibility/focus/eligibility.ts
new file mode 100644
index 0000000..ed8cd3f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/focus/inert.ts b/packages/web-platform-tests/src/compatibility/focus/inert.ts
new file mode 100644
index 0000000..3d49b5e
--- /dev/null
+++ b/packages/web-platform-tests/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 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 =
+ 'Outside ';
+ 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/web-platform-tests/src/compatibility/focus/reentrancy.ts b/packages/web-platform-tests/src/compatibility/focus/reentrancy.ts
new file mode 100644
index 0000000..94d715e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/focus/removal.ts b/packages/web-platform-tests/src/compatibility/focus/removal.ts
new file mode 100644
index 0000000..da00a9e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/focus/transitions.ts b/packages/web-platform-tests/src/compatibility/focus/transitions.ts
new file mode 100644
index 0000000..6297b3d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/globals.ts b/packages/web-platform-tests/src/compatibility/globals.ts
new file mode 100644
index 0000000..4eb6225
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input-driver.ts b/packages/web-platform-tests/src/compatibility/input-driver.ts
new file mode 100644
index 0000000..3b84f40
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/keyboard/activation.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/activation.ts
new file mode 100644
index 0000000..aac4e03
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/keyboard/delivery.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/delivery.ts
new file mode 100644
index 0000000..5f8549d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/keyboard/editing.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/editing.ts
new file mode 100644
index 0000000..7f2972a
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/keyboard/focus-styles.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/focus-styles.ts
new file mode 100644
index 0000000..2a2977c
--- /dev/null
+++ b/packages/web-platform-tests/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.
+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: 'First Second ',
+ 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:
+ 'First Last ',
+ 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/web-platform-tests/src/compatibility/input/keyboard/focus-visible.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/focus-visible.ts
new file mode 100644
index 0000000..bcfc279
--- /dev/null
+++ b/packages/web-platform-tests/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 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 ',
+ 'Target ',
+ ' ',
+ ]) {
+ context.test(
+ `Tab makes ${markup} focus-visible and clears matching after blur`,
+ async (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, {
+ markup: `Start ${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/web-platform-tests/src/compatibility/input/keyboard/implicit-submission.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/implicit-submission.ts
new file mode 100644
index 0000000..75f8140
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/keyboard/modifiers.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/modifiers.ts
new file mode 100644
index 0000000..a7acb48
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/keyboard/multiline-editing.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/multiline-editing.ts
new file mode 100644
index 0000000..f80e726
--- /dev/null
+++ b/packages/web-platform-tests/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.
+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/web-platform-tests/src/compatibility/input/keyboard/navigation.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/navigation.ts
new file mode 100644
index 0000000..189ac54
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/keyboard/number-stepping.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/number-stepping.ts
new file mode 100644
index 0000000..6debce7
--- /dev/null
+++ b/packages/web-platform-tests/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.
+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/web-platform-tests/src/compatibility/input/keyboard/shadow.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/shadow.ts
new file mode 100644
index 0000000..192985d
--- /dev/null
+++ b/packages/web-platform-tests/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.
+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/web-platform-tests/src/compatibility/input/keyboard/trusted-events.ts b/packages/web-platform-tests/src/compatibility/input/keyboard/trusted-events.ts
new file mode 100644
index 0000000..015e7a5
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/mouse/activation.ts b/packages/web-platform-tests/src/compatibility/input/mouse/activation.ts
new file mode 100644
index 0000000..6f73070
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/mouse/buttons.ts b/packages/web-platform-tests/src/compatibility/input/mouse/buttons.ts
new file mode 100644
index 0000000..aa4093d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/mouse/delivery.ts b/packages/web-platform-tests/src/compatibility/input/mouse/delivery.ts
new file mode 100644
index 0000000..e032cad
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/mouse/focus-visible.ts b/packages/web-platform-tests/src/compatibility/input/mouse/focus-visible.ts
new file mode 100644
index 0000000..aaa7fa0
--- /dev/null
+++ b/packages/web-platform-tests/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 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 [
+ 'Button ',
+ '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/web-platform-tests/src/compatibility/input/mouse/focus.ts b/packages/web-platform-tests/src/compatibility/input/mouse/focus.ts
new file mode 100644
index 0000000..cff189e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/mouse/hover.ts b/packages/web-platform-tests/src/compatibility/input/mouse/hover.ts
new file mode 100644
index 0000000..b21e28f
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/input/mouse/shadow.ts b/packages/web-platform-tests/src/compatibility/input/mouse/shadow.ts
new file mode 100644
index 0000000..8849a8c
--- /dev/null
+++ b/packages/web-platform-tests/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.
+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/web-platform-tests/src/compatibility/input/mouse/state-styles.ts b/packages/web-platform-tests/src/compatibility/input/mouse/state-styles.ts
new file mode 100644
index 0000000..e2a8105
--- /dev/null
+++ b/packages/web-platform-tests/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 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: 'Outside ',
+ 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: 'Press ',
+ 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:
+ 'Outside ',
+ 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: 'First Second ',
+ 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/web-platform-tests/src/compatibility/input/mouse/trusted-events.ts b/packages/web-platform-tests/src/compatibility/input/mouse/trusted-events.ts
new file mode 100644
index 0000000..4b3288e
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/detached.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/detached.ts
new file mode 100644
index 0000000..2259ffe
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/disconnect.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/disconnect.ts
new file mode 100644
index 0000000..63dbc93
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/filtering.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/filtering.ts
new file mode 100644
index 0000000..b21e2fa
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/microtask-delivery.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/microtask-delivery.ts
new file mode 100644
index 0000000..db46003
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/old-value.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/old-value.ts
new file mode 100644
index 0000000..7e6eb54
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/records/attributes.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/records/attributes.ts
new file mode 100644
index 0000000..59d2bf0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/records/character-data.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/records/character-data.ts
new file mode 100644
index 0000000..96a6b51
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/records/child-list.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/records/child-list.ts
new file mode 100644
index 0000000..5d0e42c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/reentrancy.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/reentrancy.ts
new file mode 100644
index 0000000..f2505c5
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/registration.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/registration.ts
new file mode 100644
index 0000000..acdcdd5
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/subtree.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/subtree.ts
new file mode 100644
index 0000000..b9af97a
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/mutation-observer/take-records.ts b/packages/web-platform-tests/src/compatibility/mutation-observer/take-records.ts
new file mode 100644
index 0000000..f942f14
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/adoption.ts b/packages/web-platform-tests/src/compatibility/node/adoption.ts
new file mode 100644
index 0000000..d71bf1c
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/append.ts b/packages/web-platform-tests/src/compatibility/node/append.ts
new file mode 100644
index 0000000..1bf8784
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/basic-append.ts b/packages/web-platform-tests/src/compatibility/node/basic-append.ts
new file mode 100644
index 0000000..8776dcb
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/cloning.ts b/packages/web-platform-tests/src/compatibility/node/cloning.ts
new file mode 100644
index 0000000..908524d
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/comparison.ts b/packages/web-platform-tests/src/compatibility/node/comparison.ts
new file mode 100644
index 0000000..15de413
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/connectivity.ts b/packages/web-platform-tests/src/compatibility/node/connectivity.ts
new file mode 100644
index 0000000..5f1b9da
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/fragments.ts b/packages/web-platform-tests/src/compatibility/node/fragments.ts
new file mode 100644
index 0000000..8e37cad
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/insert-before.ts b/packages/web-platform-tests/src/compatibility/node/insert-before.ts
new file mode 100644
index 0000000..20f98d3
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/live-collections.ts b/packages/web-platform-tests/src/compatibility/node/live-collections.ts
new file mode 100644
index 0000000..abf0586
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/normalization.ts b/packages/web-platform-tests/src/compatibility/node/normalization.ts
new file mode 100644
index 0000000..2e77cf6
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/relationships.ts b/packages/web-platform-tests/src/compatibility/node/relationships.ts
new file mode 100644
index 0000000..43b5b09
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/remove.ts b/packages/web-platform-tests/src/compatibility/node/remove.ts
new file mode 100644
index 0000000..f63dcb6
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/replace.ts b/packages/web-platform-tests/src/compatibility/node/replace.ts
new file mode 100644
index 0000000..d173a9b
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/text-content.ts b/packages/web-platform-tests/src/compatibility/node/text-content.ts
new file mode 100644
index 0000000..2c3d1c8
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/node/variadic-mutation.ts b/packages/web-platform-tests/src/compatibility/node/variadic-mutation.ts
new file mode 100644
index 0000000..57cb334
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/parsing/contextual.ts b/packages/web-platform-tests/src/compatibility/parsing/contextual.ts
new file mode 100644
index 0000000..be20354
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/parsing/fragments.ts b/packages/web-platform-tests/src/compatibility/parsing/fragments.ts
new file mode 100644
index 0000000..cf553e0
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/parsing/markup-mutation.ts b/packages/web-platform-tests/src/compatibility/parsing/markup-mutation.ts
new file mode 100644
index 0000000..eb73ded
--- /dev/null
+++ b/packages/web-platform-tests/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/web-platform-tests/src/compatibility/parsing/namespaces.ts b/packages/web-platform-tests/src/compatibility/parsing/namespaces.ts
new file mode 100644
index 0000000..421f206
--- /dev/null
+++ b/packages/web-platform-tests/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 = 'x ';
+ 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/web-platform-tests/src/compatibility/parsing/recovery.ts b/packages/web-platform-tests/src/compatibility/parsing/recovery.ts
new file mode 100644
index 0000000..6b225be
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/parsing/recovery.ts
@@ -0,0 +1,98 @@
+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, ../../../test/wpt/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: '12 3 ',
+ expected: '12 3 ',
+ },
+ {
+ 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
4 5 ',
+ },
+ {
+ 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: '3 ',
+ },
+ {
+ name: 'closes a button and its table cell when a new cell begins',
+ markup: '',
+ expected: '',
+ },
+ {
+ name: 'inserts colgroup and preserves attributes on implicitly grouped columns',
+ markup: ' ',
+ expected: '',
+ },
+ {
+ name: 'closes foreign content when a new cell starts at an HTML integration point',
+ markup: '',
+ expected: '',
+ },
+] as const;
+
+/** Registers malformed-markup tree construction cases across parsing entry points. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const { name, markup, expected } of recoveryCases) {
+ context.test(name, (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const DOMParser = fixture.globals('DOMParser') as typeof globalThis.DOMParser;
+ const fragment = document.createElement('div');
+ fragment.innerHTML = markup;
+ expect(fragment.innerHTML).toBe(expected);
+ const parsed = new DOMParser().parseFromString(markup, 'text/html');
+ expect(parsed.body.innerHTML).toBe(expected);
+ // Serialization must be stable after reparsing the repaired tree.
+ fragment.innerHTML = expected;
+ expect(fragment.innerHTML).toBe(expected);
+ });
+ }
+ context.test('keeps foster-parented nodes inside template content', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div');
+ root.innerHTML = 'outside ';
+ const template = root.firstElementChild as HTMLTemplateElement;
+ expect(template.innerHTML).toBe('AC');
+ expect(template.childNodes.length).toBe(0);
+ expect(template.content.firstChild!.nodeValue).toBe('AC');
+ expect(Array.from(root.children).map((node) => node.localName)).toEqual(['template', 'i']);
+ expect(root.textContent).toBe('outside');
+ });
+ context.test(
+ 'reconstructs formatting independently inside nested template content',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const template = document.createElement('template');
+ template.innerHTML = 'outer12 3 end';
+ const nested = template.content.querySelector('template')!;
+ expect(nested.innerHTML).toBe('12 3 ');
+ expect(template.content.firstElementChild!.textContent).toBe('outerend');
+ expect(nested.content.querySelector('b')!.ownerDocument).toBe(nested.content.ownerDocument);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/parsing/serialization.ts b/packages/web-platform-tests/src/compatibility/parsing/serialization.ts
new file mode 100644
index 0000000..d94c105
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/parsing/serialization.ts
@@ -0,0 +1,38 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('escapes markup characters and serializes void elements', ({ document }) => {
+ const container = document.createElement('div');
+ const span = document.createElement('span');
+ span.setAttribute('title', 'a"&b');
+ span.textContent = '<&>';
+ container.append(span, document.createElement('br'));
+ expect(container.innerHTML).toBe('<&> ');
+ });
+
+ context.test(
+ 'escapes text and quoted attribute values without serializing comments as text',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.setAttribute('title', 'a"&');
+ e.append(document.createTextNode('<>&'), document.createComment('note'));
+ expect(e.innerHTML).toBe('<>&');
+ expect(e.outerHTML).toContain('title="a"&"');
+ },
+ );
+
+ context.test(
+ 'serializes void elements without end tags and preserves raw script text',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.innerHTML = ' ';
+ expect(e.innerHTML).toBe(' ');
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/registry.ts b/packages/web-platform-tests/src/compatibility/registry.ts
new file mode 100644
index 0000000..f6e1cc0
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/registry.ts
@@ -0,0 +1,450 @@
+import type { FeatureModule } from './context.ts';
+import type { DOMFeaturePath } from './features.ts';
+import * as expansion0 from './document/basic-creation.ts';
+import * as feature12 from './document/creation.ts';
+import * as feature14 from './document/lookup.ts';
+import * as feature13 from './document/import-node.ts';
+import * as expansion1 from './document/xml.ts';
+import * as extra1 from './document/parsing.ts';
+import * as interactionDocumentXmlSerialization from './document/xml-serialization.ts';
+import * as expansion2 from './node/basic-append.ts';
+import * as feature126 from './node/relationships.ts';
+import * as feature122 from './node/connectivity.ts';
+import * as feature125 from './node/live-collections.ts';
+import * as feature129 from './node/text-content.ts';
+import * as feature120 from './node/append.ts';
+import * as feature124 from './node/insert-before.ts';
+import * as feature127 from './node/remove.ts';
+import * as feature128 from './node/replace.ts';
+import * as feature123 from './node/fragments.ts';
+import * as feature130 from './node/variadic-mutation.ts';
+import * as feature121 from './node/cloning.ts';
+import * as expansion3 from './node/comparison.ts';
+import * as expansion4 from './node/normalization.ts';
+import * as feature119 from './node/adoption.ts';
+import * as expansion5 from './character-data/string-storage.ts';
+import * as feature0 from './character-data/data.ts';
+import * as feature1 from './character-data/editing.ts';
+import * as feature2 from './character-data/split-text.ts';
+import * as expansion6 from './element/child-elements.ts';
+import * as feature16 from './element/attributes.ts';
+import * as feature15 from './element/attribute-nodes.ts';
+import * as feature18 from './element/namespaces.ts';
+import * as feature17 from './element/class-list.ts';
+import * as expansion8 from './element/dataset.ts';
+import * as expansion9 from './element/reflection.ts';
+import * as expansion7 from './element/selectors.ts';
+import * as expansion12 from './traversal/node-iterator.ts';
+import * as feature164 from './traversal/tree-walker.ts';
+import * as feature131 from './parsing/fragments.ts';
+import * as feature132 from './parsing/namespaces.ts';
+import * as feature133 from './parsing/serialization.ts';
+import * as expansion10 from './parsing/contextual.ts';
+import * as expansion11 from './parsing/markup-mutation.ts';
+import * as interactionParsingRecovery from './parsing/recovery.ts';
+import * as feature162 from './templates/content.ts';
+import * as feature161 from './templates/cloning.ts';
+import * as feature163 from './templates/parsing.ts';
+import * as feature82 from './events/data/custom.ts';
+import * as feature83 from './events/data/focus.ts';
+import * as feature85 from './events/data/keyboard.ts';
+import * as feature86 from './events/data/mouse.ts';
+import * as feature84 from './events/data/input.ts';
+import * as feature95 from './events/dispatch/target.ts';
+import * as feature89 from './events/dispatch/capture.ts';
+import * as feature87 from './events/dispatch/bubbling.ts';
+import * as feature88 from './events/dispatch/cancellation.ts';
+import * as feature94 from './events/dispatch/stop-propagation.ts';
+import * as feature93 from './events/dispatch/stop-immediate-propagation.ts';
+import * as feature90 from './events/dispatch/path-snapshot.ts';
+import * as feature92 from './events/dispatch/reuse.ts';
+import * as feature91 from './events/dispatch/reentrancy.ts';
+import * as feature101 from './events/listeners/registration.ts';
+import * as feature98 from './events/listeners/objects.ts';
+import * as feature99 from './events/listeners/once.ts';
+import * as feature100 from './events/listeners/passive.ts';
+import * as feature96 from './events/listeners/abort-signal.ts';
+import * as feature102 from './events/listeners/snapshots.ts';
+import * as feature97 from './events/listeners/exception-reporting.ts';
+import * as feature116 from './mutation-observer/registration.ts';
+import * as feature114 from './mutation-observer/records/child-list.ts';
+import * as feature112 from './mutation-observer/records/attributes.ts';
+import * as feature113 from './mutation-observer/records/character-data.ts';
+import * as feature109 from './mutation-observer/filtering.ts';
+import * as feature111 from './mutation-observer/old-value.ts';
+import * as feature117 from './mutation-observer/subtree.ts';
+import * as feature118 from './mutation-observer/take-records.ts';
+import * as feature108 from './mutation-observer/disconnect.ts';
+import * as feature110 from './mutation-observer/microtask-delivery.ts';
+import * as feature107 from './mutation-observer/detached.ts';
+import * as feature115 from './mutation-observer/reentrancy.ts';
+import * as feature103 from './focus/eligibility.ts';
+import * as feature106 from './focus/transitions.ts';
+import * as feature105 from './focus/removal.ts';
+import * as interactionFocusInert from './focus/inert.ts';
+import * as feature104 from './focus/reentrancy.ts';
+import * as feature138 from './selection/range/boundaries.ts';
+import * as feature140 from './selection/range/comparison.ts';
+import * as feature144 from './selection/range/stringification.ts';
+import * as feature139 from './selection/range/clone-contents.ts';
+import * as feature141 from './selection/range/delete-contents.ts';
+import * as feature142 from './selection/range/extract-contents.ts';
+import * as feature143 from './selection/range/live-boundaries.ts';
+import * as extra0 from './selection/range/mutation.ts';
+import * as feature137 from './selection/document/range-management.ts';
+import * as feature135 from './selection/document/direction.ts';
+import * as feature134 from './selection/document/connectivity.ts';
+import * as feature136 from './selection/document/live-updates.ts';
+import * as interactionSelectionDocumentMutation from './selection/document/mutation.ts';
+import * as interactionSelectionDocumentChangeEvents from './selection/document/change-events.ts';
+import * as feature66 from './elements/progress.ts';
+import * as feature58 from './elements/meter.ts';
+import * as feature60 from './elements/option/attributes.ts';
+import * as feature62 from './elements/option/text.ts';
+import * as feature61 from './elements/option/selectedness.ts';
+import * as feature59 from './elements/optgroup.ts';
+import * as feature22 from './elements/datalist.ts';
+import * as feature54 from './elements/input/value.ts';
+import * as feature50 from './elements/input/sanitization.ts';
+import * as feature43 from './elements/input/color-value.ts';
+import * as feature47 from './elements/input/numeric-value.ts';
+import * as feature44 from './elements/input/date-value.ts';
+import * as feature52 from './elements/input/stepping.ts';
+import * as feature41 from './elements/input/checkedness.ts';
+import * as feature48 from './elements/input/radio-groups.ts';
+import * as feature51 from './elements/input/selection.ts';
+import * as feature49 from './elements/input/range-text.ts';
+import * as feature53 from './elements/input/validation.ts';
+import * as feature45 from './elements/input/form-association.ts';
+import * as feature46 from './elements/input/labels.ts';
+import * as feature40 from './elements/input/activation.ts';
+import * as feature42 from './elements/input/cloning.ts';
+import * as feature81 from './elements/textarea/value.ts';
+import * as feature79 from './elements/textarea/selection.ts';
+import * as feature78 from './elements/textarea/range-text.ts';
+import * as feature80 from './elements/textarea/validation.ts';
+import * as feature76 from './elements/textarea/form-association.ts';
+import * as feature77 from './elements/textarea/labels.ts';
+import * as feature75 from './elements/textarea/cloning.ts';
+import * as feature70 from './elements/select/options.ts';
+import * as feature68 from './elements/select/indexed-options.ts';
+import * as feature71 from './elements/select/selection.ts';
+import * as feature69 from './elements/select/mutation.ts';
+import * as feature72 from './elements/select/validation.ts';
+import * as feature67 from './elements/select/cloning.ts';
+import * as feature73 from './elements/selectedcontent.ts';
+import * as feature34 from './elements/form/controls.ts';
+import * as feature36 from './elements/form/named-controls.ts';
+import * as feature37 from './elements/form/reset.ts';
+import * as feature39 from './elements/form/validation.ts';
+import * as feature38 from './elements/form/submission.ts';
+import * as feature35 from './elements/form/form-data.ts';
+import * as interactionElementsFormDataEvents from './elements/form/data-events.ts';
+import * as feature20 from './elements/button/attributes.ts';
+import * as feature21 from './elements/button/form-association.ts';
+import * as feature19 from './elements/button/activation.ts';
+import * as feature56 from './elements/label/association.ts';
+import * as feature55 from './elements/label/activation.ts';
+import * as feature32 from './elements/fieldset/controls.ts';
+import * as feature33 from './elements/fieldset/disabled.ts';
+import * as feature57 from './elements/legend.ts';
+import * as feature63 from './elements/output/html-for.ts';
+import * as feature65 from './elements/output/value.ts';
+import * as feature64 from './elements/output/reset.ts';
+import * as feature24 from './elements/details/open.ts';
+import * as feature25 from './elements/details/toggle-events.ts';
+import * as feature23 from './elements/details/exclusive-groups.ts';
+import * as feature74 from './elements/summary/activation.ts';
+import * as feature31 from './elements/dialog/show.ts';
+import * as feature26 from './elements/dialog/close.ts';
+import * as feature29 from './elements/dialog/modal.ts';
+import * as feature30 from './elements/dialog/request-close.ts';
+import * as feature27 from './elements/dialog/focus.ts';
+import * as feature28 from './elements/dialog/form-submission.ts';
+import * as feature10 from './custom-elements/registration.ts';
+import * as feature5 from './custom-elements/construction.ts';
+import * as feature11 from './custom-elements/upgrading.ts';
+import * as feature7 from './custom-elements/observed-attributes.ts';
+import * as feature4 from './custom-elements/connection.ts';
+import * as feature3 from './custom-elements/adoption.ts';
+import * as feature8 from './custom-elements/reaction-ordering.ts';
+import * as feature9 from './custom-elements/reentrancy.ts';
+import * as feature6 from './custom-elements/exception-reporting.ts';
+import * as interactionCustomElementsFormAssociation from './custom-elements/form-association.ts';
+import * as feature152 from './shadow/roots/attachment.ts';
+import * as feature155 from './shadow/roots/host-eligibility.ts';
+import * as feature154 from './shadow/roots/connectivity.ts';
+import * as feature153 from './shadow/roots/cloning.ts';
+import * as feature151 from './shadow/roots/adoption.ts';
+import * as feature159 from './shadow/slots/named.ts';
+import * as feature157 from './shadow/slots/fallback.ts';
+import * as feature158 from './shadow/slots/manual.ts';
+import * as feature160 from './shadow/slots/slotchange.ts';
+import * as interactionShadowSlotsObserverOrdering from './shadow/slots/observer-ordering.ts';
+import * as feature148 from './shadow/events/retargeting.ts';
+import * as feature146 from './shadow/events/composed-path.ts';
+import * as feature147 from './shadow/events/related-target.ts';
+import * as feature150 from './shadow/focus/retargeting.ts';
+import * as feature149 from './shadow/focus/delegation.ts';
+import * as feature145 from './shadow/declarative.ts';
+import * as feature156 from './shadow/serialization.ts';
+import * as expansion13 from './style/inline.ts';
+import * as expansion15 from './style/parsing.ts';
+import * as expansion14 from './style/attribute-synchronization.ts';
+import * as expansion16 from './style/custom-properties.ts';
+import * as expansion17 from './style/stylesheets.ts';
+import * as expansion18 from './style/computed.ts';
+import * as extra2 from './style/cascade.ts';
+import * as interactionStyleLayers from './style/layers.ts';
+import * as interactionStyleConstructedStylesheets from './style/constructed-stylesheets.ts';
+import * as interactionStyleFocusState from './style/focus-state.ts';
+import * as interactionStyleControlSelectors from './style/control-selectors.ts';
+import * as interactionStyleRelationalSelectors from './style/relational-selectors.ts';
+import * as interactionStyleMutationObservation from './style/mutation-observation.ts';
+import * as inputKeyboardDelivery from './input/keyboard/delivery.ts';
+import * as inputKeyboardModifiers from './input/keyboard/modifiers.ts';
+import * as inputKeyboardNavigation from './input/keyboard/navigation.ts';
+import * as inputKeyboardActivation from './input/keyboard/activation.ts';
+import * as inputKeyboardEditing from './input/keyboard/editing.ts';
+import * as interactionInputKeyboardMultilineEditing from './input/keyboard/multiline-editing.ts';
+import * as interactionInputKeyboardNumberStepping from './input/keyboard/number-stepping.ts';
+import * as interactionInputKeyboardFocusStyles from './input/keyboard/focus-styles.ts';
+import * as interactionInputKeyboardFocusVisible from './input/keyboard/focus-visible.ts';
+import * as interactionInputKeyboardShadow from './input/keyboard/shadow.ts';
+import * as inputKeyboardImplicitSubmission from './input/keyboard/implicit-submission.ts';
+import * as inputKeyboardTrustedEvents from './input/keyboard/trusted-events.ts';
+import * as inputMouseDelivery from './input/mouse/delivery.ts';
+import * as inputMouseButtons from './input/mouse/buttons.ts';
+import * as inputMouseHover from './input/mouse/hover.ts';
+import * as inputMouseFocus from './input/mouse/focus.ts';
+import * as inputMouseActivation from './input/mouse/activation.ts';
+import * as interactionInputMouseStateStyles from './input/mouse/state-styles.ts';
+import * as interactionInputMouseFocusVisible from './input/mouse/focus-visible.ts';
+import * as inputMouseShadow from './input/mouse/shadow.ts';
+import * as inputMouseTrustedEvents from './input/mouse/trusted-events.ts';
+
+/** Exhaustiveness is checked by TypeScript and compatibility-harness.ts. */
+export const registry = {
+ 'document.basicCreation': expansion0,
+ 'document.creation': feature12,
+ 'document.lookup': feature14,
+ 'document.importNode': feature13,
+ 'document.xml': expansion1,
+ 'document.parsing': extra1,
+ 'document.xmlSerialization': interactionDocumentXmlSerialization,
+ 'node.basicAppend': expansion2,
+ 'node.relationships': feature126,
+ 'node.connectivity': feature122,
+ 'node.liveCollections': feature125,
+ 'node.textContent': feature129,
+ 'node.append': feature120,
+ 'node.insertBefore': feature124,
+ 'node.remove': feature127,
+ 'node.replace': feature128,
+ 'node.fragments': feature123,
+ 'node.variadicMutation': feature130,
+ 'node.cloning': feature121,
+ 'node.comparison': expansion3,
+ 'node.normalization': expansion4,
+ 'node.adoption': feature119,
+ 'characterData.stringStorage': expansion5,
+ 'characterData.data': feature0,
+ 'characterData.editing': feature1,
+ 'characterData.splitText': feature2,
+ 'element.childElements': expansion6,
+ 'element.attributes': feature16,
+ 'element.attributeNodes': feature15,
+ 'element.namespaces': feature18,
+ 'element.classList': feature17,
+ 'element.dataset': expansion8,
+ 'element.reflection': expansion9,
+ 'element.selectors': expansion7,
+ 'traversal.nodeIterator': expansion12,
+ 'traversal.treeWalker': feature164,
+ 'parsing.fragments': feature131,
+ 'parsing.namespaces': feature132,
+ 'parsing.serialization': feature133,
+ 'parsing.contextual': expansion10,
+ 'parsing.markupMutation': expansion11,
+ 'parsing.recovery': interactionParsingRecovery,
+ 'templates.content': feature162,
+ 'templates.cloning': feature161,
+ 'templates.parsing': feature163,
+ 'events.data.custom': feature82,
+ 'events.data.focus': feature83,
+ 'events.data.keyboard': feature85,
+ 'events.data.mouse': feature86,
+ 'events.data.input': feature84,
+ 'events.dispatch.target': feature95,
+ 'events.dispatch.capture': feature89,
+ 'events.dispatch.bubbling': feature87,
+ 'events.dispatch.cancellation': feature88,
+ 'events.dispatch.stopPropagation': feature94,
+ 'events.dispatch.stopImmediatePropagation': feature93,
+ 'events.dispatch.pathSnapshot': feature90,
+ 'events.dispatch.reuse': feature92,
+ 'events.dispatch.reentrancy': feature91,
+ 'events.listeners.registration': feature101,
+ 'events.listeners.objects': feature98,
+ 'events.listeners.once': feature99,
+ 'events.listeners.passive': feature100,
+ 'events.listeners.abortSignal': feature96,
+ 'events.listeners.snapshots': feature102,
+ 'events.listeners.exceptionReporting': feature97,
+ 'mutationObserver.registration': feature116,
+ 'mutationObserver.records.childList': feature114,
+ 'mutationObserver.records.attributes': feature112,
+ 'mutationObserver.records.characterData': feature113,
+ 'mutationObserver.filtering': feature109,
+ 'mutationObserver.oldValue': feature111,
+ 'mutationObserver.subtree': feature117,
+ 'mutationObserver.takeRecords': feature118,
+ 'mutationObserver.disconnect': feature108,
+ 'mutationObserver.microtaskDelivery': feature110,
+ 'mutationObserver.detached': feature107,
+ 'mutationObserver.reentrancy': feature115,
+ 'focus.eligibility': feature103,
+ 'focus.transitions': feature106,
+ 'focus.removal': feature105,
+ 'focus.inert': interactionFocusInert,
+ 'focus.reentrancy': feature104,
+ 'selection.range.boundaries': feature138,
+ 'selection.range.comparison': feature140,
+ 'selection.range.stringification': feature144,
+ 'selection.range.cloneContents': feature139,
+ 'selection.range.deleteContents': feature141,
+ 'selection.range.extractContents': feature142,
+ 'selection.range.liveBoundaries': feature143,
+ 'selection.range.mutation': extra0,
+ 'selection.document.rangeManagement': feature137,
+ 'selection.document.direction': feature135,
+ 'selection.document.connectivity': feature134,
+ 'selection.document.liveUpdates': feature136,
+ 'selection.document.mutation': interactionSelectionDocumentMutation,
+ 'selection.document.changeEvents': interactionSelectionDocumentChangeEvents,
+ 'elements.progress': feature66,
+ 'elements.meter': feature58,
+ 'elements.option.attributes': feature60,
+ 'elements.option.text': feature62,
+ 'elements.option.selectedness': feature61,
+ 'elements.optgroup': feature59,
+ 'elements.datalist': feature22,
+ 'elements.input.value': feature54,
+ 'elements.input.sanitization': feature50,
+ 'elements.input.colorValue': feature43,
+ 'elements.input.numericValue': feature47,
+ 'elements.input.dateValue': feature44,
+ 'elements.input.stepping': feature52,
+ 'elements.input.checkedness': feature41,
+ 'elements.input.radioGroups': feature48,
+ 'elements.input.selection': feature51,
+ 'elements.input.rangeText': feature49,
+ 'elements.input.validation': feature53,
+ 'elements.input.formAssociation': feature45,
+ 'elements.input.labels': feature46,
+ 'elements.input.activation': feature40,
+ 'elements.input.cloning': feature42,
+ 'elements.textarea.value': feature81,
+ 'elements.textarea.selection': feature79,
+ 'elements.textarea.rangeText': feature78,
+ 'elements.textarea.validation': feature80,
+ 'elements.textarea.formAssociation': feature76,
+ 'elements.textarea.labels': feature77,
+ 'elements.textarea.cloning': feature75,
+ 'elements.select.options': feature70,
+ 'elements.select.indexedOptions': feature68,
+ 'elements.select.selection': feature71,
+ 'elements.select.mutation': feature69,
+ 'elements.select.validation': feature72,
+ 'elements.select.cloning': feature67,
+ 'elements.selectedcontent': feature73,
+ 'elements.form.controls': feature34,
+ 'elements.form.namedControls': feature36,
+ 'elements.form.reset': feature37,
+ 'elements.form.validation': feature39,
+ 'elements.form.submission': feature38,
+ 'elements.form.formData': feature35,
+ 'elements.form.dataEvents': interactionElementsFormDataEvents,
+ 'elements.button.attributes': feature20,
+ 'elements.button.formAssociation': feature21,
+ 'elements.button.activation': feature19,
+ 'elements.label.association': feature56,
+ 'elements.label.activation': feature55,
+ 'elements.fieldset.controls': feature32,
+ 'elements.fieldset.disabled': feature33,
+ 'elements.legend': feature57,
+ 'elements.output.htmlFor': feature63,
+ 'elements.output.value': feature65,
+ 'elements.output.reset': feature64,
+ 'elements.details.open': feature24,
+ 'elements.details.toggleEvents': feature25,
+ 'elements.details.exclusiveGroups': feature23,
+ 'elements.summary.activation': feature74,
+ 'elements.dialog.show': feature31,
+ 'elements.dialog.close': feature26,
+ 'elements.dialog.modal': feature29,
+ 'elements.dialog.requestClose': feature30,
+ 'elements.dialog.focus': feature27,
+ 'elements.dialog.formSubmission': feature28,
+ 'customElements.registration': feature10,
+ 'customElements.construction': feature5,
+ 'customElements.upgrading': feature11,
+ 'customElements.observedAttributes': feature7,
+ 'customElements.connection': feature4,
+ 'customElements.adoption': feature3,
+ 'customElements.reactionOrdering': feature8,
+ 'customElements.reentrancy': feature9,
+ 'customElements.exceptionReporting': feature6,
+ 'customElements.formAssociation': interactionCustomElementsFormAssociation,
+ 'shadow.roots.attachment': feature152,
+ 'shadow.roots.hostEligibility': feature155,
+ 'shadow.roots.connectivity': feature154,
+ 'shadow.roots.cloning': feature153,
+ 'shadow.roots.adoption': feature151,
+ 'shadow.slots.named': feature159,
+ 'shadow.slots.fallback': feature157,
+ 'shadow.slots.manual': feature158,
+ 'shadow.slots.slotchange': feature160,
+ 'shadow.slots.observerOrdering': interactionShadowSlotsObserverOrdering,
+ 'shadow.events.retargeting': feature148,
+ 'shadow.events.composedPath': feature146,
+ 'shadow.events.relatedTarget': feature147,
+ 'shadow.focus.retargeting': feature150,
+ 'shadow.focus.delegation': feature149,
+ 'shadow.declarative': feature145,
+ 'shadow.serialization': feature156,
+ 'style.inline': expansion13,
+ 'style.parsing': expansion15,
+ 'style.attributeSynchronization': expansion14,
+ 'style.customProperties': expansion16,
+ 'style.stylesheets': expansion17,
+ 'style.computed': expansion18,
+ 'style.cascade': extra2,
+ 'style.layers': interactionStyleLayers,
+ 'style.constructedStylesheets': interactionStyleConstructedStylesheets,
+ 'style.focusState': interactionStyleFocusState,
+ 'style.controlSelectors': interactionStyleControlSelectors,
+ 'style.relationalSelectors': interactionStyleRelationalSelectors,
+ 'style.mutationObservation': interactionStyleMutationObservation,
+ 'input.keyboard.delivery': inputKeyboardDelivery,
+ 'input.keyboard.modifiers': inputKeyboardModifiers,
+ 'input.keyboard.navigation': inputKeyboardNavigation,
+ 'input.keyboard.activation': inputKeyboardActivation,
+ 'input.keyboard.editing': inputKeyboardEditing,
+ 'input.keyboard.multilineEditing': interactionInputKeyboardMultilineEditing,
+ 'input.keyboard.numberStepping': interactionInputKeyboardNumberStepping,
+ 'input.keyboard.focusStyles': interactionInputKeyboardFocusStyles,
+ 'input.keyboard.focusVisible': interactionInputKeyboardFocusVisible,
+ 'input.keyboard.shadow': interactionInputKeyboardShadow,
+ 'input.keyboard.implicitSubmission': inputKeyboardImplicitSubmission,
+ 'input.keyboard.trustedEvents': inputKeyboardTrustedEvents,
+ 'input.mouse.delivery': inputMouseDelivery,
+ 'input.mouse.buttons': inputMouseButtons,
+ 'input.mouse.hover': inputMouseHover,
+ 'input.mouse.focus': inputMouseFocus,
+ 'input.mouse.activation': inputMouseActivation,
+ 'input.mouse.stateStyles': interactionInputMouseStateStyles,
+ 'input.mouse.focusVisible': interactionInputMouseFocusVisible,
+ 'input.mouse.shadow': inputMouseShadow,
+ 'input.mouse.trustedEvents': inputMouseTrustedEvents,
+} satisfies Record;
diff --git a/packages/web-platform-tests/src/compatibility/selection/document/change-events.ts b/packages/web-platform-tests/src/compatibility/selection/document/change-events.ts
new file mode 100644
index 0000000..a012e6d
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/document/change-events.ts
@@ -0,0 +1,124 @@
+import type { CompatibilityContext, DOMFixture } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec = 'https://w3c.github.io/selection-api/#selectionchange-event';
+export const requires = ['selection.document.rangeManagement', 'events.dispatch.target'] as const;
+// Adapted from WPT selection/onselectionchange-on-document.html.
+// Copyright © web-platform-tests contributors. BSD-3-Clause.
+// See ../../../../test/wpt/LICENSE and docs/wpt-ranges-parsing.md.
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/selection/onselectionchange-on-document.html';
+
+function task(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+async function setup(
+ fixture: DOMFixture,
+): Promise<{ document: Document; root: HTMLDivElement; selection: Selection }> {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div');
+ root.append('abc', document.createElement('b'), 'def');
+ fixture.connect(root as unknown as DOMElementContract);
+ const selection = document.getSelection()!;
+ selection.removeAllRanges();
+ fixture.cleanup(() => selection.removeAllRanges());
+ // Drain any event from resetting the fixture before installing the test listener.
+ await task();
+ return { document, root, selection };
+}
+
+function listen(fixture: DOMFixture, callback: (event: Event) => void): void {
+ const document = fixture.document as unknown as Document;
+ document.addEventListener('selectionchange', callback);
+ fixture.cleanup(() => document.removeEventListener('selectionchange', callback));
+}
+
+/** Registers asynchronous selection notification and coalescing cases. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'delivers selectionchange asynchronously at the document without bubbling or cancellation',
+ async (fixture) => {
+ const { document, root, selection } = await setup(fixture);
+ const events: Event[] = [];
+ listen(fixture, (event) => events.push(event));
+ selection.collapse(root, 1);
+ expect(events.length).toBe(0);
+ await task();
+ expect(events.length).toBe(1);
+ expect([events[0]!.target, events[0]!.bubbles, events[0]!.cancelable]).toEqual([
+ document,
+ false,
+ false,
+ ]);
+ },
+ );
+ context.test(
+ 'coalesces several synchronous selection changes into one notification',
+ async (fixture) => {
+ const { root, selection } = await setup(fixture);
+ const offsets: number[] = [];
+ listen(fixture, () => offsets.push(selection.focusOffset));
+ selection.collapse(root, 0);
+ selection.collapse(root, 1);
+ selection.collapse(root, 2);
+ expect(offsets).toEqual([]);
+ await task();
+ expect(offsets).toEqual([2]);
+ },
+ );
+ context.test(
+ 'queues another notification when selection changes after delivery',
+ async (fixture) => {
+ const { root, selection } = await setup(fixture);
+ const offsets: number[] = [];
+ listen(fixture, () => offsets.push(selection.focusOffset));
+ selection.collapse(root, 1);
+ await task();
+ expect(offsets).toEqual([1]);
+ selection.collapse(root, 0);
+ selection.collapse(root, 2);
+ expect(offsets).toEqual([1]);
+ await task();
+ expect(offsets).toEqual([1, 2]);
+ },
+ );
+ context.test(
+ 'listener mutations schedule a later event and never recursively dispatch',
+ async (fixture) => {
+ const { root, selection } = await setup(fixture);
+ const offsets: number[] = [];
+ let inside = false,
+ recursive = false;
+ listen(fixture, () => {
+ if (inside) recursive = true;
+ inside = true;
+ offsets.push(selection.focusOffset);
+ if (offsets.length === 1) {
+ selection.collapse(root, 2);
+ selection.collapse(root, 0);
+ }
+ inside = false;
+ });
+ selection.collapse(root, 1);
+ await task();
+ await task();
+ expect(offsets).toEqual([1, 0]);
+ expect(recursive).toBe(false);
+ },
+ );
+ context.test('mutating a retained range schedules a selection notification', async (fixture) => {
+ const { root, selection } = await setup(fixture);
+ selection.collapse(root.firstChild!, 0);
+ await task();
+ const range = selection.getRangeAt(0);
+ const text: string[] = [];
+ listen(fixture, () => text.push(selection.toString()));
+ range.setEnd(root.firstChild!, 2);
+ expect(text).toEqual([]);
+ await task();
+ expect(text).toEqual(['ab']);
+ expect(selection.getRangeAt(0)).toBe(range);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/document/connectivity.ts b/packages/web-platform-tests/src/compatibility/selection/document/connectivity.ts
new file mode 100644
index 0000000..fb11e0f
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/document/connectivity.ts
@@ -0,0 +1,39 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://w3c.github.io/selection-api/#dom-selection-addrange';
+const requiredGlobals = ['Document'] as const;
+export { requiredGlobals as globals };
+export const requires = ['selection.document.rangeManagement'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'ignores ranges rooted outside the selection document',
+ ({ document, cleanup, globals }) => {
+ const selection = document.getSelection();
+ if (!selection) return expect.fail('Document selection expected');
+ selection.removeAllRanges();
+ cleanup(() => selection.removeAllRanges());
+ const detached = document.createTextNode('detached');
+ const range = document.createRange();
+ range.selectNodeContents(detached);
+ selection.addRange(range);
+ expect(selection.rangeCount).toBe(0);
+ const Document = globals('Document');
+ const other = new Document();
+ selection.addRange(other.createRange());
+ expect(selection.rangeCount).toBe(0);
+ },
+ );
+
+ context.test('ignores adding a range rooted in a detached tree', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.getSelection()!,
+ t = document.createTextNode('abc'),
+ r = document.createRange();
+ s.removeAllRanges();
+ r.setStart(t, 0);
+ r.setEnd(t, 2);
+ s.addRange(r);
+ expect(s.rangeCount).toBe(0);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/document/direction.ts b/packages/web-platform-tests/src/compatibility/selection/document/direction.ts
new file mode 100644
index 0000000..abcb970
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/document/direction.ts
@@ -0,0 +1,51 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://w3c.github.io/selection-api/#dom-selection-setbaseandextent';
+export const requires = ['selection.document.rangeManagement'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'distinguishes backward anchor and focus from ordered range endpoints',
+ ({ document, connect, cleanup }) => {
+ const root = document.createElement('div');
+ const text = document.createTextNode('abcdef');
+ root.append(text);
+ connect(root);
+ const selection = document.getSelection();
+ if (!selection) return expect.fail('Document selection expected');
+ cleanup(() => selection.removeAllRanges());
+ selection.setBaseAndExtent(text, 5, text, 1);
+ expect(selection.direction).toBe('backward');
+ expect(selection.anchorOffset).toBe(5);
+ expect(selection.focusOffset).toBe(1);
+ const range = selection.getRangeAt(0);
+ expect(range.startOffset).toBe(1);
+ selection.extend(text, 6);
+ expect(selection.direction).toBe('forward');
+ expect(selection.toString()).toBe('f');
+ },
+ );
+
+ context.test(
+ 'setBaseAndExtent preserves backward anchor and focus while ordering the range',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ t = document.createTextNode('abcd');
+ p.append(t);
+ fixture.connect(p as unknown as DOMElementContract);
+ const s = document.getSelection()!;
+ s.setBaseAndExtent(t, 3, t, 1);
+ expect(s.anchorNode).toBe(t);
+ expect(s.anchorOffset).toBe(3);
+ expect(s.focusOffset).toBe(1);
+ expect(s.getRangeAt(0).startOffset).toBe(1);
+ expect(s.getRangeAt(0).endOffset).toBe(3);
+ expect(s.toString()).toBe('bc');
+ s.collapseToStart();
+ expect(s.anchorOffset).toBe(1);
+ expect(s.isCollapsed).toBe(true);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/document/live-updates.ts b/packages/web-platform-tests/src/compatibility/selection/document/live-updates.ts
new file mode 100644
index 0000000..588799d
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/document/live-updates.ts
@@ -0,0 +1,47 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://w3c.github.io/selection-api/#responding-to-dom-mutations';
+export const requires = [
+ 'selection.document.rangeManagement',
+ 'selection.range.liveBoundaries',
+] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'reflects character-data mutations through its retained live range',
+ ({ document, connect, cleanup }) => {
+ const root = document.createElement('div');
+ const text = document.createTextNode('abcdef');
+ root.append(text);
+ connect(root);
+ const selection = document.getSelection();
+ if (!selection) return expect.fail('Document selection expected');
+ selection.removeAllRanges();
+ cleanup(() => selection.removeAllRanges());
+ const range = document.createRange();
+ range.setStart(text, 2);
+ range.setEnd(text, 5);
+ selection.addRange(range);
+ text.deleteData(0, 1);
+ expect(selection.getRangeAt(0)).toBe(range);
+ expect(selection.anchorOffset).toBe(1);
+ expect(selection.focusOffset).toBe(4);
+ expect(selection.toString()).toBe('cde');
+ },
+ );
+
+ context.test('updates selection offsets when selected text is edited', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ t = document.createTextNode('abcd');
+ p.append(t);
+ fixture.connect(p as unknown as DOMElementContract);
+ const s = document.getSelection()!;
+ s.setBaseAndExtent(t, 1, t, 3);
+ t.insertData(0, 'XX');
+ expect(s.anchorOffset).toBe(3);
+ expect(s.focusOffset).toBe(5);
+ expect(s.toString()).toBe('bc');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/document/mutation.ts b/packages/web-platform-tests/src/compatibility/selection/document/mutation.ts
new file mode 100644
index 0000000..6934f4a
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/document/mutation.ts
@@ -0,0 +1,167 @@
+import type { CompatibilityContext, DOMFixture } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec = 'https://w3c.github.io/selection-api/#selection-interface';
+export const requires = [
+ 'selection.document.rangeManagement',
+ 'selection.range.deleteContents',
+ 'document.xml',
+] as const;
+// Adapted from WPT selection/{selectAllChildren,removeRange,deleteFromDocument}.html.
+// Copyright © web-platform-tests contributors. deleteFromDocument author: Aryeh Gregor.
+// BSD-3-Clause; see ../../../../test/wpt/LICENSE and docs/wpt-ranges-parsing.md.
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/selection/removeRange.html';
+
+function setup(fixture: DOMFixture): {
+ document: Document;
+ root: HTMLDivElement;
+ selection: Selection;
+} {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div');
+ root.append(document.createTextNode('abc'), document.createElement('b'), 'def');
+ root.children[0]!.textContent = 'middle';
+ fixture.connect(root as unknown as DOMElementContract);
+ const selection = document.getSelection()!;
+ selection.removeAllRanges();
+ fixture.cleanup(() => selection.removeAllRanges());
+ return { document, root, selection };
+}
+
+/** Registers selection mutation, range identity, and failure cases. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'selectAllChildren replaces the range and selects child offsets forwards',
+ (fixture) => {
+ const { document, root, selection } = setup(fixture);
+ const previous = document.createRange();
+ previous.setStart(root.firstChild!, 1);
+ previous.setEnd(root.firstChild!, 2);
+ selection.addRange(previous);
+ selection.selectAllChildren(root);
+ expect(selection.getRangeAt(0)).not.toBe(previous);
+ expect([
+ selection.anchorNode,
+ selection.anchorOffset,
+ selection.focusNode,
+ selection.focusOffset,
+ ]).toEqual([root, 0, root, 3]);
+ expect(selection.toString()).toBe('abcmiddledef');
+ expect([previous.startOffset, previous.endOffset]).toEqual([1, 2]);
+ },
+ );
+ context.test(
+ 'selectAllChildren ignores detached and other-document nodes without replacing the range',
+ (fixture) => {
+ const { document, root, selection } = setup(fixture);
+ selection.selectAllChildren(root);
+ const range = selection.getRangeAt(0);
+ selection.selectAllChildren(document.createElement('div'));
+ expect(selection.getRangeAt(0)).toBe(range);
+ selection.selectAllChildren(document.implementation.createHTMLDocument('').body);
+ expect(selection.getRangeAt(0)).toBe(range);
+ },
+ );
+ context.test(
+ 'selectAllChildren rejects doctypes before altering the selected range',
+ (fixture) => {
+ const { document, root, selection } = setup(fixture);
+ selection.selectAllChildren(root);
+ const range = selection.getRangeAt(0);
+ const doctype = document.implementation.createDocumentType('html', '', '');
+ expect(() => selection.selectAllChildren(doctype)).toThrow(
+ expect.objectContaining({ name: 'InvalidNodeTypeError' }),
+ );
+ expect(selection.getRangeAt(0)).toBe(range);
+ },
+ );
+ context.test(
+ 'selectAllChildren uses child counts even for text nodes and empty elements',
+ (fixture) => {
+ const { root, selection } = setup(fixture);
+ for (const node of [
+ root.firstChild!,
+ root.appendChild(root.ownerDocument.createElement('i')),
+ ]) {
+ selection.selectAllChildren(node);
+ expect([
+ selection.anchorNode,
+ selection.anchorOffset,
+ selection.focusNode,
+ selection.focusOffset,
+ ]).toEqual([node, 0, node, 0]);
+ expect(selection.isCollapsed).toBe(true);
+ }
+ },
+ );
+ context.test(
+ 'removeRange uses identity and preserves the selection after a mismatch',
+ (fixture) => {
+ const { root, selection } = setup(fixture);
+ selection.selectAllChildren(root);
+ const range = selection.getRangeAt(0);
+ expect(() => selection.removeRange(range.cloneRange())).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ expect(selection.getRangeAt(0)).toBe(range);
+ selection.removeRange(range);
+ expect([selection.rangeCount, selection.anchorNode, selection.focusNode]).toEqual([
+ 0,
+ null,
+ null,
+ ]);
+ expect(() => selection.removeRange(range)).toThrow(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ },
+ );
+ context.test('removeRange rejects values that are not Range objects', (fixture) => {
+ const { root, selection } = setup(fixture);
+ selection.selectAllChildren(root);
+ const range = selection.getRangeAt(0);
+ for (const invalid of [null, selection, {}, undefined]) {
+ expect(() => selection.removeRange(invalid as unknown as Range)).toThrow(
+ expect.objectContaining({ name: 'TypeError' }),
+ );
+ expect(selection.getRangeAt(0)).toBe(range);
+ }
+ });
+ context.test('deleteFromDocument is a no-op for empty and collapsed selections', (fixture) => {
+ const { root, selection } = setup(fixture);
+ selection.deleteFromDocument();
+ expect(selection.rangeCount).toBe(0);
+ selection.collapse(root.firstChild!, 1);
+ const range = selection.getRangeAt(0);
+ selection.deleteFromDocument();
+ expect(root.textContent).toBe('abcmiddledef');
+ expect(selection.getRangeAt(0)).toBe(range);
+ expect(selection.anchorOffset).toBe(1);
+ });
+ context.test(
+ 'deleteFromDocument removes partial text and whole nodes through the retained range',
+ (fixture) => {
+ const { document, root, selection } = setup(fixture);
+ const first = root.firstChild!,
+ last = root.lastChild!,
+ middle = root.children[0]!;
+ const range = document.createRange();
+ range.setStart(first, 1);
+ range.setEnd(last, 2);
+ selection.addRange(range);
+ selection.deleteFromDocument();
+ expect(root.textContent).toBe('af');
+ expect(Array.from(root.childNodes)).toEqual([first, last]);
+ expect(middle.parentNode).toBeNull();
+ expect(middle.textContent).toBe('middle');
+ expect(selection.getRangeAt(0)).toBe(range);
+ expect([
+ range.startContainer,
+ range.startOffset,
+ range.endContainer,
+ range.endOffset,
+ ]).toEqual([root, 1, root, 1]);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/document/range-management.ts b/packages/web-platform-tests/src/compatibility/selection/document/range-management.ts
new file mode 100644
index 0000000..e392181
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/document/range-management.ts
@@ -0,0 +1,51 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://w3c.github.io/selection-api/#dom-selection-addrange';
+export const requires = ['selection.range.boundaries'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'stores one range by reference and clears it explicitly',
+ ({ document, connect, cleanup }) => {
+ const root = document.createElement('div');
+ root.textContent = 'selection';
+ connect(root);
+ const selection = document.getSelection();
+ if (!selection) return expect.fail('Document selection expected');
+ selection.removeAllRanges();
+ cleanup(() => selection.removeAllRanges());
+ const range = document.createRange();
+ range.selectNodeContents(root);
+ selection.addRange(range);
+ expect(selection.getRangeAt(0)).toBe(range);
+ expect(selection.rangeCount).toBe(1);
+ expect(selection.toString()).toBe('selection');
+ selection.removeAllRanges();
+ expect(selection.rangeCount).toBe(0);
+ expect(selection.anchorNode).toBeNull();
+ expect(() => selection.getRangeAt(0)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ },
+ );
+
+ context.test('retains range identity and clears anchor and focus on removal', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.textContent = 'abc';
+ fixture.connect(e as unknown as DOMElementContract);
+ const s = document.getSelection()!,
+ r = document.createRange();
+ r.selectNodeContents(e);
+ s.removeAllRanges();
+ s.addRange(r);
+ expect(s.getRangeAt(0)).toBe(r);
+ expect(s.rangeCount).toBe(1);
+ s.removeAllRanges();
+ expect(s.rangeCount).toBe(0);
+ expect(s.anchorNode).toBeNull();
+ expect(s.focusNode).toBeNull();
+ expect(() => s.getRangeAt(0)).toThrow(expect.objectContaining({ name: 'IndexSizeError' }));
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/boundaries.ts b/packages/web-platform-tests/src/compatibility/selection/range/boundaries.ts
new file mode 100644
index 0000000..e054f41
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/boundaries.ts
@@ -0,0 +1,60 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#interface-range';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'initializes at the document and counts UTF-16 and child-node offsets',
+ ({ document }) => {
+ const range = document.createRange();
+ expect([
+ range.startContainer,
+ range.startOffset,
+ range.endContainer,
+ range.endOffset,
+ ]).toEqual([document, 0, document, 0]);
+ const root = document.createElement('div');
+ const text = document.createTextNode('A😀B');
+ root.append(text, document.createComment('comment'));
+ range.selectNodeContents(root);
+ expect(range.endOffset).toBe(2);
+ range.setStart(text, 1);
+ range.setEnd(text, 3);
+ expect(range.startOffset).toBe(1);
+ expect(range.endOffset).toBe(3);
+ expect(range.collapsed).toBe(false);
+ expect(range.commonAncestorContainer).toBe(text);
+ expect(() => range.setEnd(text, 5)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ expect(range.endOffset).toBe(3);
+ range.setEnd(text, 0);
+ expect(range.collapsed).toBe(true);
+ expect(range.startOffset).toBe(0);
+ },
+ );
+
+ context.test('rejects out of range offsets and invalid node kinds', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const r = document.createRange(),
+ t = document.createTextNode('abc');
+ expect(() => r.setStart(t, 4)).toThrow(expect.objectContaining({ name: 'IndexSizeError' }));
+ const dt = document.implementation.createDocumentType('html', '', '');
+ expect(() => r.setEnd(dt, 0)).toThrow(
+ expect.objectContaining({ name: 'InvalidNodeTypeError' }),
+ );
+ });
+
+ context.test('collapses to a new root when boundaries cross trees', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const a = document.createTextNode('abc'),
+ b = document.createTextNode('def'),
+ r = document.createRange();
+ r.setStart(a, 1);
+ r.setEnd(a, 2);
+ r.setEnd(b, 1);
+ expect(r.collapsed).toBe(true);
+ expect(r.startContainer).toBe(b);
+ expect(r.startOffset).toBe(1);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/clone-contents.ts b/packages/web-platform-tests/src/compatibility/selection/range/clone-contents.ts
new file mode 100644
index 0000000..865da87
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/clone-contents.ts
@@ -0,0 +1,51 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-range-clonecontents';
+export const requires = ['selection.range.boundaries'] 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/dom/ranges/Range-cloneContents.html';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('clones partial ancestor shells without changing the source', ({ document }) => {
+ const root = document.createElement('div');
+ const a = document.createElement('b');
+ const b = document.createElement('i');
+ const first = document.createTextNode('abc');
+ const last = document.createTextNode('xyz');
+ a.append(first);
+ b.append(last);
+ root.append(a, b);
+ const range = document.createRange();
+ range.setStart(first, 1);
+ range.setEnd(last, 2);
+ const fragment = range.cloneContents();
+ expect(fragment.textContent).toBe('bcxy');
+ expect(fragment.childNodes.length).toBe(2);
+ expect(fragment.firstChild?.nodeName).toBe(a.nodeName);
+ expect(fragment.firstChild).not.toBe(a);
+ expect(root.textContent).toBe('abcxyz');
+ });
+
+ context.test('clones a partial text selection without mutating boundaries or data', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('abcdef'),
+ r = document.createRange();
+ r.setStart(t, 1);
+ r.setEnd(t, 4);
+ const f = r.cloneContents();
+ expect(f.textContent).toBe('bcd');
+ expect(t.data).toBe('abcdef');
+ expect(r.startOffset).toBe(1);
+ expect(r.endOffset).toBe(4);
+ expect(f.firstChild).not.toBe(t);
+ });
+
+ context.test('returns an empty fragment for a collapsed range', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const r = document.createRange(),
+ t = document.createTextNode('abc');
+ r.setStart(t, 1);
+ expect(r.cloneContents().childNodes.length).toBe(0);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/comparison.ts b/packages/web-platform-tests/src/compatibility/selection/range/comparison.ts
new file mode 100644
index 0000000..bb482a3
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/comparison.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-range-compareboundarypoints';
+export const requires = ['selection.range.boundaries'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('compares boundaries and distinguishes points outside a range', ({ document }) => {
+ const root = document.createElement('div');
+ const left = document.createElement('span');
+ const right = document.createElement('span');
+ root.append(left, right);
+ const range = document.createRange();
+ range.selectNode(left);
+ const clone = range.cloneRange();
+ expect(clone.compareBoundaryPoints(range.START_TO_START, range)).toBe(0);
+ expect(clone.compareBoundaryPoints(range.START_TO_END, range)).toBe(1);
+ expect(clone.compareBoundaryPoints(range.END_TO_START, range)).toBe(-1);
+ expect(range.isPointInRange(root, 0)).toBe(true);
+ expect(range.comparePoint(root, 2)).toBe(1);
+ expect(range.intersectsNode(right)).toBe(false);
+ expect(() => range.comparePoint(document.createTextNode('other'), 0)).toThrow(
+ expect.objectContaining({ name: 'WrongDocumentError' }),
+ );
+ });
+
+ context.test(
+ 'compares points before inside and after a range and rejects another tree',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('abcd'),
+ r = document.createRange();
+ r.setStart(t, 1);
+ r.setEnd(t, 3);
+ expect(r.comparePoint(t, 0)).toBe(-1);
+ expect(r.comparePoint(t, 1)).toBe(0);
+ expect(r.comparePoint(t, 3)).toBe(0);
+ expect(r.comparePoint(t, 4)).toBe(1);
+ expect(r.isPointInRange(t, 2)).toBe(true);
+ expect(r.isPointInRange(document.createTextNode('x'), 0)).toBe(false);
+ expect(() => r.comparePoint(document.createTextNode('x'), 0)).toThrow(
+ expect.objectContaining({ name: 'WrongDocumentError' }),
+ );
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/delete-contents.ts b/packages/web-platform-tests/src/compatibility/selection/range/delete-contents.ts
new file mode 100644
index 0000000..8f2bd99
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/delete-contents.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-range-deletecontents';
+export const requires = ['selection.range.boundaries'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'deletes selected text and nodes while retaining partial ancestors',
+ ({ document }) => {
+ const root = document.createElement('div');
+ const a = document.createElement('b');
+ const b = document.createElement('i');
+ const first = document.createTextNode('abc');
+ const last = document.createTextNode('xyz');
+ a.append(first);
+ b.append(last);
+ root.append(a, document.createElement('hr'), b);
+ const range = document.createRange();
+ range.setStart(first, 1);
+ range.setEnd(last, 2);
+ range.deleteContents();
+ expect(root.textContent).toBe('az');
+ expect(root.childNodes.length).toBe(2);
+ expect(root.childNodes[0]).toBe(a);
+ expect(root.childNodes[1]).toBe(b);
+ expect(range.collapsed).toBe(true);
+ },
+ );
+
+ context.test('deletes complete children without destroying removed node identity', (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);
+ const r = document.createRange();
+ r.selectNode(a);
+ r.deleteContents();
+ expect(p.firstChild).toBe(b);
+ expect(a.parentNode).toBeNull();
+ expect(r.collapsed).toBe(true);
+ expect(r.startContainer).toBe(p);
+ expect(r.startOffset).toBe(0);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/extract-contents.ts b/packages/web-platform-tests/src/compatibility/selection/range/extract-contents.ts
new file mode 100644
index 0000000..daf14b6
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/extract-contents.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-range-extractcontents';
+export const requires = ['selection.range.boundaries'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('moves wholly contained nodes while cloning partial ancestors', ({ document }) => {
+ const root = document.createElement('div');
+ const a = document.createElement('b');
+ const middle = document.createElement('span');
+ const b = document.createElement('i');
+ const first = document.createTextNode('abc');
+ const last = document.createTextNode('xyz');
+ a.append(first);
+ middle.textContent = 'middle';
+ b.append(last);
+ root.append(a, middle, b);
+ const range = document.createRange();
+ range.setStart(first, 1);
+ range.setEnd(last, 2);
+ const fragment = range.extractContents();
+ expect(fragment.textContent).toBe('bcmiddlexy');
+ expect(fragment.childNodes[1]).toBe(middle);
+ expect(fragment.firstChild).not.toBe(a);
+ expect(root.textContent).toBe('az');
+ expect(range.collapsed).toBe(true);
+ });
+
+ context.test('extracts partial text and collapses the range', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('abcdef'),
+ r = document.createRange();
+ r.setStart(t, 1);
+ r.setEnd(t, 4);
+ expect(r.extractContents().textContent).toBe('bcd');
+ expect(t.data).toBe('aef');
+ expect(r.collapsed).toBe(true);
+ expect(r.startOffset).toBe(1);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/live-boundaries.ts b/packages/web-platform-tests/src/compatibility/selection/range/live-boundaries.ts
new file mode 100644
index 0000000..4bac530
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/live-boundaries.ts
@@ -0,0 +1,76 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#concept-live-range';
+export const requires = [
+ 'selection.range.boundaries',
+ 'characterData.editing',
+ 'characterData.splitText',
+] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'adjusts live boundaries during replacement, splitting, and removal',
+ ({ document }) => {
+ const root = document.createElement('div');
+ const text = document.createTextNode('A😀B');
+ root.append(text);
+ const range = document.createRange();
+ range.setStart(text, 1);
+ range.setEnd(text, 4);
+ text.replaceData(1, 2, 'X');
+ expect([range.startOffset, range.endOffset]).toEqual([1, 3]);
+ const split = text.splitText(1);
+ expect(range.startContainer).toBe(text);
+ expect(range.endContainer).toBe(split);
+ expect(range.endOffset).toBe(2);
+ split.remove();
+ expect(range.endContainer).toBe(root);
+ expect(range.endOffset).toBe(1);
+ },
+ );
+ context.test(
+ 'does not move a boundary when insertion occurs exactly at that boundary',
+ ({ document }) => {
+ const root = document.createElement('div');
+ const a = document.createElement('span');
+ const b = document.createElement('span');
+ root.append(a, b);
+ const range = document.createRange();
+ range.setStart(root, 1);
+ range.setEnd(root, 2);
+ root.insertBefore(document.createElement('span'), b);
+ expect(range.startOffset).toBe(1);
+ expect(range.endOffset).toBe(3);
+ },
+ );
+
+ context.test('adjusts offsets after text insertion and deletion', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const t = document.createTextNode('abcdef'),
+ r = document.createRange();
+ r.setStart(t, 2);
+ r.setEnd(t, 5);
+ t.insertData(1, 'XX');
+ expect(r.startOffset).toBe(4);
+ expect(r.endOffset).toBe(7);
+ t.deleteData(2, 3);
+ expect(r.startOffset).toBe(2);
+ expect(r.endOffset).toBe(4);
+ });
+
+ context.test('relocates boundaries to the old parent when a subtree is removed', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i'),
+ t = document.createTextNode('abc');
+ e.append(t);
+ p.append(e);
+ const r = document.createRange();
+ r.setStart(t, 1);
+ r.setEnd(t, 2);
+ e.remove();
+ expect(r.startContainer).toBe(p);
+ expect(r.startOffset).toBe(0);
+ expect(r.collapsed).toBe(true);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/mutation.ts b/packages/web-platform-tests/src/compatibility/selection/range/mutation.ts
new file mode 100644
index 0000000..987f18f
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/mutation.ts
@@ -0,0 +1,205 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#interface-range';
+export const requires = ['selection.range.boundaries', 'document.xml'] as const;
+// Additional matrices adapted from WPT Range-{insertNode,surroundContents}.html.
+// Copyright © web-platform-tests contributors. Original author: Aryeh Gregor.
+// BSD-3-Clause; see ../../../../test/wpt/LICENSE and docs/wpt-ranges-parsing.md.
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/dom/ranges/Range-surroundContents.html';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'inserts into a text boundary by splitting text and expands a collapsed range',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ t = document.createTextNode('abcd'),
+ e = document.createElement('i'),
+ r = document.createRange();
+ p.append(t);
+ r.setStart(t, 2);
+ r.collapse(true);
+ r.insertNode(e);
+ expect(Array.from(p.childNodes).map((n) => n.nodeName)).toEqual(['#text', 'I', '#text']);
+ expect(t.data).toBe('ab');
+ expect(p.lastChild!.nodeValue).toBe('cd');
+ expect(r.startContainer).toBe(t);
+ expect(r.startOffset).toBe(2);
+ expect(r.endContainer).toBe(p);
+ expect(r.endOffset).toBe(2);
+ },
+ );
+ context.test('surrounds selected text and selects the new parent', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ t = document.createTextNode('abcd'),
+ r = document.createRange(),
+ e = document.createElement('b');
+ p.append(t);
+ r.setStart(t, 1);
+ r.setEnd(t, 3);
+ r.surroundContents(e);
+ expect(p.innerHTML).toBe('abc d');
+ expect(r.startContainer).toBe(p);
+ expect(r.startOffset).toBe(1);
+ expect(r.endOffset).toBe(2);
+ });
+ context.test('rejects surrounding a partially selected non-text node atomically', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = 'ab cd';
+ const r = document.createRange();
+ r.setStart(p.firstChild!.firstChild!, 1);
+ r.setEnd(p, 2);
+ expect(() => r.surroundContents(document.createElement('b'))).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ expect(p.innerHTML).toBe('ab cd');
+ });
+ context.test(
+ 'selectNode rejects parentless nodes and selectNodeContents selects children',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ r = document.createRange();
+ expect(() => r.selectNode(p)).toThrow(
+ expect.objectContaining({ name: 'InvalidNodeTypeError' }),
+ );
+ p.append('a', 'b');
+ r.selectNodeContents(p);
+ expect(r.startOffset).toBe(0);
+ expect(r.endOffset).toBe(2);
+ r.collapse(false);
+ expect(r.startOffset).toBe(2);
+ expect(r.collapsed).toBe(true);
+ },
+ );
+ context.test(
+ 'rejects invalid surround wrapper kinds without extracting the selection',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div');
+ const text = parent.appendChild(document.createTextNode('abcd'));
+ const range = document.createRange();
+ range.setStart(text, 1);
+ range.setEnd(text, 3);
+ for (const wrapper of [
+ document,
+ document.createDocumentFragment(),
+ document.implementation.createDocumentType('html', '', ''),
+ ]) {
+ expect(() => range.surroundContents(wrapper)).toThrow(
+ expect.objectContaining({ name: 'InvalidNodeTypeError' }),
+ );
+ expect(parent.textContent).toBe('abcd');
+ expect(parent.firstChild).toBe(text);
+ expect([
+ range.startContainer,
+ range.startOffset,
+ range.endContainer,
+ range.endOffset,
+ ]).toEqual([text, 1, text, 3]);
+ }
+ },
+ );
+ context.test(
+ 'surroundContents clears existing wrapper children and moves the wrapper by identity',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div');
+ const first = document.createElement('i'),
+ last = document.createElement('b');
+ parent.append(first, last);
+ const oldParent = document.createElement('div');
+ const wrapper = oldParent.appendChild(document.createElement('section'));
+ const discarded = wrapper.appendChild(document.createTextNode('discarded'));
+ const range = document.createRange();
+ range.selectNodeContents(parent);
+ range.surroundContents(wrapper);
+ expect(parent.firstChild).toBe(wrapper);
+ expect(Array.from(wrapper.childNodes)).toEqual([first, last]);
+ expect(discarded.parentNode).toBeNull();
+ expect(discarded.data).toBe('discarded');
+ expect(oldParent.childNodes.length).toBe(0);
+ expect([
+ range.startContainer,
+ range.startOffset,
+ range.endContainer,
+ range.endOffset,
+ ]).toEqual([parent, 0, parent, 1]);
+ },
+ );
+ context.test(
+ 'insertNode empties a fragment while preserving child identities and expanding a collapsed range',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div'),
+ tail = document.createElement('i');
+ parent.append(tail);
+ const fragment = document.createDocumentFragment();
+ const a = document.createElement('b'),
+ b = document.createTextNode('text');
+ fragment.append(a, b);
+ const range = document.createRange();
+ range.setStart(parent, 0);
+ range.insertNode(fragment);
+ expect(Array.from(parent.childNodes)).toEqual([a, b, tail]);
+ expect(fragment.childNodes.length).toBe(0);
+ expect([range.startOffset, range.endOffset]).toEqual([0, 2]);
+ expect(range.startContainer).toBe(parent);
+ expect(range.endContainer).toBe(parent);
+ },
+ );
+ context.test(
+ 'insertNode adopts nodes from another document and removes them from their old tree',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const other = document.implementation.createHTMLDocument('');
+ const node = other.body.appendChild(other.createElement('i'));
+ const child = node.appendChild(other.createTextNode('foreign'));
+ const parent = document.createElement('div'),
+ range = document.createRange();
+ range.selectNodeContents(parent);
+ range.insertNode(node);
+ expect(parent.firstChild).toBe(node);
+ expect(node.ownerDocument).toBe(document);
+ expect(child.ownerDocument).toBe(document);
+ expect(other.body.childNodes.length).toBe(0);
+ expect([range.startOffset, range.endOffset]).toEqual([0, 1]);
+ },
+ );
+ context.test('insertNode validates ancestor cycles before splitting boundary text', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div');
+ const text = parent.appendChild(document.createTextNode('abcd'));
+ const range = document.createRange();
+ range.setStart(text, 2);
+ expect(() => range.insertNode(parent)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(Array.from(parent.childNodes)).toEqual([text]);
+ expect(text.data).toBe('abcd');
+ expect(range.startContainer).toBe(text);
+ expect(range.startOffset).toBe(2);
+ expect(range.collapsed).toBe(true);
+ });
+ context.test('insertNode rejects comment and parentless text boundary containers', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const parent = document.createElement('div');
+ const comment = parent.appendChild(document.createComment('abc'));
+ const detached = document.createTextNode('abc');
+ for (const boundary of [comment, detached]) {
+ const range = document.createRange();
+ range.setStart(boundary, 1);
+ const node = document.createElement('i');
+ expect(() => range.insertNode(node)).toThrow(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(node.parentNode).toBeNull();
+ expect(boundary.data).toBe('abc');
+ expect(range.startContainer).toBe(boundary);
+ expect(range.startOffset).toBe(1);
+ }
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/selection/range/stringification.ts b/packages/web-platform-tests/src/compatibility/selection/range/stringification.ts
new file mode 100644
index 0000000..5767a91
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/selection/range/stringification.ts
@@ -0,0 +1,30 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-range-stringifier';
+export const requires = ['selection.range.boundaries'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('stringifies selected text but excludes comments', ({ document }) => {
+ const root = document.createElement('div');
+ const text = document.createTextNode('A😀B');
+ root.append(text, document.createComment('ignored'), document.createTextNode('tail'));
+ const range = document.createRange();
+ range.selectNodeContents(root);
+ expect(range.toString()).toBe('A😀Btail');
+ range.setStart(text, 1);
+ range.setEnd(text, 3);
+ expect(range.toString()).toBe('😀');
+ });
+
+ context.test('stringifies only text portions and excludes comments', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = 'abc de';
+ const r = document.createRange();
+ r.setStart(p.firstChild!, 0);
+ r.setEnd(p.lastChild!, 1);
+ expect(r.toString()).toBe('abcd');
+ r.collapse(true);
+ expect(r.toString()).toBe('');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/declarative.ts b/packages/web-platform-tests/src/compatibility/shadow/declarative.ts
new file mode 100644
index 0000000..f1c2880
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/declarative.ts
@@ -0,0 +1,25 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhead';
+export const requires = ['shadow.roots.attachment', 'parsing.fragments'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('constructs declarative roots beneath newly parsed hosts', ({ document }) => {
+ const container = document.createElement('div');
+ container.setHTMLUnsafe(
+ '',
+ );
+ const host = container.firstElementChild;
+ if (!host) return expect.fail('Parsed host expected');
+ expect(host.shadowRoot?.textContent).toBe('shadow');
+ expect(host.shadowRoot?.host).toBe(host);
+ expect(host.childNodes.length).toBe(1);
+ expect(host.firstChild?.textContent).toBe('light');
+ });
+ context.test('does not attach a root to the fragment context element', ({ document }) => {
+ const host = document.createElement('div');
+ host.setHTMLUnsafe('shadow light');
+ expect(host.shadowRoot).toBeNull();
+ expect(host.firstElementChild?.localName).toBe('template');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/events/composed-path.ts b/packages/web-platform-tests/src/compatibility/shadow/events/composed-path.ts
new file mode 100644
index 0000000..acfb584
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/events/composed-path.ts
@@ -0,0 +1,142 @@
+// Adapted from WPT event-composed-path.html at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. Original author: Hayato Ito.
+// BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+import type { CompatibilityContext } from '../../context.ts';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/event-composed-path.html';
+export const spec = 'https://dom.spec.whatwg.org/#dom-event-composedpath';
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['shadow.events.retargeting'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'hides closed-root internals from listeners outside the root',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'closed' });
+ const child = document.createElement('span');
+ root.append(child);
+ const paths: unknown[] = [];
+ child.addEventListener('probe', (event) => {
+ paths.push(event.composedPath());
+ });
+ host.addEventListener('probe', (event) => {
+ paths.push(event.composedPath());
+ });
+ const event = new Event('probe', { bubbles: true, composed: true });
+ child.dispatchEvent(event);
+ expect(paths).toEqual([[child, root, host], [host]]);
+ expect(event.composedPath()).toEqual([]);
+ },
+ );
+
+ context.test('hides closed-root internals from outside composedPath', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'closed' }),
+ child = document.createElement('i');
+ root.append(child);
+ let inside: EventTarget[] = [],
+ outside: EventTarget[] = [];
+ child.addEventListener('x', (e) => (inside = e.composedPath()));
+ host.addEventListener('x', (e) => (outside = e.composedPath()));
+ const Event = fixture.globals('Event');
+ child.dispatchEvent(new Event('x', { bubbles: true, composed: true }) as unknown as Event);
+ expect(inside).toContain(child);
+ expect(inside).toContain(root);
+ expect(outside).not.toContain(child);
+ expect(outside).not.toContain(root);
+ expect(outside[0]).toBe(host);
+ });
+
+ for (const outerMode of ['open', 'closed'] as const) {
+ for (const innerMode of ['open', 'closed'] as const) {
+ context.test(
+ `retargets and filters every listener path through ${outerMode}/${innerMode} nested roots`,
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Event = fixture.globals('Event') as unknown as typeof globalThis.Event;
+ const outerHost = document.createElement('div');
+ const outerRoot = outerHost.attachShadow({ mode: outerMode });
+ const innerHost = document.createElement('div');
+ const innerRoot = innerHost.attachShadow({ mode: innerMode });
+ const target = document.createElement('span');
+ outerRoot.append(innerHost);
+ innerRoot.append(target);
+ const fullPath = [target, innerRoot, innerHost, outerRoot, outerHost];
+ const calls: unknown[] = [];
+ for (const current of fullPath)
+ current.addEventListener('probe', (event) => {
+ calls.push([current, event.target, event.composedPath()]);
+ });
+ const event = new Event('probe', { bubbles: true, composed: true });
+ target.dispatchEvent(event);
+ const middlePath = innerMode === 'closed' ? fullPath.slice(2) : fullPath;
+ const outsidePath = outerMode === 'closed' ? [outerHost] : middlePath;
+ expect(calls).toEqual([
+ [target, target, fullPath],
+ [innerRoot, target, fullPath],
+ [innerHost, innerHost, middlePath],
+ [outerRoot, innerHost, middlePath],
+ [outerHost, outerHost, outsidePath],
+ ]);
+ expect(event.composedPath()).toEqual([]);
+ },
+ );
+
+ context.test(
+ `preserves a light-tree target through slots in ${outerMode}/${innerMode} nested roots`,
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Event = fixture.globals('Event') as unknown as typeof globalThis.Event;
+ const outerHost = document.createElement('div');
+ const outerRoot = outerHost.attachShadow({ mode: outerMode });
+ const innerHost = document.createElement('div');
+ const innerRoot = innerHost.attachShadow({ mode: innerMode });
+ const outerSlot = document.createElement('slot');
+ const innerSlot = document.createElement('slot');
+ const target = document.createElement('span');
+ outerRoot.append(innerHost);
+ innerHost.append(outerSlot);
+ innerRoot.append(innerSlot);
+ outerHost.append(target);
+ const fullPath = [
+ target,
+ outerSlot,
+ innerSlot,
+ innerRoot,
+ innerHost,
+ outerRoot,
+ outerHost,
+ ];
+ const middlePath =
+ innerMode === 'closed'
+ ? [target, outerSlot, innerHost, outerRoot, outerHost]
+ : fullPath;
+ const outsidePath = outerMode === 'closed' ? [target, outerHost] : middlePath;
+ const calls: unknown[] = [];
+ for (const current of fullPath)
+ current.addEventListener('probe', (event) => {
+ calls.push([current, event.target, event.composedPath()]);
+ });
+ const event = new Event('probe', { bubbles: true, composed: true });
+ target.dispatchEvent(event);
+ expect(calls).toEqual([
+ [target, target, outsidePath],
+ [outerSlot, target, middlePath],
+ [innerSlot, target, fullPath],
+ [innerRoot, target, fullPath],
+ [innerHost, target, middlePath],
+ [outerRoot, target, middlePath],
+ [outerHost, target, outsidePath],
+ ]);
+ expect(event.composedPath()).toEqual([]);
+ },
+ ['shadow.slots.named'],
+ );
+ }
+ }
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/events/related-target.ts b/packages/web-platform-tests/src/compatibility/shadow/events/related-target.ts
new file mode 100644
index 0000000..5041d4a
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/events/related-target.ts
@@ -0,0 +1,29 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#concept-event-dispatch';
+const requiredGlobals = ['FocusEvent'] as const;
+export { requiredGlobals as globals };
+export const requires = ['shadow.events.retargeting'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'suppresses delivery when target and relatedTarget retarget to the same host',
+ ({ document, globals }) => {
+ const FocusEvent = globals('FocusEvent');
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const first = document.createElement('button');
+ const second = document.createElement('button');
+ root.append(first, second);
+ let inside = 0;
+ let outside = 0;
+ root.addEventListener('focusout', () => inside++);
+ host.addEventListener('focusout', () => outside++);
+ first.dispatchEvent(
+ new FocusEvent('focusout', { bubbles: true, composed: true, relatedTarget: second }),
+ );
+ expect(inside).toBe(1);
+ expect(outside).toBe(0);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/events/retargeting.ts b/packages/web-platform-tests/src/compatibility/shadow/events/retargeting.ts
new file mode 100644
index 0000000..ea856a2
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/events/retargeting.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#retarget';
+const requiredGlobals = ['Event'] as const;
+export { requiredGlobals as globals };
+export const requires = ['shadow.roots.attachment', 'events.dispatch.bubbling'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'retargets composed events to the host outside the shadow tree',
+ ({ document, globals }) => {
+ const Event = globals('Event');
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const child = document.createElement('span');
+ root.append(child);
+ const targets: unknown[] = [];
+ child.addEventListener('probe', (event) => {
+ targets.push(event.target);
+ });
+ host.addEventListener('probe', (event) => {
+ targets.push(event.target);
+ });
+ child.dispatchEvent(new Event('probe', { bubbles: true, composed: true }));
+ expect(targets).toEqual([child, host]);
+ targets.length = 0;
+ child.dispatchEvent(new Event('probe', { bubbles: true }));
+ expect(targets).toEqual([child]);
+ },
+ );
+
+ context.test('prevents noncomposed events from escaping a shadow root', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open' }),
+ child = document.createElement('i');
+ root.append(child);
+ const seen: unknown[] = [];
+ root.addEventListener('x', (e) => seen.push(e.target));
+ host.addEventListener('x', () => seen.push('host'));
+ const Event = fixture.globals('Event');
+ child.dispatchEvent(new Event('x', { bubbles: true }) as unknown as Event);
+ expect(seen).toEqual([child]);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/focus/delegation.ts b/packages/web-platform-tests/src/compatibility/shadow/focus/delegation.ts
new file mode 100644
index 0000000..bc1121b
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/focus/delegation.ts
@@ -0,0 +1,18 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/interaction.html#focus-delegate';
+export const requires = ['shadow.focus.retargeting'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('delegates host focus to an eligible shadow descendant', ({ document, connect }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open', delegatesFocus: true });
+ const control = document.createElement('button');
+ root.append(control);
+ connect(host);
+ host.focus();
+ expect(root.delegatesFocus).toBe(true);
+ expect(document.activeElement).toBe(host);
+ expect(root.activeElement).toBe(control);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/focus/retargeting.ts b/packages/web-platform-tests/src/compatibility/shadow/focus/retargeting.ts
new file mode 100644
index 0000000..041e6e3
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/focus/retargeting.ts
@@ -0,0 +1,23 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/interaction.html#dom-documentorshadowroot-activeelement';
+export const requires = ['shadow.roots.attachment', 'focus.eligibility'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'exposes the host as document focus and the control as shadow focus',
+ ({ document, connect }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const control = document.createElement('button');
+ root.append(control);
+ connect(host);
+ control.focus();
+ expect(document.activeElement).toBe(host);
+ expect(root.activeElement).toBe(control);
+ control.blur();
+ expect(root.activeElement).toBeNull();
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/roots/adoption.ts b/packages/web-platform-tests/src/compatibility/shadow/roots/adoption.ts
new file mode 100644
index 0000000..2f2ec9e
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/roots/adoption.ts
@@ -0,0 +1,36 @@
+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 };
+export const requires = ['shadow.roots.attachment', 'node.adoption'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('adopts shadow descendants with their host', ({ document, globals }) => {
+ const Document = globals('Document');
+ const other = new Document();
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'closed' });
+ const child = document.createElement('span');
+ root.append(child);
+ other.adoptNode(host);
+ expect(host.ownerDocument).toBe(other);
+ expect(root.ownerDocument).toBe(other);
+ expect(child.ownerDocument).toBe(other);
+ expect(root.host).toBe(host);
+ expect(root.firstChild).toBe(child);
+ });
+
+ context.test('updates shadow descendants ownership when the host is adopted', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const other = document.implementation.createHTMLDocument(''),
+ host = other.createElement('div'),
+ root = host.attachShadow({ mode: 'open' }),
+ e = other.createElement('i');
+ root.append(e);
+ document.adoptNode(host);
+ expect(root.ownerDocument).toBe(document);
+ expect(e.ownerDocument).toBe(document);
+ expect(root.host).toBe(host);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/roots/attachment.ts b/packages/web-platform-tests/src/compatibility/shadow/roots/attachment.ts
new file mode 100644
index 0000000..0dea158
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/roots/attachment.ts
@@ -0,0 +1,39 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-element-attachshadow';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'exposes open roots and hides closed roots without changing their host',
+ ({ document }) => {
+ for (const mode of ['open', 'closed'] as const) {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode });
+ expect(root.host).toBe(host);
+ expect(root.mode).toBe(mode);
+ expect(root.parentNode).toBeNull();
+ expect(root.ownerDocument).toBe(document);
+ expect(host.shadowRoot).toBe(mode === 'open' ? root : null);
+ expect(() => host.attachShadow({ mode })).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ }
+ },
+ );
+
+ context.test('keeps light and shadow trees separate with stable root identity', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ light = document.createElement('i');
+ host.append(light);
+ const root = host.attachShadow({ mode: 'open' }),
+ child = document.createElement('b');
+ root.append(child);
+ expect(host.firstChild).toBe(light);
+ expect(root.firstChild).toBe(child);
+ expect(root.getRootNode()).toBe(root);
+ expect(child.getRootNode()).toBe(root);
+ expect(host.contains(child)).toBe(false);
+ expect(host.shadowRoot).toBe(root);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/roots/cloning.ts b/packages/web-platform-tests/src/compatibility/shadow/roots/cloning.ts
new file mode 100644
index 0000000..37c6816
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/roots/cloning.ts
@@ -0,0 +1,43 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#concept-node-clone';
+const requiredGlobals = ['Element'] as const;
+export { requiredGlobals as globals };
+export const requires = ['shadow.roots.attachment', 'node.cloning'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('clones clonable roots and omits ordinary shadow roots', ({ document, globals }) => {
+ const Element = globals('Element');
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open', clonable: true });
+ root.append(document.createTextNode('shadow'));
+ const clone = host.cloneNode(true);
+ if (!(clone instanceof Element)) return expect.fail('Element clone expected');
+ expect(clone.shadowRoot?.clonable).toBe(true);
+ expect(clone.shadowRoot?.textContent).toBe('shadow');
+ expect(clone.shadowRoot).not.toBe(root);
+ const ordinary = document.createElement('div');
+ ordinary.attachShadow({ mode: 'open' });
+ const other = ordinary.cloneNode(true);
+ if (!(other instanceof Element)) return expect.fail('Element clone expected');
+ expect(other.shadowRoot).toBeNull();
+ });
+
+ context.test('ordinary shadow roots are not cloned without the clonable option', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div');
+ host.attachShadow({ mode: 'open' }).append(document.createElement('i'));
+ expect((host.cloneNode(true) as Element).shadowRoot).toBeNull();
+ });
+
+ context.test('clonable roots clone contents independently while keeping mode', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open', clonable: true });
+ root.innerHTML = 'x ';
+ const copy = host.cloneNode(true) as Element;
+ expect(copy.shadowRoot!.mode).toBe('open');
+ expect(copy.shadowRoot!.innerHTML).toBe('x ');
+ expect(copy.shadowRoot!.firstChild).not.toBe(root.firstChild);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/roots/connectivity.ts b/packages/web-platform-tests/src/compatibility/shadow/roots/connectivity.ts
new file mode 100644
index 0000000..7c0b4bb
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/roots/connectivity.ts
@@ -0,0 +1,46 @@
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#connected';
+export const requires = ['shadow.roots.attachment'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'follows host connectivity while preserving the shadow tree root',
+ ({ document, connect }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const child = document.createElement('span');
+ root.append(child);
+ expect(child.isConnected).toBe(false);
+ connect(host);
+ expect(root.isConnected).toBe(true);
+ expect(child.isConnected).toBe(true);
+ expect(child.getRootNode()).toBe(root);
+ expect(child.getRootNode({ composed: true })).toBe(document);
+ expect(host.contains(child)).toBe(false);
+ host.remove();
+ expect(root.isConnected).toBe(false);
+ expect(child.parentNode).toBe(root);
+ },
+ );
+
+ context.test(
+ 'composed roots change with host connectivity while ordinary roots stay shadow roots',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open' }),
+ child = document.createElement('i');
+ root.append(child);
+ expect(child.isConnected).toBe(false);
+ expect(child.getRootNode({ composed: true })).toBe(host);
+ fixture.connect(host as unknown as DOMElementContract);
+ expect(root.isConnected).toBe(true);
+ expect(child.getRootNode({ composed: true })).toBe(document);
+ expect(child.getRootNode()).toBe(root);
+ host.remove();
+ expect(root.isConnected).toBe(false);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/roots/host-eligibility.ts b/packages/web-platform-tests/src/compatibility/shadow/roots/host-eligibility.ts
new file mode 100644
index 0000000..c45c646
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/roots/host-eligibility.ts
@@ -0,0 +1,25 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#dom-element-attachshadow';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('rejects HTML elements that cannot be shadow hosts', ({ document }) => {
+ const input = document.createElement('input');
+ expect(() => input.attachShadow({ mode: 'open' })).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ });
+
+ context.test('rejects SVG elements and standard HTML non-host elements', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ for (const host of [
+ document.createElementNS('http://www.w3.org/2000/svg', 'svg'),
+ document.createElement('input'),
+ document.createElement('a'),
+ document.createElement('br'),
+ ])
+ expect(() => host.attachShadow({ mode: 'open' })).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/serialization.ts b/packages/web-platform-tests/src/compatibility/shadow/serialization.ts
new file mode 100644
index 0000000..01cdbf6
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/serialization.ts
@@ -0,0 +1,31 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec =
+ 'https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-element-gethtml';
+export const requires = ['shadow.roots.attachment', 'parsing.serialization'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('serializes explicitly included and serializable shadow roots', ({ document }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open', serializable: true });
+ const child = document.createElement('span');
+ child.textContent = 'shadow';
+ root.append(child);
+ host.append(document.createTextNode('light'));
+ expect(host.getHTML()).toBe('light');
+ const serialized = host.getHTML({ serializableShadowRoots: true });
+ expect(serialized).toContain('shadowrootmode="open"');
+ expect(serialized).toContain('shadow ');
+ expect(serialized.endsWith('light')).toBe(true);
+ expect(host.getHTML({ shadowRoots: [root] })).toBe(serialized);
+ });
+
+ context.test('innerHTML and outerHTML exclude shadow markup by default', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div');
+ host.innerHTML = 'light ';
+ host.attachShadow({ mode: 'open' }).innerHTML = 'shadow ';
+ expect(host.innerHTML).toBe('light ');
+ expect(host.outerHTML).toBe('light
');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/slots/fallback.ts b/packages/web-platform-tests/src/compatibility/shadow/slots/fallback.ts
new file mode 100644
index 0000000..0b9c9a1
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/slots/fallback.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#find-flattened-slotables';
+export const requires = ['shadow.slots.named'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'includes fallback children only in flattened assignment results',
+ ({ document }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const slot = document.createElement('slot');
+ const fallback = document.createElement('span');
+ slot.append(fallback);
+ root.append(slot);
+ expect(slot.assignedNodes()).toEqual([]);
+ expect(slot.assignedNodes({ flatten: true })).toEqual([fallback]);
+ const child = document.createTextNode('assigned');
+ host.append(child);
+ expect(slot.assignedNodes({ flatten: true })).toEqual([child]);
+ },
+ );
+
+ context.test('uses fallback only for flattened assignment and excludes comments', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open' }),
+ slot = document.createElement('slot'),
+ fallback = document.createTextNode('fallback');
+ slot.append(fallback);
+ root.append(slot);
+ host.append(document.createComment('ignored'));
+ expect(slot.assignedNodes()).toEqual([]);
+ expect(slot.assignedNodes({ flatten: true })).toEqual([fallback]);
+ const light = document.createTextNode('light');
+ host.append(light);
+ expect(slot.assignedNodes({ flatten: true })).toEqual([light]);
+ expect(light.assignedSlot).toBe(slot);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/slots/manual.ts b/packages/web-platform-tests/src/compatibility/shadow/slots/manual.ts
new file mode 100644
index 0000000..2c3ca97
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/slots/manual.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/scripting.html#dom-slot-assign';
+export const requires = ['shadow.roots.attachment'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('honors manual assignment order without duplicating slottables', ({ document }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
+ const slot = document.createElement('slot');
+ root.append(slot);
+ const first = document.createElement('span');
+ const second = document.createTextNode('second');
+ host.append(first, second);
+ expect(slot.assignedNodes()).toEqual([]);
+ slot.assign(second, first, second);
+ expect(root.slotAssignment).toBe('manual');
+ expect(slot.assignedNodes()).toEqual([second, first]);
+ expect(first.assignedSlot).toBe(slot);
+ slot.assign();
+ expect(slot.assignedNodes()).toEqual([]);
+ });
+
+ context.test(
+ 'manual assignments replace previous assignments and update assignedSlot',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open', slotAssignment: 'manual' }),
+ slot = document.createElement('slot'),
+ a = document.createElement('i'),
+ b = document.createTextNode('b');
+ root.append(slot);
+ host.append(a, b);
+ slot.assign(a, b);
+ expect(slot.assignedNodes()).toEqual([a, b]);
+ slot.assign(b);
+ expect(slot.assignedNodes()).toEqual([b]);
+ expect(a.assignedSlot).toBeNull();
+ expect(b.assignedSlot).toBe(slot);
+ slot.assign();
+ expect(slot.assignedNodes()).toEqual([]);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/slots/named.ts b/packages/web-platform-tests/src/compatibility/shadow/slots/named.ts
new file mode 100644
index 0000000..183f1bd
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/slots/named.ts
@@ -0,0 +1,45 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#find-slot';
+export const requires = ['shadow.roots.attachment'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('assigns matching light children to the first named slot', ({ document }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const first = document.createElement('slot');
+ const duplicate = document.createElement('slot');
+ first.name = duplicate.name = 'named';
+ root.append(first, duplicate);
+ const child = document.createElement('span');
+ child.slot = 'named';
+ const text = document.createTextNode('unmatched');
+ host.append(child, text);
+ expect(first.assignedNodes()).toEqual([child]);
+ expect(first.assignedElements()).toEqual([child]);
+ expect(duplicate.assignedNodes()).toEqual([]);
+ expect(child.assignedSlot).toBe(first);
+ child.slot = 'other';
+ expect(first.assignedNodes()).toEqual([]);
+ expect(child.assignedSlot).toBeNull();
+ });
+
+ context.test('assigns to the first matching slot and redistributes on renaming', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open' }),
+ first = document.createElement('slot'),
+ second = document.createElement('slot'),
+ light = document.createElement('i');
+ first.name = second.name = 'x';
+ root.append(first, second);
+ light.slot = 'x';
+ host.append(light);
+ expect(first.assignedNodes()).toEqual([light]);
+ expect(second.assignedNodes()).toEqual([]);
+ expect(light.assignedSlot).toBe(first);
+ first.name = 'y';
+ expect(second.assignedNodes()).toEqual([light]);
+ expect(light.assignedSlot).toBe(second);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/slots/observer-ordering.ts b/packages/web-platform-tests/src/compatibility/shadow/slots/observer-ordering.ts
new file mode 100644
index 0000000..e1a4011
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/slots/observer-ordering.ts
@@ -0,0 +1,107 @@
+// Adapted from WPT slotchange-event.html at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. Original authors: Ryosuke Niwa and Hayato Ito.
+// BSD-3-Clause; see ../../../../test/wpt/LICENSE.
+import type { CompatibilityContext } from '../../context.ts';
+import type { DOMElement as DOMElementContract } from '../../contracts/nodes.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#notify-mutation-observers';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/shadow-dom/slotchange-event.html';
+export const globals = ['MutationObserver'] as const;
+export const requires = [
+ 'shadow.slots.slotchange',
+ 'mutationObserver.microtaskDelivery',
+ 'mutationObserver.filtering',
+] 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) {
+ for (const connected of [false, true]) {
+ context.test(
+ `orders observer callbacks and reentrant slot signals in a ${connected ? 'connected' : 'detached'} ${mode} root`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals(
+ 'MutationObserver',
+ ) as unknown as typeof globalThis.MutationObserver;
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode });
+ const slot = document.createElement('slot');
+ root.append(slot);
+ if (connected) fixture.connect(host as unknown as DOMElementContract);
+ const observed = document.createElement('div');
+ const calls: string[] = [];
+ slot.addEventListener('slotchange', () => calls.push('slotchange'));
+ const first = new MutationObserver(() => {
+ calls.push('observer:id');
+ host.append(document.createElement('span'));
+ observed.setAttribute('title', 'next');
+ });
+ const second = new MutationObserver(() => calls.push('observer:title'));
+ fixture.cleanup(() => first.disconnect());
+ fixture.cleanup(() => second.disconnect());
+ first.observe(observed, { attributes: true, attributeFilter: ['id'] });
+ second.observe(observed, { attributes: true, attributeFilter: ['title'] });
+ observed.id = 'start';
+ host.append(document.createElement('div'));
+ expect(calls).toEqual([]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(calls).toEqual(['observer:id', 'slotchange', 'observer:title', 'slotchange']);
+ },
+ );
+ }
+ }
+
+ context.test(
+ 'delivers pending slot signals after removal and after observer delivery',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals(
+ 'MutationObserver',
+ ) as unknown as typeof globalThis.MutationObserver;
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const slot = document.createElement('slot');
+ root.append(slot);
+ const calls: string[] = [];
+ const observer = new MutationObserver(() => calls.push('observer'));
+ fixture.cleanup(() => observer.disconnect());
+ observer.observe(host, { childList: true });
+ slot.addEventListener('slotchange', () => calls.push('slotchange'));
+ host.append(document.createElement('span'));
+ slot.remove();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(calls).toEqual(['observer', 'slotchange']);
+ expect(slot.assignedNodes()).toEqual([]);
+ },
+ );
+
+ context.test('orders distinct slot signals by their first assignment change', async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const MutationObserver = fixture.globals(
+ 'MutationObserver',
+ ) as unknown as typeof globalThis.MutationObserver;
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const first = document.createElement('slot'),
+ second = document.createElement('slot');
+ first.name = 'first';
+ second.name = 'second';
+ root.append(first, second);
+ const calls: string[] = [];
+ const observer = new MutationObserver(() => calls.push('observer'));
+ fixture.cleanup(() => observer.disconnect());
+ observer.observe(host, { childList: true });
+ first.addEventListener('slotchange', () => calls.push('first'));
+ second.addEventListener('slotchange', () => calls.push('second'));
+ for (const name of ['second', 'first', 'second']) {
+ const node = document.createElement('span');
+ node.slot = name;
+ host.append(node);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(calls).toEqual(['observer', 'second', 'first']);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/shadow/slots/slotchange.ts b/packages/web-platform-tests/src/compatibility/shadow/slots/slotchange.ts
new file mode 100644
index 0000000..b041999
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/shadow/slots/slotchange.ts
@@ -0,0 +1,49 @@
+import type { CompatibilityContext } from '../../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#signal-a-slot-change';
+export const requires = ['shadow.slots.named', 'events.dispatch.target'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('coalesces slotchange at a microtask checkpoint', async ({ document }) => {
+ const host = document.createElement('div');
+ const root = host.attachShadow({ mode: 'open' });
+ const slot = document.createElement('slot');
+ root.append(slot);
+ await Promise.resolve();
+ let count = 0;
+ slot.addEventListener('slotchange', (event) => {
+ count++;
+ expect(event.bubbles).toBe(true);
+ expect(event.composed).toBe(false);
+ expect(event.target).toBe(slot);
+ });
+ host.append(document.createTextNode('one'));
+ host.append(document.createTextNode('two'));
+ expect(count).toBe(0);
+ await Promise.resolve();
+ expect(count).toBe(1);
+ });
+
+ context.test(
+ 'coalesces assignment changes into one asynchronous bubbling event',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open' }),
+ slot = document.createElement('slot');
+ root.append(slot);
+ let count = 0;
+ const targets: unknown[] = [];
+ root.addEventListener('slotchange', (e) => {
+ count++;
+ targets.push(e.target);
+ expect(e.composed).toBe(false);
+ });
+ host.append(document.createElement('i'), document.createTextNode('x'));
+ expect(count).toBe(0);
+ await new Promise((resolve) => queueMicrotask(resolve));
+ expect(count).toBe(1);
+ expect(targets).toEqual([slot]);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/style-fixture.ts b/packages/web-platform-tests/src/compatibility/style-fixture.ts
new file mode 100644
index 0000000..fdbe0e6
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style-fixture.ts
@@ -0,0 +1,21 @@
+import type { DOMFixture } from './context.ts';
+import type { DOMElement } from './contracts/nodes.ts';
+
+/** Connect markup and a stylesheet using only the consumer's DOM and style bindings. */
+export function createStyleFixture(
+ fixture: DOMFixture,
+ { markup, css }: { markup: string; css: string },
+): {
+ document: Document;
+ root: HTMLDivElement;
+ get: typeof getComputedStyle;
+} {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div');
+ const style = document.createElement('style');
+ root.innerHTML = markup;
+ style.textContent = css;
+ fixture.connect(style as unknown as DOMElement);
+ fixture.connect(root as unknown as DOMElement);
+ return { document, root, get: fixture.globals('getComputedStyle') as typeof getComputedStyle };
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/attribute-synchronization.ts b/packages/web-platform-tests/src/compatibility/style/attribute-synchronization.ts
new file mode 100644
index 0000000..932f8ec
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/attribute-synchronization.ts
@@ -0,0 +1,41 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://drafts.csswg.org/cssom/#dom-elementcssinlinestyle-style';
+export const requires = ['style.inline', 'element.attributes'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('updates style attribute from declaration mutations', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.style.color = 'red';
+ expect(e.getAttribute('style')).toBe('color: red;');
+ e.style.removeProperty('color');
+ expect(e.getAttribute('style')).toBe('');
+ });
+ context.test(
+ 'updates retained declarations from attribute replacement and removal',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div'),
+ s = e.style;
+ e.setAttribute('style', 'color: red; display: block');
+ expect(s.color).toBe('red');
+ expect(s.display).toBe('block');
+ e.setAttribute('style', 'color: blue');
+ expect(s.color).toBe('blue');
+ expect(s.display).toBe('');
+ e.removeAttribute('style');
+ expect(s.length).toBe(0);
+ expect(e.style).toBe(s);
+ },
+ );
+ context.test('replaces all declarations when cssText is assigned', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ e.style.cssText = 'color:red; display:block';
+ e.style.cssText = 'visibility: hidden';
+ expect(e.style.color).toBe('');
+ expect(e.style.length).toBe(1);
+ expect(e.getAttribute('style')).toBe('visibility: hidden;');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/cascade.ts b/packages/web-platform-tests/src/compatibility/style/cascade.ts
new file mode 100644
index 0000000..75be18e
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/cascade.ts
@@ -0,0 +1,89 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://drafts.csswg.org/css-cascade/#cascade';
+export const requires = ['style.computed'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('uses inheritance, initial and unset independently of layout', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle,
+ p = document.createElement('div'),
+ e = document.createElement('i');
+ p.style.color = 'rgb(1, 2, 3)';
+ p.style.visibility = 'hidden';
+ p.append(e);
+ fixture.connect(p as unknown as DOMElementContract);
+ e.style.color = 'inherit';
+ expect(get(e).color).toBe('rgb(1, 2, 3)');
+ e.style.visibility = 'initial';
+ expect(get(e).visibility).toBe('visible');
+ e.style.visibility = 'unset';
+ expect(get(e).visibility).toBe('hidden');
+ });
+ context.test('honors inline important over stylesheet important', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle,
+ s = document.createElement('style'),
+ e = document.createElement('i');
+ e.id = 'cascade';
+ s.textContent = '#cascade {color:rgb(1, 1, 1)!important}';
+ fixture.connect(s as unknown as DOMElementContract);
+ fixture.connect(e as unknown as DOMElementContract);
+ e.style.setProperty('color', 'rgb(2, 2, 2)', 'important');
+ expect(get(e).color).toBe('rgb(2, 2, 2)');
+ });
+ context.test(
+ 'resolves custom property cycles to a fallback without changing inline declarations',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle,
+ e = document.createElement('div');
+ fixture.connect(e as unknown as DOMElementContract);
+ e.style.setProperty('--a', 'var(--b)');
+ e.style.setProperty('--b', 'var(--a)');
+ e.style.color = 'var(--a, rgb(3, 4, 5))';
+ expect(get(e).color).toBe('rgb(3, 4, 5)');
+ expect(e.style.getPropertyValue('--a')).toBe('var(--b)');
+ },
+ );
+ context.test('updates cascade after rule removal and style element removal', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle,
+ p = document.createElement('div'),
+ e = document.createElement('i'),
+ s = document.createElement('style');
+ p.style.color = 'rgb(9, 9, 9)';
+ e.className = 'cascade';
+ p.append(e);
+ s.textContent = '.cascade {color:rgb(1, 1, 1)} .cascade {color:rgb(2, 2, 2)}';
+ fixture.connect(p as unknown as DOMElementContract);
+ fixture.connect(s as unknown as DOMElementContract);
+ expect(get(e).color).toBe('rgb(2, 2, 2)');
+ s.sheet!.deleteRule(1);
+ expect(get(e).color).toBe('rgb(1, 1, 1)');
+ s.remove();
+ expect(get(e).color).toBe('rgb(9, 9, 9)');
+ });
+ context.test(
+ 'scopes shadow styles and inherits host values into the shadow tree',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle,
+ host = document.createElement('div'),
+ root = host.attachShadow({ mode: 'open' }),
+ e = document.createElement('i'),
+ s = document.createElement('style'),
+ outside = document.createElement('i');
+ host.style.color = 'rgb(1, 2, 3)';
+ root.append(e, s);
+ s.textContent = 'i {visibility:hidden}';
+ fixture.connect(host as unknown as DOMElementContract);
+ fixture.connect(outside as unknown as DOMElementContract);
+ expect(get(e).color).toBe('rgb(1, 2, 3)');
+ expect(get(e).visibility).toBe('hidden');
+ expect(get(outside).visibility).toBe('visible');
+ },
+ ['shadow.roots.attachment'],
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/computed.ts b/packages/web-platform-tests/src/compatibility/style/computed.ts
new file mode 100644
index 0000000..dc8960e
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/computed.ts
@@ -0,0 +1,69 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://drafts.csswg.org/cssom/#dom-window-getcomputedstyle';
+export const requires = ['style.inline', 'style.stylesheets', 'style.customProperties'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('computes layout independent inherited properties and remains live', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ fixture.connect(p as unknown as DOMElementContract);
+ p.style.color = 'rgb(1, 2, 3)';
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ const s = get(e);
+ expect(s.color).toBe('rgb(1, 2, 3)');
+ p.style.color = 'rgb(4, 5, 6)';
+ expect(s.color).toBe('rgb(4, 5, 6)');
+ e.style.color = 'rgb(7, 8, 9)';
+ expect(s.color).toBe('rgb(7, 8, 9)');
+ });
+ context.test(
+ 'applies specificity, source order, importance and inline declarations',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const style = document.createElement('style'),
+ e = document.createElement('i');
+ e.id = 'test';
+ e.className = 'x';
+ style.textContent =
+ 'i {color:rgb(1, 1, 1)} .x {color:rgb(2, 2, 2)} #test {color:rgb(3, 3, 3)} #test {color:rgb(4, 4, 4)}';
+ fixture.connect(style as unknown as DOMElementContract);
+ fixture.connect(e as unknown as DOMElementContract);
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ expect(get(e).color).toBe('rgb(4, 4, 4)');
+ e.style.color = 'rgb(5, 5, 5)';
+ expect(get(e).color).toBe('rgb(5, 5, 5)');
+ (style.sheet!.cssRules[0] as CSSStyleRule).style.setProperty(
+ 'color',
+ 'rgb(6, 6, 6)',
+ 'important',
+ );
+ expect(get(e).color).toBe('rgb(6, 6, 6)');
+ },
+ );
+ context.test('inherits custom properties and resolves variable fallbacks', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div'),
+ e = document.createElement('i');
+ p.append(e);
+ fixture.connect(p as unknown as DOMElementContract);
+ p.style.setProperty('--ink', 'rgb(1, 2, 3)');
+ e.style.color = 'var(--ink)';
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ expect(get(e).color).toBe('rgb(1, 2, 3)');
+ e.style.color = 'var(--missing, rgb(4, 5, 6))';
+ expect(get(e).color).toBe('rgb(4, 5, 6)');
+ });
+ context.test('returns a readonly computed declaration', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div');
+ fixture.connect(e as unknown as DOMElementContract);
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ expect(() => get(e).setProperty('color', 'red')).toThrow(
+ expect.objectContaining({ name: 'NoModificationAllowedError' }),
+ );
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/constructed-stylesheets.ts b/packages/web-platform-tests/src/compatibility/style/constructed-stylesheets.ts
new file mode 100644
index 0000000..259d9ce
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/constructed-stylesheets.ts
@@ -0,0 +1,149 @@
+import type { CompatibilityContext } from '../context.ts';
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+
+export const spec = 'https://drafts.csswg.org/cssom/#dom-cssstylesheet-replace';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/cssom/CSSStyleSheet-constructable-replace-cssRules.html';
+export const globals = ['CSSStyleSheet', 'getComputedStyle'] as const;
+export const requires = ['style.stylesheets', 'style.computed'] as const;
+
+// Adapted from WPT at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. Authors: Emilio Cobos Álvarez,
+// Mozilla, Rakina Zata Amni. BSD-3-Clause; see ../../../test/wpt/LICENSE
+// and ../../../docs/wpt-css-forms.md for the complete source inventory.
+
+/** Registers constructed stylesheet identity, adoption and mutation cases. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'constructs empty ownerless sheets with explicit media and disabled state',
+ (fixture) => {
+ const Sheet = fixture.globals('CSSStyleSheet') as typeof CSSStyleSheet;
+ const sheet = new Sheet();
+ expect(sheet.ownerNode).toBeNull();
+ expect(sheet.ownerRule).toBeNull();
+ expect(sheet.href).toBeNull();
+ expect(sheet.title).toBeNull();
+ expect(sheet.disabled).toBe(false);
+ expect(sheet.cssRules.length).toBe(0);
+ expect(sheet.media.length).toBe(0);
+ const configured = new Sheet({ disabled: true, media: 'screen, print' });
+ expect(configured.disabled).toBe(true);
+ expect(Array.from(configured.media)).toEqual(['screen', 'print']);
+ },
+ );
+ for (const sync of [true, false]) {
+ context.test(
+ `retains live cssRules across ${sync ? 'replaceSync' : 'replace'} and adoption`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Sheet = fixture.globals('CSSStyleSheet') as typeof CSSStyleSheet;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ const sheet = new Sheet(),
+ rules = sheet.cssRules;
+ const parent = document.createElement('div'),
+ target = document.createElement('i');
+ parent.style.color = 'rgb(1, 2, 3)';
+ target.className = 'constructed-target';
+ parent.append(target);
+ fixture.connect(parent as unknown as DOMElementContract);
+ const previous = [...document.adoptedStyleSheets];
+ fixture.cleanup(() => {
+ document.adoptedStyleSheets = previous;
+ });
+ for (const color of ['red', 'green']) {
+ if (sync)
+ expect(sheet.replaceSync(`.constructed-target {color:${color}}`)).toBeUndefined();
+ else expect(await sheet.replace(`.constructed-target {color:${color}}`)).toBe(sheet);
+ expect(sheet.cssRules).toBe(rules);
+ expect(rules.length).toBe(1);
+ expect((rules[0] as CSSStyleRule).style.color).toBe(color);
+ }
+ document.adoptedStyleSheets.push(sheet);
+ expect(get(target).color).toBe('rgb(0, 128, 0)');
+ expect(document.adoptedStyleSheets.pop()).toBe(sheet);
+ expect(get(target).color).toBe('rgb(1, 2, 3)');
+ },
+ );
+ }
+ context.test('adoption order and disabled toggles immediately change the cascade', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Sheet = fixture.globals('CSSStyleSheet') as typeof CSSStyleSheet;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ const a = new Sheet(),
+ b = new Sheet(),
+ target = document.createElement('div');
+ target.className = 'constructed-order';
+ a.replaceSync('.constructed-order {color:red}');
+ b.replaceSync('.constructed-order {color:blue}');
+ fixture.connect(target as unknown as DOMElementContract);
+ const previous = [...document.adoptedStyleSheets];
+ fixture.cleanup(() => {
+ document.adoptedStyleSheets = previous;
+ });
+ document.adoptedStyleSheets = [a, b];
+ expect(get(target).color).toBe('rgb(0, 0, 255)');
+ b.disabled = true;
+ expect(get(target).color).toBe('rgb(255, 0, 0)');
+ b.disabled = false;
+ expect(get(target).color).toBe('rgb(0, 0, 255)');
+ document.adoptedStyleSheets = [b, a];
+ expect(get(target).color).toBe('rgb(255, 0, 0)');
+ });
+ context.test('rejects replacement and adoption of a style-element sheet', async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const style = document.createElement('style');
+ style.textContent = 'i {color:red}';
+ fixture.connect(style as unknown as DOMElementContract);
+ const sheet = style.sheet!;
+ const previous = [...document.adoptedStyleSheets];
+ fixture.cleanup(() => {
+ document.adoptedStyleSheets = previous;
+ });
+ expect(() => sheet.replaceSync('i {color:blue}')).toThrow(
+ expect.objectContaining({ name: 'NotAllowedError' }),
+ );
+ await expect(sheet.replace('i {color:blue}')).rejects.toMatchObject({
+ name: 'NotAllowedError',
+ });
+ expect(() => {
+ document.adoptedStyleSheets = [sheet];
+ }).toThrow(expect.objectContaining({ name: 'NotAllowedError' }));
+ expect([...document.adoptedStyleSheets]).toEqual(previous);
+ expect((sheet.cssRules[0] as CSSStyleRule).style.color).toBe('red');
+ });
+ context.test(
+ 'updates all shadow adopters while retaining separate adoption lists',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Sheet = fixture.globals('CSSStyleSheet') as typeof CSSStyleSheet;
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ const sheet = new Sheet();
+ const roots = [0, 1].map(() => {
+ const host = document.createElement('div');
+ host.style.color = 'rgb(1, 2, 3)';
+ const root = host.attachShadow({ mode: 'open' });
+ root.append(document.createElement('i'));
+ root.adoptedStyleSheets = [sheet];
+ fixture.connect(host as unknown as DOMElementContract);
+ return root;
+ });
+ sheet.replaceSync('i {color:red}');
+ expect(roots.map((root) => get(root.firstElementChild!).color)).toEqual([
+ 'rgb(255, 0, 0)',
+ 'rgb(255, 0, 0)',
+ ]);
+ sheet.replaceSync('i {color:blue}');
+ expect(roots.map((root) => get(root.firstElementChild!).color)).toEqual([
+ 'rgb(0, 0, 255)',
+ 'rgb(0, 0, 255)',
+ ]);
+ roots[0]!.adoptedStyleSheets = [];
+ expect(roots.map((root) => get(root.firstElementChild!).color)).toEqual([
+ 'rgb(1, 2, 3)',
+ 'rgb(0, 0, 255)',
+ ]);
+ },
+ ['shadow.roots.attachment'],
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/control-selectors.ts b/packages/web-platform-tests/src/compatibility/style/control-selectors.ts
new file mode 100644
index 0000000..5657392
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/control-selectors.ts
@@ -0,0 +1,162 @@
+// Adapted from WPT checked.html, valid-invalid.html and disabled.html at
+// b719612f963a05ece9c89a7dd7598c1c59618874. Copyright web-platform-tests contributors;
+// Denis Ah-Kang. BSD-3-Clause; see ../../../test/wpt/LICENSE and ../../../docs/wpt-state-styles.md.
+import type { CompatibilityContext } from '../context.ts';
+import { createStyleFixture } from '../style-fixture.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/semantics-other.html#pseudo-classes';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/html/semantics/selectors/pseudo-classes/valid-invalid.html';
+export const requires = [
+ 'element.selectors',
+ 'parsing.fragments',
+ 'style.computed',
+ 'elements.input.checkedness',
+ 'elements.input.validation',
+] as const;
+
+export const globals = ['getComputedStyle'] as const;
+/** Registers control state, selector and computed style interactions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'checked follows live state rather than the default attribute and changes with input type',
+ (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')!;
+ expect(target.matches(':checked')).toBe(true);
+ expect(get(target).color).toBe('rgb(4, 5, 6)');
+ target.checked = false;
+ expect(target.hasAttribute('checked')).toBe(true);
+ expect(target.matches(':checked')).toBe(false);
+ expect(get(target).color).toBe('rgb(1, 2, 3)');
+ target.checked = true;
+ target.type = 'text';
+ expect(target.matches(':checked')).toBe(false);
+ expect(get(target).color).toBe('rgb(1, 2, 3)');
+ target.type = 'checkbox';
+ expect(target.matches(':checked')).toBe(true);
+ },
+ );
+ context.test(
+ 'option checked matching follows selectedness and select reset',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: 'option {color:rgb(1,2,3)} option:checked {color:rgb(4,5,6)}',
+ });
+ const form = root.querySelector('form')!,
+ select = root.querySelector('select')!;
+ const first = select.options[0]!,
+ second = select.options[1]!;
+ second.selected = true;
+ expect(first.matches(':checked')).toBe(false);
+ expect(second.matches(':checked')).toBe(true);
+ expect(get(first).color).toBe('rgb(1, 2, 3)');
+ expect(get(second).color).toBe('rgb(4, 5, 6)');
+ form.reset();
+ expect(first.matches(':checked')).toBe(true);
+ expect(second.matches(':checked')).toBe(false);
+ expect(get(first).color).toBe('rgb(4, 5, 6)');
+ },
+ ['elements.select.selection', 'elements.option.selectedness', 'elements.form.reset'],
+ );
+ context.test(
+ 'required, pattern and custom validity invalidate selectors and retained styles',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: ' ',
+ css: 'input:invalid {color:rgb(1,2,3)} input:valid {color:rgb(4,5,6)}',
+ });
+ const target = root.querySelector('input')!,
+ retained = get(target);
+ expect(target.matches(':invalid')).toBe(true);
+ expect(retained.color).toBe('rgb(1, 2, 3)');
+ target.value = 'AB';
+ expect(target.matches(':valid')).toBe(true);
+ expect(retained.color).toBe('rgb(4, 5, 6)');
+ target.setCustomValidity('Rejected');
+ expect(target.matches(':invalid')).toBe(true);
+ expect(retained.color).toBe('rgb(1, 2, 3)');
+ target.setCustomValidity('');
+ target.value = 'bad';
+ expect(target.matches(':invalid')).toBe(true);
+ target.removeAttribute('pattern');
+ expect(target.matches(':valid')).toBe(true);
+ },
+ );
+ for (const tag of ['form', 'fieldset'] as const) {
+ context.test(
+ `${tag} aggregate validity updates on insertion, type changes and removal`,
+ (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, {
+ markup: `<${tag}>${tag}>`,
+ css: `${tag}:valid {color:rgb(1,2,3)} ${tag}:invalid {color:rgb(4,5,6)}`,
+ });
+ const parent = root.querySelector(tag)!;
+ expect(parent.matches(':valid')).toBe(true);
+ const target = document.createElement('input');
+ target.type = 'text';
+ target.min = '8';
+ target.value = '4';
+ parent.append(target);
+ expect(parent.matches(':valid')).toBe(true);
+ target.type = 'number';
+ expect(parent.matches(':invalid')).toBe(true);
+ expect(get(parent).color).toBe('rgb(4, 5, 6)');
+ target.value = '9';
+ expect(parent.matches(':valid')).toBe(true);
+ expect(get(parent).color).toBe('rgb(1, 2, 3)');
+ target.value = '4';
+ target.remove();
+ expect(parent.matches(':valid')).toBe(true);
+ },
+ ['elements.form.validation', 'elements.input.numericValue'],
+ );
+ }
+ context.test(
+ 'disabled fieldsets style descendants but exempt the first legend subtree',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup:
+ ' ',
+ css: 'input:enabled {color:rgb(1,2,3)} input:disabled {color:rgb(4,5,6)}',
+ });
+ const fieldset = root.querySelector('fieldset')!,
+ exempt = root.querySelector('#exempt')!,
+ blocked = root.querySelector('#blocked')!;
+ expect(blocked.disabled).toBe(false);
+ expect(blocked.matches(':disabled')).toBe(true);
+ expect(get(blocked).color).toBe('rgb(4, 5, 6)');
+ expect(exempt.matches(':enabled')).toBe(true);
+ expect(get(exempt).color).toBe('rgb(1, 2, 3)');
+ fieldset.disabled = false;
+ expect(blocked.matches(':enabled')).toBe(true);
+ expect(get(blocked).color).toBe('rgb(1, 2, 3)');
+ fieldset.disabled = true;
+ fieldset.querySelector('legend')!.append(blocked);
+ expect(blocked.matches(':enabled')).toBe(true);
+ },
+ ['elements.fieldset.disabled'],
+ );
+ context.test(
+ 'disabled and readonly controls are excluded from valid and invalid matching',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: ' ',
+ css: 'input {color:rgb(1,2,3)} input:invalid {color:rgb(4,5,6)}',
+ });
+ const target = root.querySelector('input')!;
+ for (const property of ['disabled', 'readOnly'] as const) {
+ target[property] = true;
+ expect(target.matches(':valid, :invalid')).toBe(false);
+ expect(get(target).color).toBe('rgb(1, 2, 3)');
+ target[property] = false;
+ expect(target.matches(':invalid')).toBe(true);
+ expect(get(target).color).toBe('rgb(4, 5, 6)');
+ }
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/custom-properties.ts b/packages/web-platform-tests/src/compatibility/style/custom-properties.ts
new file mode 100644
index 0000000..be6a2e5
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/custom-properties.ts
@@ -0,0 +1,27 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-setproperty';
+export const requires = ['style.inline'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('preserves case sensitive custom properties and priorities', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.createElement('div').style;
+ s.setProperty('--A', 'one');
+ s.setProperty('--a', 'two', 'important');
+ expect(s.getPropertyValue('--A')).toBe('one');
+ expect(s.getPropertyValue('--a')).toBe('two');
+ expect(s.getPropertyPriority('--a')).toBe('important');
+ expect(s.removeProperty('--A')).toBe('one');
+ expect(s.getPropertyValue('--a')).toBe('two');
+ });
+ context.test('round trips variable references without resolving inline values', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.createElement('div').style;
+ s.setProperty('--size', '12px');
+ s.setProperty('width', 'var(--size, 1px)');
+ expect(s.getPropertyValue('width')).toBe('var(--size, 1px)');
+ s.setProperty('--size', '20px');
+ expect(s.getPropertyValue('width')).toBe('var(--size, 1px)');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/focus-state.ts b/packages/web-platform-tests/src/compatibility/style/focus-state.ts
new file mode 100644
index 0000000..48717ed
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/focus-state.ts
@@ -0,0 +1,131 @@
+// Adapted from WPT focus-within-011.html, revision b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright web-platform-tests contributors; Ethan Lin and Mozilla. BSD-3-Clause.
+// See ../../../test/wpt/LICENSE 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/#focus-within-pseudo';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/focus-within-011.html';
+export const requires = [
+ 'element.selectors',
+ 'parsing.fragments',
+ 'style.computed',
+ 'focus.transitions',
+] as const;
+export const globals = ['getComputedStyle'] as const;
+
+/** Registers selector matching and style invalidation across focus transitions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ const markup =
+ 'Outside ';
+ const css =
+ 'section, button {color:rgb(1,2,3)} section:focus-within {color:rgb(4,5,6)} button:focus {color:rgb(7,8,9)}';
+ context.test(
+ 'focus matches the target while focus-within includes its ancestors and itself',
+ (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, { markup: markup, css: css });
+ const target = root.querySelector('#first')!;
+ const parent = root.querySelector('section')!;
+ expect(target.matches(':focus')).toBe(false);
+ expect(parent.matches(':focus-within')).toBe(false);
+ const retained = get(target);
+ expect(retained.color).toBe('rgb(1, 2, 3)');
+ target.focus();
+ expect(document.activeElement).toBe(target);
+ expect(document.querySelector(':focus')).toBe(target);
+ expect(target.matches(':focus:focus-within')).toBe(true);
+ expect(parent.matches(':focus')).toBe(false);
+ expect(parent.matches(':focus-within')).toBe(true);
+ expect(retained.color).toBe('rgb(7, 8, 9)');
+ expect(get(parent).color).toBe('rgb(4, 5, 6)');
+ },
+ );
+ context.test(
+ 'moving focus between siblings invalidates both targets and retains ancestor state',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, { markup: markup, css: css });
+ const first = root.querySelector('#first')!;
+ const second = root.querySelector('#second')!;
+ const parent = root.querySelector('section')!;
+ first.focus();
+ second.focus();
+ expect(first.matches(':focus-within')).toBe(false);
+ expect(get(first).color).toBe('rgb(1, 2, 3)');
+ expect(second.matches(':focus')).toBe(true);
+ expect(get(second).color).toBe('rgb(7, 8, 9)');
+ expect(get(parent).color).toBe('rgb(4, 5, 6)');
+ root.querySelector('#outside')!.focus();
+ expect(parent.matches(':focus-within')).toBe(false);
+ expect(get(parent).color).toBe('rgb(1, 2, 3)');
+ },
+ );
+ context.test(
+ 'blur clears matching and retained computed styles without changing attributes',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, { markup: markup, css: css });
+ const target = root.querySelector('#first')!;
+ const original = target.outerHTML;
+ target.focus();
+ const computed = get(target);
+ target.blur();
+ expect(target.matches(':focus, :focus-within')).toBe(false);
+ expect(computed.color).toBe('rgb(1, 2, 3)');
+ expect(target.outerHTML).toBe(original);
+ },
+ ['parsing.serialization'],
+ );
+ context.test(
+ 'focus listeners observe the new matching state and computed declaration',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, { markup: markup, css: css });
+ const target = root.querySelector('#first')!;
+ const seen: unknown[] = [];
+ target.addEventListener('focus', () =>
+ seen.push(target.matches(':focus'), get(target).color),
+ );
+ target.focus();
+ expect(seen).toEqual([true, 'rgb(7, 8, 9)']);
+ },
+ );
+ context.test(
+ 'removing a focused descendant clears ancestor selector and computed state',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, { markup: markup, css: css });
+ const target = root.querySelector('#first')!;
+ const parent = root.querySelector('section')!;
+ target.focus();
+ target.remove();
+ expect(target.matches(':focus')).toBe(false);
+ expect(parent.matches(':focus-within')).toBe(false);
+ expect(get(parent).color).toBe('rgb(1, 2, 3)');
+ },
+ );
+ for (const mode of ['open', 'closed'] as const) {
+ context.test(
+ `focus matching crosses a ${mode} shadow host without exposing internal queries`,
+ (fixture) => {
+ const { document, root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: '#host {color:rgb(1,2,3)} #host:focus {color:rgb(4,5,6)}',
+ });
+ const host = root.querySelector('#host')!;
+ const shadow = host.attachShadow({ mode });
+ const target = document.createElement('input');
+ shadow.append(target);
+ target.focus();
+ expect(target.matches(':focus:focus-within')).toBe(true);
+ expect(host.matches(':focus:focus-within')).toBe(true);
+ expect(root.matches(':focus-within')).toBe(true);
+ expect(document.querySelector(':focus')).toBe(host);
+ expect(shadow.querySelector(':focus')).toBe(target);
+ expect(get(host).color).toBe('rgb(4, 5, 6)');
+ target.blur();
+ expect(host.matches(':focus, :focus-within')).toBe(false);
+ expect(get(host).color).toBe('rgb(1, 2, 3)');
+ },
+ ['shadow.focus.retargeting'],
+ );
+ }
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/inline.ts b/packages/web-platform-tests/src/compatibility/style/inline.ts
new file mode 100644
index 0000000..bb13adb
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/inline.ts
@@ -0,0 +1,47 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://drafts.csswg.org/cssom/#the-cssstyledeclaration-interface';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('starts empty and keeps declaration identity', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('div'),
+ s = e.style;
+ expect(s.length).toBe(0);
+ expect(s.cssText).toBe('');
+ expect(s.item(0)).toBe('');
+ expect(s.getPropertyValue('color')).toBe('');
+ expect(s.getPropertyPriority('color')).toBe('');
+ s.color = 'red';
+ expect(e.style).toBe(s);
+ expect(s.getPropertyValue('color')).toBe('red');
+ });
+ context.test('sets, replaces and removes properties with priorities', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.createElement('div').style;
+ s.setProperty('color', 'red', 'important');
+ s.setProperty('display', 'block');
+ expect(s.length).toBe(2);
+ expect(s.item(0)).toBe('color');
+ expect(s.getPropertyPriority('color')).toBe('important');
+ s.setProperty('color', 'blue');
+ expect(s.item(0)).toBe('color');
+ expect(s.getPropertyPriority('color')).toBe('');
+ expect(s.removeProperty('color')).toBe('blue');
+ expect(s.item(0)).toBe('display');
+ expect(s.removeProperty('missing')).toBe('');
+ });
+ context.test('ignores invalid declarations and invalid priorities atomically', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.createElement('div').style;
+ s.color = 'red';
+ s.color = 'not-a-color';
+ expect(s.color).toBe('red');
+ s.setProperty('color', 'blue', 'urgent');
+ expect(s.color).toBe('red');
+ s.setProperty('unknown-property', 'x');
+ expect(s.length).toBe(1);
+ s.setProperty('color', '');
+ expect(s.length).toBe(0);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/layers.ts b/packages/web-platform-tests/src/compatibility/style/layers.ts
new file mode 100644
index 0000000..a6161d8
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/layers.ts
@@ -0,0 +1,58 @@
+import type { CompatibilityContext } from '../context.ts';
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+
+export const spec = 'https://drafts.csswg.org/css-cascade-5/#layering';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/css-cascade/layer-basic.html';
+export const globals = ['getComputedStyle'] as const;
+export const requires = ['style.computed'] as const;
+
+// Adapted from layer-basic.html and layer-important.html in WPT at
+// b719612f963a05ece9c89a7dd7598c1c59618874. Copyright © web-platform-tests
+// contributors. Authors: Antti Koivisto, Romain Menke. BSD-3-Clause;
+// see ../../../test/wpt/LICENSE and ../../../docs/wpt-css-forms.md.
+
+/** Registers layout-independent cascade layer ordering cases. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ const cases = [
+ [
+ 'unlayered normal rules outrank higher-specificity layered rules',
+ '.layer-target {color:green} @layer {#layer-target {color:red}}',
+ ],
+ [
+ 'later anonymous layers win independently of specificity',
+ '@layer {#layer-target {color:red}} @layer {.layer-target {color:green}}',
+ ],
+ [
+ 'reopening a named layer preserves its original order',
+ '@layer a {.layer-target {color:red}} @layer b {.layer-target {color:green}} @layer a {.layer-target {color:red}}',
+ ],
+ [
+ 'statement order controls layers whose blocks appear in reverse order',
+ '@layer a, b; @layer b {.layer-target {color:green}} @layer a {.layer-target {color:red}}',
+ ],
+ [
+ 'unlayered declarations inside a layer outrank its nested layers',
+ '@layer a {.layer-target {color:green} @layer inner {#layer-target {color:red}}}',
+ ],
+ [
+ 'important declarations reverse layer order and outrank unlayered important rules',
+ '@layer a, b; @layer b {#layer-target {color:red!important}} @layer a {.layer-target {color:green!important}} #layer-target {color:red!important}',
+ ],
+ ] as const;
+ for (const [name, css] of cases) {
+ context.test(name, (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const style = document.createElement('style'),
+ target = document.createElement('div');
+ target.id = 'layer-target';
+ target.className = 'layer-target';
+ style.textContent = css;
+ fixture.connect(style as unknown as DOMElementContract);
+ fixture.connect(target as unknown as DOMElementContract);
+ const get = fixture.globals('getComputedStyle') as typeof getComputedStyle;
+ expect(get(target).color).toBe('rgb(0, 128, 0)');
+ });
+ }
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/mutation-observation.ts b/packages/web-platform-tests/src/compatibility/style/mutation-observation.ts
new file mode 100644
index 0000000..b658a4f
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/mutation-observation.ts
@@ -0,0 +1,101 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://drafts.csswg.org/cssom/#update-style-attribute';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/cssom/MutationObserver-style.html';
+export const globals = ['MutationObserver'] as const;
+export const requires = [
+ 'style.attributeSynchronization',
+ 'mutationObserver.records.attributes',
+ 'mutationObserver.microtaskDelivery',
+ 'mutationObserver.oldValue',
+ 'mutationObserver.filtering',
+ 'mutationObserver.takeRecords',
+ 'mutationObserver.disconnect',
+] as const;
+
+// Adapted and extended from WPT at b719612f963a05ece9c89a7dd7598c1c59618874.
+// Copyright © web-platform-tests contributors. BSD-3-Clause;
+// see ../../../test/wpt/LICENSE and ../../../docs/wpt-css-forms.md.
+
+/** Registers interactions between CSS declaration mutations and DOM observers. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ for (const cssText of [false, true]) {
+ context.test(
+ `${cssText ? 'repeated cssText assignments deliver' : 'repeated same-value property assignments suppress'} additional observations`,
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Observer = fixture.globals('MutationObserver') as unknown as typeof MutationObserver;
+ const target = document.createElement('div');
+ const records: MutationRecord[] = [];
+ const observer = new Observer((batch) => records.push(...batch));
+ fixture.cleanup(() => observer.disconnect());
+ observer.observe(target, { attributes: true, attributeOldValue: true });
+ for (let i = 0; i < 2; i++) {
+ if (cssText) target.style.cssText = 'color: red';
+ else target.style.color = 'red';
+ expect(records.length).toBe(i === 0 ? 0 : 1);
+ await Promise.resolve();
+ expect(records.length).toBe(cssText ? i + 1 : 1);
+ }
+ expect(records.map((record) => record.attributeName)).toEqual(
+ cssText ? ['style', 'style'] : ['style'],
+ );
+ expect(records.map((record) => record.oldValue)).toEqual(
+ cssText ? [null, 'color: red;'] : [null],
+ );
+ },
+ );
+ }
+ context.test(
+ 'batches declaration changes with ordered old serialized values',
+ async (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Observer = fixture.globals('MutationObserver') as unknown as typeof MutationObserver;
+ const target = document.createElement('div');
+ const batches: MutationRecord[][] = [];
+ const observer = new Observer((records) => batches.push(records));
+ fixture.cleanup(() => observer.disconnect());
+ observer.observe(target, {
+ attributes: true,
+ attributeOldValue: true,
+ attributeFilter: ['style'],
+ });
+ target.style.setProperty('color', 'red');
+ target.style.setProperty('color', 'blue', 'important');
+ target.style.removeProperty('color');
+ expect(batches.length).toBe(0);
+ await Promise.resolve();
+ expect(batches.length).toBe(1);
+ expect(batches[0]!.map((record) => record.oldValue)).toEqual([
+ null,
+ 'color: red;',
+ 'color: blue !important;',
+ ]);
+ for (const record of batches[0]!) {
+ expect(record.target).toBe(target);
+ expect(record.type).toBe('attributes');
+ expect(record.attributeName).toBe('style');
+ expect(record.attributeNamespace).toBeNull();
+ }
+ },
+ );
+ context.test(
+ 'invalid declarations and removal of missing properties create no records',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const Observer = fixture.globals('MutationObserver') as unknown as typeof MutationObserver;
+ const target = document.createElement('div');
+ target.style.color = 'red';
+ const observer = new Observer(() => {});
+ fixture.cleanup(() => observer.disconnect());
+ observer.observe(target, { attributes: true });
+ target.style.color = 'not-a-color';
+ target.style.setProperty('color', 'blue', 'invalid-priority');
+ target.style.removeProperty('visibility');
+ expect(observer.takeRecords()).toEqual([]);
+ expect(target.style.color).toBe('red');
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/parsing.ts b/packages/web-platform-tests/src/compatibility/style/parsing.ts
new file mode 100644
index 0000000..f3e059d
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/parsing.ts
@@ -0,0 +1,36 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://drafts.csswg.org/cssom/#serialize-a-css-declaration-block';
+export const requires = ['style.inline'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('recovers from invalid values and resolves duplicate priority', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.createElement('div').style;
+ s.cssText = 'color:red; invalid: x; color:blue!important; color:green; display: block';
+ expect(s.color).toBe('blue');
+ expect(s.getPropertyPriority('color')).toBe('important');
+ expect(s.display).toBe('block');
+ expect(s.length).toBe(2);
+ });
+ context.test('expands and serializes shorthands', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.createElement('div').style;
+ s.margin = '1px 2px';
+ expect(s.marginTop).toBe('1px');
+ expect(s.marginRight).toBe('2px');
+ expect(s.marginBottom).toBe('1px');
+ expect(s.marginLeft).toBe('2px');
+ expect(s.getPropertyValue('margin')).toBe('1px 2px');
+ s.removeProperty('margin');
+ expect(s.marginTop).toBe('');
+ });
+ context.test('handles comments, quoted separators and case insensitive names', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const s = document.createElement('div').style;
+ s.cssText = 'COLOR: /* comment */ red; font-family: "a;b";';
+ expect(s.color).toBe('red');
+ expect(s.fontFamily).toBe('"a;b"');
+ expect(s.cssText).toContain('color: red;');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/relational-selectors.ts b/packages/web-platform-tests/src/compatibility/style/relational-selectors.ts
new file mode 100644
index 0000000..6a3be31
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/relational-selectors.ts
@@ -0,0 +1,87 @@
+// 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 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/#relational';
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/css/selectors/invalidation/user-action-pseudo-classes-in-has.html';
+export const requires = [
+ 'element.selectors',
+ 'parsing.fragments',
+ 'style.computed',
+ 'style.focusState',
+] as const;
+export const globals = ['getComputedStyle'] as const;
+/** Registers relational matching and invalidation without geometry assertions. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('has focus invalidates an ancestor and a different descendant', (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: '',
+ css: 'section, i {color:rgb(1,2,3)} section:has(:focus) {color:rgb(4,5,6)} section:has(:focus) i {color:rgb(7,8,9)}',
+ });
+ const section = root.querySelector('section')!,
+ button = root.querySelector('button')!,
+ other = root.querySelector('i')!;
+ expect(section.matches(':has(:focus)')).toBe(false);
+ button.focus();
+ expect(section.matches(':has(:focus)')).toBe(true);
+ expect(get(section).color).toBe('rgb(4, 5, 6)');
+ expect(get(other).color).toBe('rgb(7, 8, 9)');
+ button.remove();
+ expect(section.matches(':has(:focus)')).toBe(false);
+ expect(get(other).color).toBe('rgb(1, 2, 3)');
+ });
+ context.test(
+ 'relative child and sibling selectors update after class changes and moves',
+ (fixture) => {
+ const { root, get } = createStyleFixture(fixture, {
+ markup: ' ',
+ css: 'section {color:rgb(1,2,3)} section:has(> .marked) {color:rgb(4,5,6)} section:has(+ .marked) {color:rgb(7,8,9)}',
+ });
+ const section = root.querySelector('section')!,
+ child = root.querySelector('i')!,
+ sibling = root.querySelector('b')!;
+ child.className = 'marked';
+ expect(root.querySelector(':has(> .marked)')).toBe(section);
+ expect(get(section).color).toBe('rgb(4, 5, 6)');
+ child.className = '';
+ expect(get(section).color).toBe('rgb(1, 2, 3)');
+ sibling.className = 'marked';
+ expect(section.matches(':has(+ .marked)')).toBe(true);
+ expect(get(section).color).toBe('rgb(7, 8, 9)');
+ root.prepend(sibling);
+ expect(section.matches(':has(+ .marked)')).toBe(false);
+ expect(get(section).color).toBe('rgb(1, 2, 3)');
+ },
+ );
+ context.test(
+ 'relational queries return static snapshots while fresh queries reflect changes',
+ (fixture) => {
+ const { root } = createStyleFixture(fixture, {
+ markup: '',
+ css: '',
+ });
+ const sections = root.querySelectorAll('section');
+ const selected = root.querySelectorAll('section:has(.marked)');
+ sections[1]!.append(sections[0]!.firstChild!);
+ expect(Array.from(selected)).toEqual([sections[0]]);
+ expect(Array.from(root.querySelectorAll('section:has(.marked)'))).toEqual([sections[1]]);
+ },
+ );
+ context.test(
+ 'nested has and empty relative selector lists reject with SyntaxError',
+ (fixture) => {
+ const { root } = createStyleFixture(fixture, { markup: '', css: '' });
+ for (const selector of [':has(:has(i))', ':has()', ':has(>)']) {
+ expect(() => root.querySelector(selector)).toThrow(
+ expect.objectContaining({ name: 'SyntaxError' }),
+ );
+ expect(() => root.matches(selector)).toThrow(
+ expect.objectContaining({ name: 'SyntaxError' }),
+ );
+ }
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/style/stylesheets.ts b/packages/web-platform-tests/src/compatibility/style/stylesheets.ts
new file mode 100644
index 0000000..82e3c7a
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/style/stylesheets.ts
@@ -0,0 +1,49 @@
+import type { DOMElement as DOMElementContract } from '../contracts/nodes.ts';
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://drafts.csswg.org/cssom/#the-cssstylesheet-interface';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('exposes live rule lists and inserts/deletes at explicit indices', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('style');
+ e.textContent = 'i { color: red }';
+ fixture.connect(e as unknown as DOMElementContract);
+ const sheet = e.sheet!,
+ rules = sheet.cssRules;
+ expect(rules.length).toBe(1);
+ expect(sheet.insertRule('b { color: blue }', 1)).toBe(1);
+ expect(rules.length).toBe(2);
+ expect((rules[1] as CSSStyleRule).selectorText).toBe('b');
+ sheet.deleteRule(0);
+ expect(rules.length).toBe(1);
+ expect((rules[0] as CSSStyleRule).style.color).toBe('blue');
+ });
+ context.test('rejects invalid rules and out of bounds indices without mutation', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('style');
+ fixture.connect(e as unknown as DOMElementContract);
+ const s = e.sheet!;
+ expect(() => s.insertRule('i {}', 1)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ expect(() => s.insertRule('not a rule', 0)).toThrow(
+ expect.objectContaining({ name: 'SyntaxError' }),
+ );
+ expect(() => s.deleteRule(0)).toThrow(expect.objectContaining({ name: 'IndexSizeError' }));
+ expect(s.cssRules.length).toBe(0);
+ });
+ context.test('keeps style rules writable and linked to their parent sheet', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('style');
+ e.textContent = 'i {color:red}';
+ fixture.connect(e as unknown as DOMElementContract);
+ const s = e.sheet!,
+ rule = s.cssRules[0] as CSSStyleRule;
+ expect(rule.parentStyleSheet).toBe(s);
+ rule.style.color = 'blue';
+ rule.selectorText = 'b';
+ expect(rule.cssText).toContain('b');
+ expect(rule.cssText).toContain('blue');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/templates/cloning.ts b/packages/web-platform-tests/src/compatibility/templates/cloning.ts
new file mode 100644
index 0000000..09f39f6
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/templates/cloning.ts
@@ -0,0 +1,40 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/scripting.html#the-template-element';
+const requiredGlobals = ['HTMLTemplateElement', 'Document'] as const;
+export { requiredGlobals as globals };
+export const requires = ['templates.content', 'node.cloning', 'document.importNode'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('clones and imports template contents independently', ({ document, globals }) => {
+ const HTMLTemplateElement = globals('HTMLTemplateElement');
+ const Document = globals('Document');
+ const template = document.createElement('template');
+ template.content.append(document.createTextNode('contents'));
+ const clone = template.cloneNode(true);
+ if (!(clone instanceof HTMLTemplateElement))
+ return expect.fail('Template clone must be a template');
+ expect(clone.content.textContent).toBe('contents');
+ expect(clone.content.firstChild).not.toBe(template.content.firstChild);
+ const other = new Document();
+ const imported = other.importNode(template, true);
+ if (!(imported instanceof HTMLTemplateElement))
+ return expect.fail('Imported template must be a template');
+ expect(imported.ownerDocument).toBe(other);
+ expect(imported.content.ownerDocument).not.toBe(other);
+ expect(imported.content.firstChild?.ownerDocument).toBe(imported.content.ownerDocument);
+ });
+
+ context.test('deep cloning duplicates template content without sharing nodes', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('template');
+ e.innerHTML = 'x ';
+ const shallow = e.cloneNode(false) as HTMLTemplateElement,
+ deep = e.cloneNode(true) as HTMLTemplateElement;
+ expect(shallow.content.childNodes.length).toBe(0);
+ expect(deep.content.firstChild).not.toBe(e.content.firstChild);
+ expect(deep.innerHTML).toBe('x ');
+ deep.content.firstChild!.textContent = 'changed';
+ expect(e.content.firstChild!.textContent).toBe('x');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/templates/content.ts b/packages/web-platform-tests/src/compatibility/templates/content.ts
new file mode 100644
index 0000000..6ef1f32
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/templates/content.ts
@@ -0,0 +1,29 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/scripting.html#the-template-element';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('keeps template content in an inert separate document fragment', ({ document }) => {
+ const template = document.createElement('template');
+ const child = document.createElement('span');
+ template.content.append(child);
+ expect(template.childNodes.length).toBe(0);
+ expect(template.content.firstChild).toBe(child);
+ expect(template.content.nodeType).toBe(11);
+ expect(template.content.parentNode).toBeNull();
+ expect(child.isConnected).toBe(false);
+ expect(child.ownerDocument).toBe(template.content.ownerDocument);
+ expect(template.content.ownerDocument).not.toBe(document);
+ });
+
+ context.test('keeps template contents outside ordinary children and textContent', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('template');
+ e.innerHTML = 'x ';
+ expect(e.childNodes.length).toBe(0);
+ expect(e.textContent).toBe('');
+ expect(e.content.firstChild!.textContent).toBe('x');
+ expect(e.content.parentNode).toBeNull();
+ expect(e.content.ownerDocument).not.toBe(document);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/templates/parsing.ts b/packages/web-platform-tests/src/compatibility/templates/parsing.ts
new file mode 100644
index 0000000..0842aa2
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/templates/parsing.ts
@@ -0,0 +1,28 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://html.spec.whatwg.org/multipage/scripting.html#the-template-element';
+export const requires = ['templates.content', 'parsing.fragments'] as const;
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'parses markup into template content, including nested templates',
+ ({ document }) => {
+ const template = document.createElement('template');
+ template.innerHTML = 'one & two nested ';
+ expect(template.childNodes.length).toBe(0);
+ expect(template.content.childNodes.length).toBe(2);
+ expect(template.content.firstChild?.textContent).toBe('one & two');
+ expect(template.innerHTML).toBe('one & two nested ');
+ },
+ );
+
+ context.test('parses nested templates into separate content fragments', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const e = document.createElement('template');
+ e.innerHTML = 'x ';
+ const nested = e.content.firstChild as HTMLTemplateElement;
+ expect(nested.childNodes.length).toBe(0);
+ expect(nested.content.firstChild!.textContent).toBe('x');
+ expect(e.innerHTML).toBe('x ');
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/test-dom-compatibility.ts b/packages/web-platform-tests/src/compatibility/test-dom-compatibility.ts
new file mode 100644
index 0000000..16e0866
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/test-dom-compatibility.ts
@@ -0,0 +1,88 @@
+import { domFeaturesSchema, enabled, type DOMFeatures, type DOMFeaturePath } from './features.ts';
+import type {
+ CompatibilityContext,
+ CompatibilityRunner,
+ CompatibilityTestOptions,
+ FeatureModule,
+} from './context.ts';
+import { withFixture, type DOMFixtureOptions } from './fixture.ts';
+import { registry } from './registry.ts';
+
+/** Compatibility CompatibilityConfigurationError support. */
+export class CompatibilityConfigurationError extends Error {
+ readonly details: { feature?: string; prerequisite?: string; global?: string };
+ constructor(
+ message: string,
+ details: { feature?: string; prerequisite?: string; global?: string } = {},
+ ) {
+ super(message);
+ this.name = 'CompatibilityConfigurationError';
+ this.details = details;
+ }
+}
+
+export interface DOMCompatibilityOptions extends DOMFixtureOptions {
+ runner: CompatibilityRunner;
+ features: DOMFeatures;
+}
+
+/** Registers only enabled cases. No skip/todo calls, implicit feature detection, or globals installation. */
+export function testDOMCompatibility(options: DOMCompatibilityOptions): void {
+ const validation = domFeaturesSchema['~standard'].validate(options.features);
+ if (validation instanceof Promise)
+ throw new CompatibilityConfigurationError('Feature validation must be synchronous');
+ if (validation.issues) {
+ throw new CompatibilityConfigurationError(
+ validation.issues.map((issue) => `${issue.path?.join('.')}: ${issue.message}`).join('\n'),
+ );
+ }
+ const features = validation.value;
+ const modules: Record = registry;
+ const selected = Object.entries(modules).filter(([path]) =>
+ enabled(features, path as DOMFeaturePath),
+ );
+ // Validate prerequisites before registering anything; never silently enable dependencies.
+ for (const [path, leaf] of selected) {
+ if (leaf.input?.length && !options.input)
+ throw new CompatibilityConfigurationError(`${path} requires an input driver`, {
+ feature: path,
+ });
+ for (const prerequisite of leaf.requires ?? []) {
+ if (!enabled(features, prerequisite))
+ throw new CompatibilityConfigurationError(`${path} requires ${prerequisite}`, {
+ feature: path,
+ prerequisite,
+ });
+ }
+ }
+ const context: CompatibilityContext = {
+ runner: options.runner,
+ test(name, run, request = {}) {
+ const testOptions: CompatibilityTestOptions = Array.isArray(request)
+ ? { requires: request }
+ : (request as CompatibilityTestOptions);
+ if (!(testOptions.requires ?? []).every((path) => enabled(features, path))) return;
+ options.runner.it(name, () =>
+ withFixture({ ...options, document: testOptions.document }, run),
+ );
+ },
+ };
+ function register(prefix: string, entries: typeof selected): void {
+ const groups = new Map();
+ for (const entry of entries) {
+ const part = entry[0].slice(prefix.length).split('.')[0]!;
+ const group = groups.get(part) ?? [];
+ group.push(entry);
+ groups.set(part, group);
+ }
+ for (const [name, group] of groups) {
+ options.runner.describe(name, () => {
+ const path = prefix + name;
+ const leaf = group.find(([key]) => key === path);
+ if (leaf) leaf[1].register(context);
+ else register(`${path}.`, group);
+ });
+ }
+ }
+ register('', selected);
+}
diff --git a/packages/web-platform-tests/src/compatibility/traversal/node-iterator.ts b/packages/web-platform-tests/src/compatibility/traversal/node-iterator.ts
new file mode 100644
index 0000000..a7218d9
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/traversal/node-iterator.ts
@@ -0,0 +1,36 @@
+import type { CompatibilityContext } from '../context.ts';
+export const spec = 'https://dom.spec.whatwg.org/#interface-nodeiterator';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test('iterates inclusive root in order and applies masks', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = 'a b ';
+ const it = document.createNodeIterator(p, 1);
+ expect(it.nextNode()).toBe(p);
+ expect(it.nextNode()).toBe(p.firstChild);
+ expect(it.nextNode()).toBe(p.lastChild);
+ expect(it.nextNode()).toBeNull();
+ expect(it.previousNode()).toBe(p.lastChild);
+ });
+ context.test('skips rejected nodes but traverses their descendants', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = ' ';
+ const it = document.createNodeIterator(p, 1, (n) => (n.nodeName === 'I' ? 2 : 1));
+ expect(it.nextNode()).toBe(p);
+ expect(it.nextNode()).toBe(p.querySelector('b'));
+ expect(it.nextNode()).toBeNull();
+ });
+ context.test('updates its reference after removing the current node', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = ' ';
+ const it = document.createNodeIterator(p, 1);
+ it.nextNode();
+ const first = it.nextNode()!;
+ first.parentNode!.removeChild(first);
+ expect(it.nextNode()).toBe(p.firstChild);
+ });
+}
diff --git a/packages/web-platform-tests/src/compatibility/traversal/tree-walker.ts b/packages/web-platform-tests/src/compatibility/traversal/tree-walker.ts
new file mode 100644
index 0000000..ef51956
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/traversal/tree-walker.ts
@@ -0,0 +1,217 @@
+import type { CompatibilityContext } from '../context.ts';
+
+export const spec = 'https://dom.spec.whatwg.org/#interface-treewalker';
+// Additional filter cases adapted from WPT TreeWalker-acceptNode-filter.html,
+// itself adapted from WebKit LayoutTests/fast/dom/TreeWalker/script-tests/acceptNode-filter.js.
+// Copyright © web-platform-tests contributors. BSD-3-Clause.
+// See ../../../test/wpt/LICENSE and docs/wpt-ranges-parsing.md.
+export const wpt =
+ 'https://github.com/web-platform-tests/wpt/blob/b719612f963a05ece9c89a7dd7598c1c59618874/dom/traversal/TreeWalker-acceptNode-filter.html';
+/** Registers behavioral cases with the consumer runner. */
+export function register(context: CompatibilityContext): void {
+ const { expect } = context.runner;
+ context.test(
+ 'walks accepted descendants in order and prunes rejected subtrees',
+ ({ document }) => {
+ const root = document.createElement('div');
+ const a = document.createElement('span');
+ const b = document.createElement('b');
+ const nested = document.createElement('i');
+ a.appendChild(nested);
+ root.append(a, b);
+ const walker = document.createTreeWalker(root, 1, {
+ acceptNode(node) {
+ return node === a ? 2 : 1;
+ },
+ });
+ expect(walker.root).toBe(root);
+ expect(walker.currentNode).toBe(root);
+ expect(walker.nextNode()).toBe(b);
+ expect(walker.nextNode()).toBeNull();
+ expect(walker.parentNode()).toBe(root);
+ },
+ );
+
+ context.test('distinguishes FILTER_SKIP from FILTER_REJECT subtrees', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = ' ';
+ const walker = document.createTreeWalker(p, 1, (node) =>
+ node.nodeName === 'SECTION' ? 2 : node.nodeName === 'B' ? 3 : 1,
+ );
+ expect(walker.nextNode()).toBe(p.querySelector('u'));
+ expect(walker.nextNode()).toBeNull();
+ expect(walker.currentNode).toBe(p.querySelector('u'));
+ });
+
+ context.test('supports reverse and sibling traversal without moving on null', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const p = document.createElement('div');
+ p.innerHTML = ' ';
+ const walker = document.createTreeWalker(p, 1);
+ expect(walker.firstChild()).toBe(p.firstChild);
+ expect(walker.nextSibling()).toBe(p.lastChild);
+ expect(walker.nextSibling()).toBeNull();
+ expect(walker.currentNode).toBe(p.lastChild);
+ expect(walker.previousSibling()).toBe(p.firstChild);
+ expect(walker.parentNode()).toBe(p);
+ });
+ context.test('rejects missing and non-callable filter methods without advancing', (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div');
+ root.append(document.createElement('i'));
+ for (const filter of [{}, { acceptNode: 'invalid' }]) {
+ const walker = document.createTreeWalker(root, 1, filter as unknown as NodeFilter);
+ for (const traverse of [() => walker.firstChild(), () => walker.nextNode()]) {
+ expect(traverse).toThrow(expect.objectContaining({ name: 'TypeError' }));
+ expect(walker.currentNode).toBe(root);
+ }
+ }
+ });
+ context.test(
+ 'propagates filter exceptions by identity and recovers on a later traversal',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div'),
+ child = document.createElement('i');
+ root.append(child);
+ const failure = new Error('filter failure');
+ let shouldThrow = true;
+ const walker = document.createTreeWalker(root, 1, () => {
+ if (shouldThrow) throw failure;
+ return 1;
+ });
+ let caught: unknown;
+ try {
+ walker.nextNode();
+ } catch (error) {
+ caught = error;
+ }
+ expect(caught).toBe(failure);
+ expect(walker.currentNode).toBe(root);
+ shouldThrow = false;
+ expect(walker.nextNode()).toBe(child);
+ },
+ );
+ context.test(
+ 'retrieves acceptNode for every invocation with the filter as its receiver',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div'),
+ first = document.createElement('i'),
+ second = document.createElement('b');
+ root.append(first, second);
+ let gets = 0;
+ const receivers: unknown[] = [],
+ nodes: Node[] = [];
+ const filter = {
+ get acceptNode() {
+ gets++;
+ return function (this: unknown, node: Node) {
+ receivers.push(this);
+ nodes.push(node);
+ return 1;
+ };
+ },
+ };
+ const walker = document.createTreeWalker(root, 1, filter);
+ expect(gets).toBe(0);
+ expect(walker.nextNode()).toBe(first);
+ expect(walker.nextNode()).toBe(second);
+ expect(gets).toBe(2);
+ expect(receivers).toEqual([filter, filter]);
+ expect(nodes).toEqual([first, second]);
+ },
+ );
+ context.test(
+ 'propagates getter exceptions without changing currentNode or retaining the active flag',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div'),
+ child = document.createElement('i');
+ root.append(child);
+ const failure = new Error('getter failure');
+ let shouldThrow = true;
+ const walker = document.createTreeWalker(root, 1, {
+ get acceptNode() {
+ if (shouldThrow) throw failure;
+ return () => 1;
+ },
+ });
+ let caught: unknown;
+ try {
+ walker.firstChild();
+ } catch (error) {
+ caught = error;
+ }
+ expect(caught).toBe(failure);
+ expect(walker.currentNode).toBe(root);
+ shouldThrow = false;
+ expect(walker.firstChild()).toBe(child);
+ },
+ );
+ context.test(
+ 'calls a callable filter directly even when it has an acceptNode property',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div'),
+ child = document.createElement('i');
+ root.append(child);
+ let methodCalls = 0;
+ const filter = Object.assign(() => 1, {
+ acceptNode: () => {
+ methodCalls++;
+ return 2;
+ },
+ });
+ const walker = document.createTreeWalker(root, 1, filter);
+ expect(walker.nextNode()).toBe(child);
+ expect(methodCalls).toBe(0);
+ },
+ );
+ context.test(
+ 'rejects recursive filtering with InvalidStateError and allows later calls',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div'),
+ first = document.createElement('i'),
+ second = document.createElement('b');
+ root.append(first, second);
+ let invocations = 0;
+ const walker = document.createTreeWalker(root, 1, () => {
+ invocations++;
+ expect(() => walker.nextNode()).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ return 1;
+ });
+ expect(walker.nextNode()).toBe(first);
+ expect(walker.nextNode()).toBe(second);
+ expect(invocations).toBe(2);
+ },
+ );
+ context.test(
+ 'applies whatToShow before invoking the filter and converts its result to unsigned short',
+ (fixture) => {
+ const document = fixture.document as unknown as Document;
+ const root = document.createElement('div'),
+ element = document.createElement('i');
+ root.append('text', document.createComment('comment'), element);
+ const visited: Node[] = [];
+ let conversions = 0;
+ const walker = document.createTreeWalker(root, 1, (node) => {
+ visited.push(node);
+ return {
+ valueOf() {
+ conversions++;
+ return 65537;
+ },
+ } as unknown as number;
+ });
+ expect(walker.nextNode()).toBe(element);
+ expect(walker.nextNode()).toBeNull();
+ expect(visited).toEqual([element]);
+ expect(conversions).toBe(1);
+ },
+ );
+}
diff --git a/packages/web-platform-tests/src/compatibility/unique-name.ts b/packages/web-platform-tests/src/compatibility/unique-name.ts
new file mode 100644
index 0000000..d0a46db
--- /dev/null
+++ b/packages/web-platform-tests/src/compatibility/unique-name.ts
@@ -0,0 +1,5 @@
+let sequence = 0;
+/** Provides uniqueName for compatibility testing. */
+export function uniqueName(): string {
+ return `x-dom-compat-${Date.now()}-${++sequence}`;
+}
diff --git a/packages/web-platform-tests/src/index.ts b/packages/web-platform-tests/src/index.ts
new file mode 100644
index 0000000..708be6b
--- /dev/null
+++ b/packages/web-platform-tests/src/index.ts
@@ -0,0 +1,25 @@
+export {
+ testDOMCompatibility,
+ CompatibilityConfigurationError,
+} from './compatibility/test-dom-compatibility.ts';
+export type { DOMCompatibilityOptions } from './compatibility/test-dom-compatibility.ts';
+export { domFeaturesSchema } from './compatibility/features.ts';
+export type { DOMFeatures, DOMFeaturePath } from './compatibility/features.ts';
+export type {
+ CompatibilityRunner,
+ CompatibilityExpect,
+ CompatibilityMatchers,
+ CompatibilityContext,
+ CompatibilityTestOptions,
+ DOMFixture,
+ FeatureModule,
+} from './compatibility/context.ts';
+export type { DOMFixtureOptions, DOMDocumentOptions } from './compatibility/fixture.ts';
+export type { DOMGlobalBindings } from './compatibility/globals.ts';
+export type {
+ DOMInputDriver,
+ DOMTestInput,
+ DOMInputCapability,
+ DOMMouseButton,
+} from './compatibility/input-driver.ts';
+export type { DOMElement } from './compatibility/contracts/nodes.ts';
diff --git a/packages/web-platform-tests/test/browser/compatibility-types.mts b/packages/web-platform-tests/test/browser/compatibility-types.mts
new file mode 100644
index 0000000..b50ab65
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/compatibility-types.mts
@@ -0,0 +1,46 @@
+import type { CompatibilityRunner } from '../../src/compatibility/context.ts';
+import { testDOMCompatibility } from '../../src/compatibility/test-dom-compatibility.ts';
+import { browserFeatures } from './features.ts';
+import { createBrowserInputDriver, type BrowserInputBridge } from './input-driver.mts';
+
+/** Native documents and bindings can enter the suite without application casts. */
+export function registerBrowserCompatibility(
+ runner: CompatibilityRunner,
+ send: BrowserInputBridge,
+): void {
+ testDOMCompatibility({
+ runner,
+ create: () => document,
+ input: (document) => createBrowserInputDriver(document, send),
+ globals: {
+ Document,
+ DOMException,
+ NodeList,
+ TypeError,
+ DOMParser,
+ XMLSerializer,
+ CSSStyleSheet,
+ getComputedStyle,
+ Element,
+ HTMLElement,
+ HTMLTemplateElement,
+ HTMLInputElement,
+ HTMLTextAreaElement,
+ HTMLSelectElement,
+ Event,
+ EventTarget,
+ CustomEvent,
+ KeyboardEvent,
+ MouseEvent,
+ FocusEvent,
+ InputEvent,
+ ErrorEvent,
+ MutationObserver,
+ AbortController,
+ FormData,
+ customElements,
+ window,
+ },
+ features: browserFeatures(true),
+ });
+}
diff --git a/packages/web-platform-tests/test/browser/compatibility.html b/packages/web-platform-tests/test/browser/compatibility.html
new file mode 100644
index 0000000..a52e9e3
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/compatibility.html
@@ -0,0 +1,8 @@
+
+
+DOM feature compatibility — browser differential
+DOM feature compatibility
+This development fixture runs the same feature tests against native browser documents.
+Keyboard and mouse tests run through the Playwright automation runner.
+Running…
+
diff --git a/packages/web-platform-tests/test/browser/compatibility.mjs b/packages/web-platform-tests/test/browser/compatibility.mjs
new file mode 100644
index 0000000..6af9bc0
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/compatibility.mjs
@@ -0,0 +1,295 @@
+// Development-only browser runner. Bundle this file, then open compatibility.html.
+// The assertions and feature catalogue are the same modules used by Vitest.
+import * as chai from 'chai';
+import { JestChaiExpect, JestAsymmetricMatchers } from '@vitest/expect';
+import { testDOMCompatibility } from '../../src/compatibility/test-dom-compatibility.ts';
+import { browserFeatures } from './features.ts';
+import { createBrowserInputDriver } from './input-driver.mts';
+
+const parameters = new URLSearchParams(location.search);
+const inputMode = parameters.get('input');
+const inputOnly = parameters.has('input-only');
+
+chai.use(JestChaiExpect);
+chai.use(JestAsymmetricMatchers);
+const expect = Object.assign(chai.expect, {
+ unreachable(message) {
+ chai.assert.fail(message ?? 'Expected unreachable code');
+ },
+});
+const cases = [];
+const names = [];
+const releases = new WeakMap();
+const pendingReleases = new Set();
+const errorsByDocument = new WeakMap();
+let currentErrors = null;
+let currentDocument = null;
+window.addEventListener('error', (event) => {
+ const errors = currentErrors;
+ queueMicrotask(() => {
+ if (errors && !event.defaultPrevented) errors.push(event.error ?? event.message);
+ });
+});
+const runner = {
+ expect,
+ describe(name, register) {
+ names.push(name);
+ try {
+ register();
+ } finally {
+ names.pop();
+ }
+ },
+ it(name, run) {
+ if (!inputOnly || names[0] === 'input') cases.push({ name: [...names, name].join(' > '), run });
+ },
+};
+
+class BrowserDocumentError extends Error {
+ constructor(message, details) {
+ super(message);
+ this.name = 'BrowserDocumentError';
+ this.details = details;
+ }
+}
+
+function createFrame() {
+ const frame = document.createElement('iframe');
+ frame.style.cssText = 'position:absolute;left:0;top:0;width:800px;height:600px';
+ return frame;
+}
+
+function initializeFrame(frame, release) {
+ const fixtureDocument = frame.contentDocument;
+ if (!fixtureDocument) chai.assert.fail('Could not construct browser document');
+ fixtureDocument.defaultView.focus();
+ releases.set(fixtureDocument, release);
+ const errors = [];
+ errorsByDocument.set(fixtureDocument, errors);
+ currentErrors = errors;
+ currentDocument = fixtureDocument;
+ fixtureDocument.defaultView.addEventListener('error', (event) => {
+ // Listener exceptions are reported, not thrown by dispatchEvent. Wait until
+ // intentional error-reporting tests have had a chance to preventDefault.
+ queueMicrotask(() => {
+ if (!event.defaultPrevented) errors.push(event.error ?? event.message);
+ });
+ });
+ return fixtureDocument;
+}
+
+async function createRequestedDocument(request) {
+ const frame = createFrame();
+ const blobURL = URL.createObjectURL(new Blob([request.content], { type: request.contentType }));
+ const navigation = new URL(blobURL);
+ navigation.hash = request.fragment ?? '';
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ frame.remove();
+ URL.revokeObjectURL(blobURL);
+ pendingReleases.delete(release);
+ };
+ pendingReleases.add(release);
+ let timeout;
+ let animationFrame;
+ let view;
+ let onLoad;
+ let onError;
+ try {
+ await new Promise((resolve, reject) => {
+ timeout = setTimeout(
+ () =>
+ reject(
+ new BrowserDocumentError('Requested fixture document did not load within 4 seconds', {
+ contentType: request.contentType,
+ phase: 'load',
+ }),
+ ),
+ 4000,
+ );
+ onError = () =>
+ reject(
+ new BrowserDocumentError('Requested fixture document failed to load', {
+ contentType: request.contentType,
+ phase: 'load',
+ }),
+ );
+ onLoad = () => {
+ view = frame.contentWindow;
+ if (!view) {
+ reject(
+ new BrowserDocumentError('Requested fixture document has no browsing context', {
+ contentType: request.contentType,
+ phase: 'load',
+ }),
+ );
+ return;
+ }
+ animationFrame = view.requestAnimationFrame(() => resolve());
+ };
+ frame.addEventListener('load', onLoad, { once: true });
+ frame.addEventListener('error', onError, { once: true });
+ frame.src = navigation.href;
+ document.body.append(frame);
+ });
+ if (released)
+ throw new BrowserDocumentError(
+ 'Requested fixture document was disposed before creation completed',
+ { contentType: request.contentType, phase: 'creation' },
+ );
+ expect(frame.contentDocument.contentType).toBe(request.contentType);
+ const fixtureDocument = initializeFrame(frame, release);
+ pendingReleases.delete(release);
+ return fixtureDocument;
+ } catch (error) {
+ release();
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ if (animationFrame !== undefined) view.cancelAnimationFrame(animationFrame);
+ frame.removeEventListener('load', onLoad);
+ frame.removeEventListener('error', onError);
+ }
+}
+
+const options = {
+ runner,
+ features: browserFeatures(inputMode === 'playwright'),
+ ...(inputMode === 'playwright'
+ ? { input: (document) => createBrowserInputDriver(document, window.__domInput) }
+ : {}),
+ create(request) {
+ if (request) return createRequestedDocument(request);
+ const frame = createFrame();
+ document.body.append(frame);
+ try {
+ const fixtureDocument = frame.contentDocument;
+ if (!fixtureDocument) chai.assert.fail('Could not construct browser document');
+ // about:blank starts in quirks mode even when the parent is in standards mode.
+ // These cases use standard selector semantics, including bare :hover/:active.
+ fixtureDocument.open();
+ fixtureDocument.write('');
+ fixtureDocument.close();
+ expect(fixtureDocument.compatMode).toBe('CSS1Compat');
+ return initializeFrame(frame, () => frame.remove());
+ } catch (error) {
+ frame.remove();
+ throw error;
+ }
+ },
+ globals(fixtureDocument) {
+ const view = fixtureDocument.defaultView;
+ return {
+ Document: view.Document,
+ DOMException: view.DOMException,
+ NodeList: view.NodeList,
+ TypeError: view.TypeError,
+ CSSStyleSheet: view.CSSStyleSheet,
+ DOMParser: view.DOMParser,
+ XMLSerializer: view.XMLSerializer,
+ getComputedStyle: view.getComputedStyle.bind(view),
+ Element: view.Element,
+ HTMLElement: view.HTMLElement,
+ HTMLInputElement: view.HTMLInputElement,
+ HTMLTextAreaElement: view.HTMLTextAreaElement,
+ HTMLSelectElement: view.HTMLSelectElement,
+ HTMLTemplateElement: view.HTMLTemplateElement,
+ Event: view.Event,
+ EventTarget: view.EventTarget,
+ CustomEvent: view.CustomEvent,
+ KeyboardEvent: view.KeyboardEvent,
+ MouseEvent: view.MouseEvent,
+ FocusEvent: view.FocusEvent,
+ InputEvent: view.InputEvent,
+ MutationObserver: view.MutationObserver,
+ AbortController: view.AbortController,
+ FormData: view.FormData,
+ customElements: view.customElements,
+ // Callbacks are authored in this module's realm, so the standard
+ // exception-reporting target and ErrorEvent constructor belong here.
+ window,
+ ErrorEvent,
+ };
+ },
+ dispose(fixtureDocument) {
+ releases.get(fixtureDocument)?.();
+ releases.delete(fixtureDocument);
+ const errors = errorsByDocument.get(fixtureDocument);
+ errorsByDocument.delete(fixtureDocument);
+ if (currentDocument === fixtureDocument) {
+ currentErrors = null;
+ currentDocument = null;
+ }
+ if (errors?.length) throw errors[0];
+ },
+};
+
+const results = [];
+let aborted = false;
+const report = document.getElementById('results');
+try {
+ if (inputMode && inputMode !== 'playwright')
+ chai.assert.fail(`Unknown input adapter: ${inputMode}`);
+ if (inputMode === 'playwright' && typeof window.__domInput !== 'function')
+ chai.assert.fail(
+ 'Playwright input requires the test/browser/run-compatibility.mjs automation runner',
+ );
+ if (inputOnly && inputMode !== 'playwright')
+ chai.assert.fail('Input-only mode requires an input adapter');
+ testDOMCompatibility(options);
+ for (const test of cases) {
+ let timeout;
+ if (report)
+ report.textContent = JSON.stringify({ running: test.name, completed: results.length });
+ try {
+ await Promise.race([
+ test.run(),
+ new Promise((_, reject) => {
+ timeout = setTimeout(() => {
+ aborted = true;
+ reject(new Error('Test did not finish within 5 seconds; remaining cases aborted'));
+ }, 5000);
+ }),
+ ]);
+ results.push({ name: test.name, status: 'passed' });
+ } catch (error) {
+ results.push({
+ name: test.name,
+ status: 'failed',
+ message: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ clearTimeout(timeout);
+ if (currentDocument) {
+ releases.get(currentDocument)?.();
+ releases.delete(currentDocument);
+ errorsByDocument.delete(currentDocument);
+ currentDocument = null;
+ currentErrors = null;
+ }
+ for (const release of pendingReleases) release();
+ }
+ // A timeout cannot cancel the pending test or its host input. Starting
+ // another fixture would let that input leak into the next test.
+ if (aborted) break;
+ }
+} catch (error) {
+ results.push({
+ name: 'suite registration',
+ status: 'failed',
+ message: error instanceof Error ? error.message : String(error),
+ });
+}
+const summary = {
+ userAgent: navigator.userAgent,
+ input: inputMode ?? 'disabled',
+ aborted,
+ notRun: Math.max(0, cases.length - results.length),
+ passed: results.filter((result) => result.status === 'passed').length,
+ failed: results.filter((result) => result.status === 'failed').length,
+ results,
+};
+if (report) report.textContent = JSON.stringify(summary, null, 2);
+document.documentElement.dataset.complete = 'true';
diff --git a/packages/web-platform-tests/test/browser/features.ts b/packages/web-platform-tests/test/browser/features.ts
new file mode 100644
index 0000000..fba57cf
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/features.ts
@@ -0,0 +1,35 @@
+import { featureShape, type DOMFeatures } from '../../src/compatibility/features.ts';
+
+/** Input is explicitly enabled only by the automation runner's ?input=playwright mode. */
+export function browserFeatures(input: boolean): DOMFeatures {
+ return {
+ ...featureShape,
+ input: {
+ keyboard: {
+ delivery: input,
+ modifiers: input,
+ navigation: input,
+ activation: input,
+ editing: input,
+ multilineEditing: input,
+ numberStepping: input,
+ focusStyles: input,
+ focusVisible: input,
+ shadow: input,
+ implicitSubmission: input,
+ trustedEvents: input,
+ },
+ mouse: {
+ delivery: input,
+ buttons: input,
+ hover: input,
+ focus: input,
+ activation: input,
+ stateStyles: input,
+ focusVisible: input,
+ shadow: input,
+ trustedEvents: input,
+ },
+ },
+ };
+}
diff --git a/packages/web-platform-tests/test/browser/input-driver.mts b/packages/web-platform-tests/test/browser/input-driver.mts
new file mode 100644
index 0000000..53bc8e5
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/input-driver.mts
@@ -0,0 +1,62 @@
+import type { DOMInputDriver, DOMMouseButton } from '../../src/compatibility/input-driver.ts';
+
+export type BrowserInputCommand =
+ | { type: 'keyDown' | 'keyUp'; key: string }
+ | { type: 'insertText'; text: string }
+ | { type: 'move'; x: number; y: number }
+ | { type: 'down' | 'up'; button: DOMMouseButton }
+ | { type: 'reset' };
+
+export type BrowserInputBridge = (command: BrowserInputCommand) => Promise;
+
+class BrowserInputError extends Error {
+ readonly target: Element;
+ constructor(message: string, target: Element) {
+ super(message);
+ this.name = 'BrowserInputError';
+ this.target = target;
+ }
+}
+
+/** The bridge delivers trusted input through browser automation, outside this realm. */
+export function createBrowserInputDriver(
+ document: Document,
+ send: BrowserInputBridge,
+): DOMInputDriver {
+ return {
+ keyboard: {
+ keyDown: (key) => send({ type: 'keyDown', key }),
+ keyUp: (key) => send({ type: 'keyUp', key }),
+ },
+ insertText: (text) => send({ type: 'insertText', text }),
+ mouse: {
+ async move(element) {
+ // The generic suite's contract enters the native-browser adapter here.
+ const target = element as unknown as Element;
+ if (target.ownerDocument !== document || !target.isConnected)
+ throw new BrowserInputError(
+ 'Mouse target must be connected to the fixture document',
+ target,
+ );
+ target.scrollIntoView({ block: 'center', inline: 'center' });
+ const rect = target.getBoundingClientRect();
+ if (!rect.width || !rect.height)
+ throw new BrowserInputError('Mouse target has no rendered box', target);
+ let x = rect.left + rect.width / 2;
+ let y = rect.top + rect.height / 2;
+ let view = document.defaultView;
+ while (view?.frameElement) {
+ const frame = view.frameElement;
+ const frameRect = frame.getBoundingClientRect();
+ x += frameRect.left + frame.clientLeft;
+ y += frameRect.top + frame.clientTop;
+ view = frame.ownerDocument.defaultView;
+ }
+ await send({ type: 'move', x, y });
+ },
+ down: (button) => send({ type: 'down', button }),
+ up: (button) => send({ type: 'up', button }),
+ },
+ reset: () => send({ type: 'reset' }),
+ };
+}
diff --git a/packages/web-platform-tests/test/browser/run-compatibility.mjs b/packages/web-platform-tests/test/browser/run-compatibility.mjs
new file mode 100644
index 0000000..260565e
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/run-compatibility.mjs
@@ -0,0 +1,140 @@
+// Development-only Playwright runner for the shared compatibility suite.
+// Usage: node test/browser/run-compatibility.mjs [--input-only] [--no-input]
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright';
+import { createServer } from 'vite';
+
+class BrowserAutomationError extends Error {
+ constructor(message, details = {}) {
+ super(message);
+ this.name = 'BrowserAutomationError';
+ this.details = details;
+ }
+}
+
+const args = new Set(process.argv.slice(2));
+for (const arg of args) {
+ if (!['--input-only', '--no-input'].includes(arg))
+ throw new BrowserAutomationError(`Unknown argument: ${arg}`);
+}
+if (args.has('--input-only') && args.has('--no-input'))
+ throw new BrowserAutomationError('--input-only and --no-input cannot be combined');
+
+function inputSession(page) {
+ const heldKeys = new Set();
+ const heldButtons = new Set();
+ const buttonNames = ['left', 'middle', 'right'];
+ async function keyDown(key) {
+ // Track before awaiting so reset also attempts release after an input failure.
+ heldKeys.add(key);
+ await page.keyboard.down(key);
+ }
+ async function keyUp(key) {
+ await page.keyboard.up(key);
+ heldKeys.delete(key);
+ }
+ async function mouseDown(button) {
+ heldButtons.add(button);
+ await page.mouse.down({ button });
+ }
+ async function mouseUp(button) {
+ await page.mouse.up({ button });
+ heldButtons.delete(button);
+ }
+ return async (command) => {
+ switch (command.type) {
+ case 'keyDown':
+ return keyDown(command.key);
+ case 'keyUp':
+ return keyUp(command.key);
+ case 'insertText':
+ return page.keyboard.insertText(command.text);
+ case 'move':
+ return page.mouse.move(command.x, command.y);
+ case 'down':
+ case 'up': {
+ const button = buttonNames[command.button];
+ if (!button) throw new BrowserAutomationError('Invalid mouse button', { command });
+ return command.type === 'down' ? mouseDown(button) : mouseUp(button);
+ }
+ case 'reset': {
+ const failures = [];
+ async function release(operation) {
+ try {
+ await operation();
+ } catch (error) {
+ failures.push(error);
+ }
+ }
+ for (const button of heldButtons) await release(() => mouseUp(button));
+ for (const key of [...heldKeys].toReversed()) await release(() => keyUp(key));
+ await release(() => page.mouse.move(1200, 800));
+ if (failures.length)
+ throw new BrowserAutomationError('Could not release all browser input', { failures });
+ return;
+ }
+ default:
+ throw new BrowserAutomationError(`Unknown input command: ${command.type}`);
+ }
+ };
+}
+
+let server;
+let browser;
+let context;
+try {
+ server = await createServer({
+ configFile: fileURLToPath(new URL('./vite.config.mjs', import.meta.url)),
+ server: { host: '127.0.0.1', open: false, hmr: false },
+ logLevel: 'error',
+ });
+ await server.listen();
+ browser = await chromium.launch({
+ headless: true,
+ ...(process.env.CHROME_BIN ? { executablePath: process.env.CHROME_BIN } : {}),
+ });
+ context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+ await page.bringToFront();
+ if (!args.has('--no-input')) {
+ const sendInput = inputSession(page);
+ let queue = Promise.resolve();
+ await page.exposeBinding('__domInput', (_source, command) => {
+ const operation = queue.then(() => sendInput(command));
+ // A rejected operation must not prevent subsequent cleanup commands.
+ queue = operation.catch(() => {});
+ return operation;
+ });
+ }
+ const url = new URL('compatibility.html', server.resolvedUrls.local[0]);
+ if (!args.has('--no-input')) url.searchParams.set('input', 'playwright');
+ if (args.has('--input-only')) url.searchParams.set('input-only', '');
+ await page.goto(url.href);
+ try {
+ await page.waitForFunction(
+ () => document.documentElement.dataset.complete === 'true',
+ undefined,
+ // The WPT matrix loads a fresh HTML/XHTML document for every case.
+ // Individual cases retain their five-second timeout and abort isolation.
+ { timeout: 600_000 },
+ );
+ } catch (cause) {
+ const report = await page.locator('#results').textContent();
+ throw new BrowserAutomationError(`Browser suite timed out. Last report: ${report}`, { cause });
+ }
+ const summary = JSON.parse(await page.locator('#results').textContent());
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
+ if (summary.failed || summary.aborted || summary.notRun || !summary.results.length)
+ process.exitCode = 1;
+} finally {
+ // Closing the context also terminates pending input after a case timeout.
+ try {
+ await context?.close();
+ } finally {
+ try {
+ await browser?.close();
+ } finally {
+ await server?.close();
+ }
+ }
+}
diff --git a/packages/web-platform-tests/test/browser/tsconfig.json b/packages/web-platform-tests/test/browser/tsconfig.json
new file mode 100644
index 0000000..a50b6b8
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "noEmit": true,
+ "composite": false
+ },
+ "include": [
+ "compatibility-types.mts"
+ ],
+ "exclude": []
+}
diff --git a/packages/web-platform-tests/test/browser/vite.config.mjs b/packages/web-platform-tests/test/browser/vite.config.mjs
new file mode 100644
index 0000000..b164e01
--- /dev/null
+++ b/packages/web-platform-tests/test/browser/vite.config.mjs
@@ -0,0 +1,15 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: fileURLToPath(new URL('.', import.meta.url)),
+ base: './',
+ build: {
+ target: 'esnext',
+ outDir: 'dist',
+ emptyOutDir: true,
+ rolldownOptions: {
+ input: fileURLToPath(new URL('./compatibility.html', import.meta.url)),
+ },
+ },
+});
diff --git a/packages/web-platform-tests/test/compatibility-harness.test.ts b/packages/web-platform-tests/test/compatibility-harness.test.ts
new file mode 100644
index 0000000..9ab6d1d
--- /dev/null
+++ b/packages/web-platform-tests/test/compatibility-harness.test.ts
@@ -0,0 +1,753 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { describe, expect, expectTypeOf, it } from 'vitest';
+import type { StandardSchemaV1 } from '@standard-schema/spec';
+const { Event, EventTarget } = globalThis;
+// Harness-only factory token: no shared DOM behavior is implemented here.
+class Document {
+ createElement(): EventTarget {
+ return new EventTarget();
+ }
+}
+import { featureShape as domFeatures } from '../src/compatibility/features.ts';
+import type { CompatibilityRunner } from '../src/compatibility/context.ts';
+import {
+ domFeaturesSchema,
+ featureFile,
+ featurePaths,
+ featureShape,
+ type DOMFeatures,
+} from '../src/compatibility/features.ts';
+import { withFixture, type DOMDocumentOptions } from '../src/compatibility/fixture.ts';
+import { registry } from '../src/compatibility/registry.ts';
+import { testDOMCompatibility } from '../src/compatibility/test-dom-compatibility.ts';
+import type { DOMDocument } from '../src/compatibility/contracts/nodes.ts';
+import type { DOMEventConstructor } from '../src/compatibility/contracts/events.ts';
+import { MissingInputError, type DOMInputDriver } from '../src/compatibility/input-driver.ts';
+
+function sourceFiles(directory: URL): string[] {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ if (entry.isDirectory())
+ return sourceFiles(new URL(`${entry.name}/`, directory)).map(
+ (file) => `${entry.name}/${file}`,
+ );
+ return entry.isFile() ? [entry.name] : [];
+ });
+}
+
+function flags(enabled: string[] = []): DOMFeatures {
+ const result: DOMFeatures = structuredClone(domFeatures);
+ function visit(value: object, prefix = ''): void {
+ for (const [key, child] of Object.entries(value)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (typeof child === 'boolean') Reflect.set(value, key, enabled.includes(path));
+ else visit(child, path);
+ }
+ }
+ visit(result);
+ return result;
+}
+
+function recordingRunner(): {
+ runner: CompatibilityRunner;
+ suites: string[];
+ tests: { name: string; run(): void | Promise }[];
+} {
+ const suites: string[] = [];
+ const tests: { name: string; run(): void | Promise }[] = [];
+ const path: string[] = [];
+ return {
+ suites,
+ tests,
+ runner: {
+ expect,
+ describe(name, register) {
+ path.push(name);
+ suites.push(path.join('.'));
+ try {
+ register();
+ } finally {
+ path.pop();
+ }
+ },
+ it(name, run) {
+ tests.push({ name: [...path, name].join('.'), run });
+ },
+ },
+ };
+}
+
+describe('compatibility catalogue', () => {
+ it('has one registered entrypoint per leaf and reachable colocated support modules, outside runner discovery', () => {
+ const directory = new URL('../src/compatibility/', import.meta.url);
+ const allFiles = sourceFiles(directory);
+ const files = allFiles
+ .filter(
+ (file) => file.endsWith('.ts') && file.includes('/') && !file.startsWith('contracts/'),
+ )
+ .toSorted();
+ const paths = featurePaths().toSorted();
+ expect(allFiles.filter((file) => /\.(?:test|spec)\.ts$/.test(file))).toEqual([]);
+ expect(Object.keys(registry).toSorted()).toEqual(paths);
+ const entrypoints = paths.map(featureFile).toSorted();
+ for (const entrypoint of entrypoints) expect(files, entrypoint).toContain(entrypoint);
+ const supportFiles = files.filter((file) => !entrypoints.includes(file));
+ const knownFiles = new Set(allFiles);
+ const reachableByEntrypoint = new Map>();
+ function reachableFrom(entrypoint: string): Set {
+ const cached = reachableByEntrypoint.get(entrypoint);
+ if (cached) return cached;
+ const reachable = new Set();
+ const pending = [entrypoint];
+ while (pending.length) {
+ const file = pending.pop()!;
+ if (reachable.has(file)) continue;
+ reachable.add(file);
+ const source = readFileSync(new URL(file, directory), 'utf8');
+ for (const match of source.matchAll(
+ /\b(?:from\s*|import\s*\(\s*)['"](\.[^'"]+\.ts)['"]/g,
+ )) {
+ const imported = new URL(match[1]!, new URL(file, directory));
+ if (!imported.href.startsWith(directory.href)) continue;
+ const relative = decodeURIComponent(imported.href.slice(directory.href.length));
+ if (knownFiles.has(relative)) pending.push(relative);
+ }
+ }
+ reachableByEntrypoint.set(entrypoint, reachable);
+ return reachable;
+ }
+ for (const file of supportFiles) {
+ const owner = entrypoints.find((entrypoint) =>
+ file.startsWith(`${entrypoint.slice(0, -3)}/`),
+ );
+ expect(owner, `${file} must be below a registered feature entrypoint`).toBeDefined();
+ expect(reachableFrom(owner!), `${file} must be imported by its owning feature`).toContain(
+ file,
+ );
+ }
+ for (const file of files)
+ expect(file).toMatch(/^(?:[a-z]+(?:-[a-z]+)*\/)*[a-z]+(?:-[a-z]+)*\.ts$/);
+ });
+ it('registers substantive tests for every leaf and provides specification provenance', () => {
+ for (const [path, leaf] of Object.entries(registry)) {
+ let cases = 0;
+ leaf.register({
+ runner: recordingRunner().runner,
+ test() {
+ cases++;
+ },
+ });
+ expect(cases, path).toBeGreaterThan(0);
+ expect(leaf.spec, path).toMatch(
+ /^https:\/\/(?:dom\.spec\.whatwg\.org|html\.spec\.whatwg\.org|w3c\.github\.io|(?:www\.)?w3\.org|drafts\.csswg\.org)\//,
+ );
+ }
+ });
+ it('keeps implementation imports and host hooks outside shared tests', () => {
+ const directory = new URL('../src/compatibility/', import.meta.url);
+ for (const file of sourceFiles(directory).filter((candidate) => candidate.endsWith('.ts'))) {
+ const source = readFileSync(new URL(file, directory), 'utf8');
+ expect(source, file).not.toMatch(/from\s+['"][^'"]*(?:\/src\/|@bomb\.sh\/dom)['"]/);
+ expect(source, file).not.toMatch(
+ /\b(?:focusManager|controlState|onInvalidate|getEventParent|setMarkupParser|reportError)\b/,
+ );
+ expect(source, file).not.toMatch(
+ /\.(?:childNodes|children|attributes|labels)\.(?:map|at|filter|find)\(/,
+ );
+ }
+ });
+});
+
+describe('exhaustive Standard Schema validation', () => {
+ it('accepts every explicit true/false decision and has an accurate inferred type', async () => {
+ expectTypeOf<
+ StandardSchemaV1.InferOutput
+ >().toEqualTypeOf();
+ const result = await domFeaturesSchema['~standard'].validate(domFeatures);
+ expect(result.issues).toBeUndefined();
+ if (!result.issues) expect(result.value).toBe(domFeatures);
+ expect(await domFeaturesSchema['~standard'].validate(flags())).toEqual({ value: flags() });
+ });
+ it('rejects missing branches, leaves, unknown keys, and non-booleans without filling defaults', async () => {
+ for (const value of [
+ null,
+ [],
+ {},
+ { ...domFeatures, node: {} },
+ { ...domFeatures, extra: true },
+ { ...domFeatures, node: { ...domFeatures.node, append: 1 } },
+ ]) {
+ const result = await domFeaturesSchema['~standard'].validate(value);
+ expect(result.issues?.length).toBeGreaterThan(0);
+ }
+ const incomplete = structuredClone(domFeatures);
+ Reflect.deleteProperty(incomplete.events.dispatch, 'capture');
+ const result = await domFeaturesSchema['~standard'].validate(incomplete);
+ expect(result.issues).toContainEqual(
+ expect.objectContaining({ path: ['events', 'dispatch', 'capture'] }),
+ );
+ expect(Object.hasOwn(incomplete.events.dispatch, 'capture')).toBe(false);
+ const extra = { ...domFeatures, node: { ...domFeatures.node, surprise: true } };
+ const unknown = await domFeaturesSchema['~standard'].validate(extra);
+ expect(unknown.issues).toContainEqual(expect.objectContaining({ path: ['node', 'surprise'] }));
+ expect(extra.node.surprise).toBe(true);
+ const invalid = await domFeaturesSchema['~standard'].validate({
+ ...domFeatures,
+ node: { ...domFeatures.node, append: 1 },
+ });
+ expect(invalid.issues).toContainEqual(expect.objectContaining({ path: ['node', 'append'] }));
+ });
+ it('requires the complete structure at compile time', () => {
+ // @ts-expect-error Every branch is required, even when no tests are wanted.
+ const missing: DOMFeatures = {};
+ // @ts-expect-error Capture and the other dispatch leaves cannot be omitted.
+ const incomplete: DOMFeatures['events']['dispatch'] = { target: true };
+ void missing;
+ void incomplete;
+ expect(featurePaths(featureShape).length).toBe(Object.keys(registry).length);
+ });
+});
+
+describe('registration and fixture lifetime', () => {
+ it('accepts a partial document and preserves its actual type for setup and disposal', async () => {
+ class EventOnlyDocument {
+ createElement(): EventTarget {
+ return new EventTarget();
+ }
+ }
+ const recording = recordingRunner();
+ const created: EventOnlyDocument[] = [];
+ const disposed: EventOnlyDocument[] = [];
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: flags(['events.dispatch.target']),
+ create() {
+ const document = new EventOnlyDocument();
+ created.push(document);
+ return document;
+ },
+ globals(document) {
+ expectTypeOf(document).toEqualTypeOf();
+ expect(document).toBe(created.at(-1));
+ return { Event };
+ },
+ dispose(document) {
+ expectTypeOf(document).toEqualTypeOf();
+ disposed.push(document);
+ },
+ });
+ expect(recording.tests).toHaveLength(3);
+ for (const test of recording.tests) await test.run();
+ expect(disposed).toEqual(created);
+ });
+ it('fails at the tested operation when a required method is missing', async () => {
+ await expect(
+ withFixture(
+ {
+ create: () => ({ createElement: () => ({ elements: [] }) }),
+ globals: {},
+ },
+ ({ document }) => {
+ const controls = document.createElement('form').elements;
+ expect(controls.item(0)).toBeNull();
+ },
+ ),
+ ).rejects.toThrow(TypeError);
+ });
+ it('uses idealized types without inspecting or wrapping the fixture', async () => {
+ let propertyReads = 0;
+ const original = {
+ get body() {
+ propertyReads++;
+ return null;
+ },
+ };
+ await withFixture(
+ { create: () => original, globals: { Event, customVersion: 'test' } },
+ ({ document, globals }) => {
+ expectTypeOf(document).toEqualTypeOf();
+ expectTypeOf(globals('Event')).toEqualTypeOf();
+ expectTypeOf(globals('customVersion')).toEqualTypeOf();
+ expect(document).toBe(original);
+ expect(globals('Event')).toBe(Event);
+ expect(globals('customVersion')).toBe('test');
+ expect(propertyReads).toBe(0);
+ },
+ );
+ });
+ it('requires only the global requested by a case', async () => {
+ await withFixture({ create: () => null, globals: { available: true } }, ({ globals }) => {
+ expect(() => globals('MutationObserver')).toThrow('globals.MutationObserver');
+ expect(globals('available')).toBe(true);
+ });
+ });
+ it('registers nothing and constructs nothing when every feature is false', () => {
+ const recording = recordingRunner();
+ let created = 0;
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: flags(),
+ globals: {},
+ create() {
+ created++;
+ return new Document();
+ },
+ });
+ expect(recording.suites).toEqual([]);
+ expect(recording.tests).toEqual([]);
+ expect(created).toBe(0);
+ });
+ it('registers target and bubbling without capture or empty unrelated suites', async () => {
+ const recording = recordingRunner();
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: flags(['events.dispatch.target', 'events.dispatch.bubbling']),
+ globals: { Event },
+ create: () => new Document(),
+ });
+ expect(recording.suites).toEqual([
+ 'events',
+ 'events.dispatch',
+ 'events.dispatch.target',
+ 'events.dispatch.bubbling',
+ ]);
+ expect(recording.tests.length).toBeGreaterThan(0);
+ // Registration shape is independent of any DOM implementation.
+ });
+ it('rejects unmet prerequisites before registering any suites', () => {
+ const recording = recordingRunner();
+ expect(() =>
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: flags(['events.dispatch.capture']),
+ globals: { Event },
+ create: () => new Document(),
+ }),
+ ).toThrow('events.dispatch.capture requires events.dispatch.target');
+ expect(recording.suites).toEqual([]);
+ });
+ it('rejects interaction prerequisites before registering any earlier feature', () => {
+ for (const path of ['style.computed', 'focus.transitions', 'input.keyboard.navigation']) {
+ const selected = structuredClone(domFeatures);
+ const keys = path.split('.');
+ let branch: object = selected;
+ for (const key of keys.slice(0, -1)) branch = Reflect.get(branch, key) as object;
+ Reflect.set(branch, keys.at(-1)!, false);
+ const recording = recordingRunner();
+ expect(() =>
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: selected,
+ create: () => new Document(),
+ globals: {},
+ input: () => ({ async reset() {} }),
+ }),
+ ).toThrow('requires');
+ expect(recording.suites).toEqual([]);
+ expect(recording.tests).toEqual([]);
+ }
+ });
+ it('selects focus indication policies independently of trusted events and ordinary input', () => {
+ const selected = {
+ ...domFeatures,
+ input: {
+ keyboard: { ...domFeatures.input.keyboard, trustedEvents: false, focusVisible: false },
+ mouse: { ...domFeatures.input.mouse, trustedEvents: false, focusVisible: false },
+ },
+ };
+ const recording = recordingRunner();
+ const register = (): void => {
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: selected,
+ create: () => new Document(),
+ globals: {},
+ input: () => ({ async reset() {} }),
+ });
+ };
+ register();
+ expect(recording.suites).toContain('input.keyboard.focusStyles');
+ expect(recording.suites).toContain('input.mouse.stateStyles');
+ expect(recording.suites).not.toContain('input.keyboard.focusVisible');
+ expect(recording.suites).not.toContain('input.mouse.focusVisible');
+ selected.input.keyboard.focusVisible = true;
+ selected.input.mouse.focusVisible = true;
+ register();
+ expect(recording.suites).toContain('input.keyboard.focusVisible');
+ expect(recording.suites).toContain('input.mouse.focusVisible');
+ expect(recording.suites).not.toContain('input.keyboard.trustedEvents');
+ expect(recording.suites).not.toContain('input.mouse.trustedEvents');
+ });
+ it('resolves globals per fresh document and disposes fixtures after failure', async () => {
+ const recording = recordingRunner();
+ const created: Document[] = [];
+ const resolved: Document[] = [];
+ const disposed: Document[] = [];
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: flags(['events.dispatch.target']),
+ create() {
+ const document = new Document();
+ created.push(document);
+ return document;
+ },
+ globals(document) {
+ resolved.push(document);
+ return {};
+ },
+ dispose(document) {
+ disposed.push(document);
+ },
+ });
+ expect(created).toHaveLength(0);
+ for (const test of recording.tests) await expect(test.run()).rejects.toThrow('globals.Event');
+ expect(created.length).toBe(recording.tests.length);
+ expect(resolved).toEqual(created);
+ expect(disposed).toEqual(created);
+ expect(new Set(created).size).toBe(created.length);
+ });
+});
+
+describe('fixture cleanup failures', () => {
+ it('runs every cleanup and disposal while preserving assertion and cleanup failures', async () => {
+ const calls: string[] = [];
+ const assertionFailure = new Error('assertion');
+ const cleanupFailure = new Error('cleanup');
+ const operation = withFixture(
+ {
+ create: () => new Document(),
+ globals: {},
+ dispose() {
+ calls.push('dispose');
+ },
+ },
+ ({ cleanup }) => {
+ cleanup(() => {
+ calls.push('first');
+ });
+ cleanup(() => {
+ calls.push('second');
+ throw cleanupFailure;
+ });
+ throw assertionFailure;
+ },
+ );
+ await expect(operation).rejects.toMatchObject({ errors: [assertionFailure, cleanupFailure] });
+ expect(calls).toEqual(['second', 'first', 'dispose']);
+ });
+ it('disposes a document when its globals resolver throws', async () => {
+ let disposed = 0;
+ const failure = new Error('globals');
+ await expect(
+ withFixture(
+ {
+ create: () => new Document(),
+ globals() {
+ throw failure;
+ },
+ dispose() {
+ disposed++;
+ },
+ },
+ () => expect.fail('Case must not start'),
+ ),
+ ).rejects.toBe(failure);
+ expect(disposed).toBe(1);
+ });
+});
+
+describe('host input fixture', () => {
+ it('rejects enabled input features without a driver before registering any suite', () => {
+ const recording = recordingRunner();
+ expect(() =>
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: { ...domFeatures },
+ create: () => new Document(),
+ globals: {},
+ }),
+ ).toThrow('requires an input driver');
+ expect(recording.suites).toEqual([]);
+ expect(recording.tests).toEqual([]);
+ });
+
+ it('does not construct an input session for cases that do not use it', async () => {
+ await withFixture(
+ {
+ create: () => ({ version: 'partial' }),
+ globals: {},
+ input() {
+ return expect.fail('Unused input must not be constructed');
+ },
+ },
+ ({ document }) => expect(document).toEqual({ version: 'partial' }),
+ );
+ });
+
+ it('constructs one session per case with the original inferred document type', async () => {
+ const subject = { host: 'test' };
+ const constructed: (typeof subject)[] = [];
+ let resets = 0;
+ for (let index = 0; index < 2; index++) {
+ await withFixture(
+ {
+ create: () => subject,
+ globals: {},
+ input(document) {
+ expectTypeOf(document).toEqualTypeOf();
+ constructed.push(document);
+ return {
+ async reset() {
+ resets++;
+ },
+ };
+ },
+ },
+ (fixture) => expect(fixture.input).toBe(fixture.input),
+ );
+ }
+ expect(constructed).toEqual([subject, subject]);
+ expect(constructed[0]).toBe(subject);
+ expect(resets).toBe(2);
+ });
+
+ it('awaits host input before advancing to the next action and preserves method receivers', async () => {
+ const calls: string[] = [];
+ let release: (() => void) | undefined;
+ const down = new Promise((resolve) => {
+ release = resolve;
+ });
+ const keyboard = {
+ async keyDown(key: string) {
+ expect(this).toBe(keyboard);
+ calls.push(`down:${key}`);
+ await down;
+ },
+ async keyUp(key: string) {
+ expect(this).toBe(keyboard);
+ calls.push(`up:${key}`);
+ },
+ };
+ await withFixture(
+ {
+ create: () => null,
+ globals: {},
+ input: () => ({
+ keyboard,
+ async reset() {
+ calls.push('reset');
+ },
+ }),
+ },
+ async ({ input }) => {
+ const pressed = input.press('Enter');
+ await Promise.resolve();
+ expect(calls).toEqual(['down:Enter']);
+ release!();
+ await pressed;
+ expect(calls).toEqual(['down:Enter', 'up:Enter']);
+ },
+ );
+ expect(calls.at(-1)).toBe('reset');
+ });
+
+ it('releases held input before removing targets and disposes after reset failures', async () => {
+ const calls: string[] = [];
+ const assertion = new Error('case failure');
+ const reset = new Error('reset failure');
+ const driver: DOMInputDriver = {
+ keyboard: {
+ async keyDown() {
+ calls.push('down');
+ },
+ async keyUp() {},
+ },
+ async reset() {
+ calls.push('reset');
+ throw reset;
+ },
+ };
+ await expect(
+ withFixture(
+ {
+ create: () => null,
+ globals: {},
+ input: () => driver,
+ dispose() {
+ calls.push('dispose');
+ },
+ },
+ async ({ input, cleanup }) => {
+ cleanup(() => {
+ calls.push('remove');
+ });
+ await input.keyDown(' ');
+ throw assertion;
+ },
+ ),
+ ).rejects.toMatchObject({ errors: [assertion, reset] });
+ expect(calls).toEqual(['down', 'reset', 'remove', 'dispose']);
+ });
+
+ it('reports missing capabilities without falling back to synthetic events', async () => {
+ let resets = 0;
+ await withFixture(
+ {
+ create: () => null,
+ globals: {},
+ input: () => ({
+ async reset() {
+ resets++;
+ },
+ }),
+ },
+ async ({ input }) => {
+ await expect(input.press('Enter')).rejects.toThrow('input.keyboard');
+ await expect(input.insertText('text')).rejects.toThrow('input.text');
+ await expect(input.down()).rejects.toThrow('input.mouse');
+ },
+ );
+ expect(resets).toBe(1);
+ await expect(
+ withFixture({ create: () => null, globals: {} }, ({ input }) => input.press('Enter')),
+ ).rejects.toBeInstanceOf(MissingInputError);
+ });
+
+ it('disposes fixtures when the input factory fails', async () => {
+ const failure = new Error('input setup');
+ let disposed = false;
+ await expect(
+ withFixture(
+ {
+ create: () => null,
+ globals: {},
+ input() {
+ throw failure;
+ },
+ dispose() {
+ disposed = true;
+ },
+ },
+ ({ input }) => input.press('Enter'),
+ ),
+ ).rejects.toBe(failure);
+ expect(disposed).toBe(true);
+ });
+});
+
+describe('requested document creation', () => {
+ it('forwards the exact request and awaits creation and disposal after a case fails', async () => {
+ const request: DOMDocumentOptions = {
+ contentType: 'application/xhtml+xml',
+ content: ' ',
+ fragment: 'target',
+ };
+ const created = Promise.withResolvers();
+ const disposalStarted = Promise.withResolvers();
+ const disposalFinished = Promise.withResolvers();
+ const subject = new Document();
+ const calls: string[] = [];
+ const failure = new Error('case failure');
+ let settled = false;
+ const operation = withFixture(
+ {
+ document: request,
+ create(options) {
+ expect(options).toBe(request);
+ calls.push('create');
+ return created.promise;
+ },
+ globals(document) {
+ expectTypeOf(document).toEqualTypeOf();
+ expect(document).toBe(subject);
+ calls.push('globals');
+ return {};
+ },
+ async dispose(document) {
+ expectTypeOf(document).toEqualTypeOf();
+ expect(document).toBe(subject);
+ calls.push('dispose started');
+ disposalStarted.resolve();
+ await disposalFinished.promise;
+ calls.push('dispose finished');
+ },
+ },
+ ({ document, cleanup }) => {
+ expect(document).toBe(subject);
+ calls.push('case');
+ cleanup(() => {
+ calls.push('cleanup');
+ });
+ throw failure;
+ },
+ );
+ const result = operation.catch((error: unknown) => {
+ settled = true;
+ return error;
+ });
+ expect(calls).toEqual(['create']);
+ created.resolve(subject);
+ await disposalStarted.promise;
+ expect(calls).toEqual(['create', 'globals', 'case', 'cleanup', 'dispose started']);
+ expect(settled).toBe(false);
+ disposalFinished.resolve();
+ expect(await result).toBe(failure);
+ expect(calls.at(-1)).toBe('dispose finished');
+ });
+ it('does not start a case or dispose an absent document when asynchronous creation rejects', async () => {
+ const failure = new Error('document creation failed');
+ const calls: string[] = [];
+ await expect(
+ withFixture(
+ {
+ async create(options) {
+ expect(options).toBeUndefined();
+ calls.push('create');
+ await Promise.resolve();
+ throw failure;
+ },
+ globals() {
+ calls.push('globals');
+ return {};
+ },
+ dispose() {
+ calls.push('dispose');
+ },
+ },
+ () => {
+ calls.push('case');
+ },
+ ),
+ ).rejects.toBe(failure);
+ expect(calls).toEqual(['create']);
+ });
+ it('uses fresh asynchronous documents for registered cases without document requests', async () => {
+ const recording = recordingRunner();
+ const created: Document[] = [],
+ disposed: Document[] = [];
+ testDOMCompatibility({
+ runner: recording.runner,
+ features: flags(['events.dispatch.target']),
+ async create(request) {
+ expect(request).toBeUndefined();
+ await Promise.resolve();
+ const document = new Document();
+ created.push(document);
+ return document;
+ },
+ globals(document) {
+ expectTypeOf(document).toEqualTypeOf();
+ expect(document).toBe(created.at(-1));
+ return { Event };
+ },
+ async dispose(document) {
+ await Promise.resolve();
+ disposed.push(document);
+ },
+ });
+ expect(created).toHaveLength(0);
+ for (const test of recording.tests) await test.run();
+ expect(created).toHaveLength(3);
+ expect(new Set(created).size).toBe(3);
+ expect(disposed).toEqual(created);
+ });
+});
diff --git a/packages/web-platform-tests/test/wpt/HTML5LIB-LICENSE.txt b/packages/web-platform-tests/test/wpt/HTML5LIB-LICENSE.txt
new file mode 100644
index 0000000..8812371
--- /dev/null
+++ b/packages/web-platform-tests/test/wpt/HTML5LIB-LICENSE.txt
@@ -0,0 +1,21 @@
+Copyright (c) 2006-2013 James Graham, Geoffrey Sneddon, and
+other contributors
+
+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 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/web-platform-tests/test/wpt/LICENSE b/packages/web-platform-tests/test/wpt/LICENSE
new file mode 100644
index 0000000..39c46d0
--- /dev/null
+++ b/packages/web-platform-tests/test/wpt/LICENSE
@@ -0,0 +1,11 @@
+# The 3-Clause BSD License
+
+Copyright © web-platform-tests contributors
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/web-platform-tests/test/wpt/README.md b/packages/web-platform-tests/test/wpt/README.md
new file mode 100644
index 0000000..e0df849
--- /dev/null
+++ b/packages/web-platform-tests/test/wpt/README.md
@@ -0,0 +1,17 @@
+# Adapted Web Platform Tests
+
+[Option selectedness cases](../../src/compatibility/elements/option/selectedness.ts) adapt [option-selected.html at b4b303c8dca22989c43e2d76fce199c36d4b77a7](https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/html/semantics/forms/the-option-element/option-selected.html).
+
+Copyright © web-platform-tests contributors. Original option selectedness author: Corey Farwell. The upstream BSD 3-Clause license is retained in [LICENSE](LICENSE). The adaptation uses typed fixtures, runner-supplied matchers, and ordinary test registration; it does not evaluate upstream HTML or install globals.
+
+Other feature modules link WPT as coverage references. See [standards references and licenses](../../docs/references.md).
+
+The adaptations at `b719612f963a05ece9c89a7dd7598c1c59618874` are catalogued with
+file-specific authorship and scope in the [state/style](../../docs/wpt-state-styles.md),
+[CSS/forms](../../docs/wpt-css-forms.md), [shadow/input](../../docs/wpt-shadow-input.md),
+and [ranges/parsing](../../docs/wpt-ranges-parsing.md) inventories. The same retained
+WPT license applies; upstream source comments identify additional notices where needed.
+
+The [selector adaptations](../../docs/wpt-selectors.md) preserve the shared
+query-selector/matches matrix and the listed standalone selector regressions.
+Their complete source data and HTML/XHTML fixture markup use the same WPT license.
diff --git a/packages/web-platform-tests/tsconfig.json b/packages/web-platform-tests/tsconfig.json
new file mode 100644
index 0000000..5c73d1b
--- /dev/null
+++ b/packages/web-platform-tests/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "@bomb.sh/tools/tsconfig.json",
+ "compilerOptions": {
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "types": [
+ "node"
+ ]
+ },
+ "include": [
+ "src/**/*.ts",
+ "test/**/*.ts"
+ ],
+ "exclude": [
+ "test/browser/dist"
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ac918e5..d432b6c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -16,16 +16,16 @@ importers:
devDependencies:
'@bomb.sh/tools':
specifier: ^0.8.0
- version: 0.8.0(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+ version: 0.8.0(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
'@codspeed/vitest-plugin':
specifier: ^6.1.0
- version: 6.1.0(tinybench@6.2.2)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))
+ version: 6.1.0(tinybench@6.2.2)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))
'@termless/core':
specifier: ^0.10.2
version: 0.10.2(node-pty@1.2.0-beta.15)
'@termless/test':
specifier: ^0.10.2
- version: 0.10.2(@termless/core@0.10.2(node-pty@1.2.0-beta.15))(playwright@1.64.1)(vitest@5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))
+ version: 0.10.2(@termless/core@0.10.2(node-pty@1.2.0-beta.15))(playwright@1.64.1)(vitest@5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))
'@types/node':
specifier: ^22.20.0
version: 22.20.0
@@ -34,13 +34,42 @@ importers:
version: 1.2.0-beta.15
vitest:
specifier: 5.0.5
- version: 5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+ version: 5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+
+ packages/web-platform-tests:
+ dependencies:
+ typebox:
+ specifier: ^1.4.3
+ version: 1.4.3
+ devDependencies:
+ '@standard-schema/spec':
+ specifier: ^1.1.0
+ version: 1.1.0
+ '@vitest/expect':
+ specifier: 5.0.5
+ version: 5.0.5
+ chai:
+ specifier: ^6.2.2
+ version: 6.3.0
+ playwright:
+ specifier: ^1.64.1
+ version: 1.64.1
+ typescript:
+ specifier: ^6.0.2
+ version: 6.0.3
+ vite:
+ specifier: ^8.1.0
+ version: 8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)
packages/ui:
dependencies:
'@bomb.sh/tty':
specifier: 'catalog:'
version: 0.10.0
+ devDependencies:
+ '@bomb.sh/web-platform-tests':
+ specifier: workspace:*
+ version: link:../web-platform-tests
packages:
@@ -722,12 +751,6 @@ packages:
cpu: [arm64]
os: [android]
- '@rolldown/binding-android-arm64@1.0.3':
- resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [android]
-
'@rolldown/binding-android-arm64@1.2.15':
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -740,12 +763,6 @@ packages:
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-arm64@1.0.3':
- resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [darwin]
-
'@rolldown/binding-darwin-arm64@1.2.15':
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -758,12 +775,6 @@ packages:
cpu: [x64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.0.3':
- resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [darwin]
-
'@rolldown/binding-darwin-x64@1.2.15':
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -776,12 +787,6 @@ packages:
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-freebsd-x64@1.0.3':
- resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [freebsd]
-
'@rolldown/binding-freebsd-x64@1.2.15':
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -794,12 +799,6 @@ packages:
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
- resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm]
- os: [linux]
-
'@rolldown/binding-linux-arm-gnueabihf@1.2.15':
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -812,12 +811,6 @@ packages:
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.0.3':
- resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
-
'@rolldown/binding-linux-arm64-gnu@1.2.15':
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -830,12 +823,6 @@ packages:
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-arm64-musl@1.0.3':
- resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
-
'@rolldown/binding-linux-arm64-musl@1.2.15':
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -848,12 +835,6 @@ packages:
cpu: [ppc64]
os: [linux]
- '@rolldown/binding-linux-ppc64-gnu@1.0.3':
- resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [ppc64]
- os: [linux]
-
'@rolldown/binding-linux-ppc64-gnu@1.2.15':
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -866,12 +847,6 @@ packages:
cpu: [s390x]
os: [linux]
- '@rolldown/binding-linux-s390x-gnu@1.0.3':
- resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [s390x]
- os: [linux]
-
'@rolldown/binding-linux-s390x-gnu@1.2.15':
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -884,12 +859,6 @@ packages:
cpu: [x64]
os: [linux]
- '@rolldown/binding-linux-x64-gnu@1.0.3':
- resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
-
'@rolldown/binding-linux-x64-gnu@1.2.15':
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -902,12 +871,6 @@ packages:
cpu: [x64]
os: [linux]
- '@rolldown/binding-linux-x64-musl@1.0.3':
- resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
-
'@rolldown/binding-linux-x64-musl@1.2.15':
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -920,12 +883,6 @@ packages:
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-openharmony-arm64@1.0.3':
- resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [openharmony]
-
'@rolldown/binding-openharmony-arm64@1.2.15':
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -937,23 +894,12 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
- '@rolldown/binding-wasm32-wasi@1.0.3':
- resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [wasm32]
-
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-arm64-msvc@1.0.3':
- resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [win32]
-
'@rolldown/binding-win32-arm64-msvc@1.2.15':
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -966,12 +912,6 @@ packages:
cpu: [x64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.0.3':
- resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [win32]
-
'@rolldown/binding-win32-x64-msvc@1.2.15':
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -984,6 +924,9 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@termless/core@0.10.2':
resolution: {integrity: sha512-xr/TvnhzB0R7g24W3EcwP8UQ+ztYd5ybk4oxR/PkcEh92O5NRjl9mCkqz/wvTcGX3u77AcmA8w5Ox16T22DOCA==}
engines: {bun: '>=1.0.0', node: '>=23.6.0'}
@@ -1145,6 +1088,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@vitest/expect@5.0.5':
+ resolution: {integrity: sha512-mDBbYrXKpc+4TcSDwBW6adCMtPnide6Z1g8bI6gL0tY+nhdBxtGoOFW/kMrLDt6lJg3xjEUBkrY9yIPlgzpuig==}
+
'@vitest/mocker@5.0.5':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
peerDependencies:
@@ -1156,9 +1102,15 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@5.0.5':
+ resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==}
+
'@vitest/spy@5.0.5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
+ '@vitest/utils@5.0.5':
+ resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==}
+
'@yuku-core/binding-android-arm64@0.19.0':
resolution: {integrity: sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==}
cpu: [arm64]
@@ -1252,6 +1204,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1410,74 +1365,74 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
- lightningcss-android-arm64@1.32.0:
- resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ lightningcss-android-arm64@1.33.0:
+ resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
- lightningcss-darwin-arm64@1.32.0:
- resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ lightningcss-darwin-arm64@1.33.0:
+ resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
- lightningcss-darwin-x64@1.32.0:
- resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ lightningcss-darwin-x64@1.33.0:
+ resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
- lightningcss-freebsd-x64@1.32.0:
- resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ lightningcss-freebsd-x64@1.33.0:
+ resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
- lightningcss-linux-arm-gnueabihf@1.32.0:
- resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
- lightningcss-linux-arm64-gnu@1.32.0:
- resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ lightningcss-linux-arm64-gnu@1.33.0:
+ resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-arm64-musl@1.32.0:
- resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ lightningcss-linux-arm64-musl@1.33.0:
+ resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-x64-gnu@1.32.0:
- resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ lightningcss-linux-x64-gnu@1.33.0:
+ resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-linux-x64-musl@1.32.0:
- resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ lightningcss-linux-x64-musl@1.33.0:
+ resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-win32-arm64-msvc@1.32.0:
- resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ lightningcss-win32-arm64-msvc@1.33.0:
+ resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
- lightningcss-win32-x64-msvc@1.32.0:
- resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ lightningcss-win32-x64-msvc@1.33.0:
+ resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
- lightningcss@1.32.0:
- resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ lightningcss@1.33.0:
+ resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
magic-string@1.4.3:
@@ -1502,8 +1457,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.12:
- resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ nanoid@3.3.20:
+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1580,8 +1535,8 @@ packages:
engines: {node: '>=20'}
hasBin: true
- postcss@8.5.15:
- resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ postcss@8.5.32:
+ resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
engines: {node: ^10 || ^12 || >=14}
proxy-from-env@2.1.0:
@@ -1626,11 +1581,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
- rolldown@1.0.3:
- resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
-
rolldown@1.2.15:
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1644,8 +1594,8 @@ packages:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
- source-map-js@1.2.1:
- resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
stack-trace@1.0.0-pre2:
@@ -1675,6 +1625,10 @@ packages:
resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==}
engines: {node: ^20.0.0 || >=22.0.0}
+ tinyrainbow@3.2.0:
+ resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==}
+ engines: {node: '>=14.0.0'}
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -1716,6 +1670,14 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ typebox@1.4.3:
+ resolution: {integrity: sha512-u3nyf7ZciAv1GOijVpI0TDt9At5wmEi7lSHSlRrnjuThv1DcB7fgGBqqrz+fGZ54jAfTpOOX0JXwxSIg3pnxQA==}
+
+ typescript@6.0.3:
+ resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
typescript@7.0.2:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'}
@@ -1748,13 +1710,13 @@ packages:
resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==}
engines: {node: '>=18.12.0'}
- vite@8.0.16:
- resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
+ vite@8.3.5:
+ resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
- '@vitejs/devtools': ^0.1.18
+ '@vitejs/devtools': ^0.7.1 || ^0.8.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
@@ -1874,7 +1836,7 @@ snapshots:
'@bomb.sh/args@0.3.1': {}
- '@bomb.sh/tools@0.8.0(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))':
+ '@bomb.sh/tools@0.8.0(@types/node@22.20.0)(oxc-resolver@11.20.0)(unrun@0.2.39)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))':
dependencies:
'@bomb.sh/args': 0.3.1
'@humanfs/node': 0.16.8
@@ -1886,8 +1848,8 @@ snapshots:
tinyexec: 1.3.1
tsdown: 0.23.1(oxc-resolver@11.20.0)(publint@0.3.21)(typescript@7.0.2)(unrun@0.2.39)
typescript: 7.0.2
- vitest: 5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
- vitest-ansi-serializer: 0.3.1(vitest@5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))
+ vitest: 5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+ vitest-ansi-serializer: 0.3.1(vitest@5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))
yaml: 2.9.1
transitivePeerDependencies:
- '@arethetypeswrong/core'
@@ -1932,12 +1894,12 @@ snapshots:
- debug
- supports-color
- '@codspeed/vitest-plugin@6.1.0(tinybench@6.2.2)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))':
+ '@codspeed/vitest-plugin@6.1.0(tinybench@6.2.2)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))':
dependencies:
'@codspeed/core': 6.1.0
tinybench: 6.2.2
- vite: 8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)
- vitest: 5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+ vite: 8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)
+ vitest: 5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
transitivePeerDependencies:
- debug
- supports-color
@@ -2346,108 +2308,72 @@ snapshots:
'@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-android-arm64@1.0.3':
- optional: true
-
'@rolldown/binding-android-arm64@1.2.15':
optional: true
'@rolldown/binding-darwin-arm64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-darwin-arm64@1.0.3':
- optional: true
-
'@rolldown/binding-darwin-arm64@1.2.15':
optional: true
'@rolldown/binding-darwin-x64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-darwin-x64@1.0.3':
- optional: true
-
'@rolldown/binding-darwin-x64@1.2.15':
optional: true
'@rolldown/binding-freebsd-x64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-freebsd-x64@1.0.3':
- optional: true
-
'@rolldown/binding-freebsd-x64@1.2.15':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
- optional: true
-
'@rolldown/binding-linux-arm-gnueabihf@1.2.15':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.0.3':
- optional: true
-
'@rolldown/binding-linux-arm64-gnu@1.2.15':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.0.3':
- optional: true
-
'@rolldown/binding-linux-arm64-musl@1.2.15':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-ppc64-gnu@1.0.3':
- optional: true
-
'@rolldown/binding-linux-ppc64-gnu@1.2.15':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-s390x-gnu@1.0.3':
- optional: true
-
'@rolldown/binding-linux-s390x-gnu@1.2.15':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.0.3':
- optional: true
-
'@rolldown/binding-linux-x64-gnu@1.2.15':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
optional: true
- '@rolldown/binding-linux-x64-musl@1.0.3':
- optional: true
-
'@rolldown/binding-linux-x64-musl@1.2.15':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
optional: true
- '@rolldown/binding-openharmony-arm64@1.0.3':
- optional: true
-
'@rolldown/binding-openharmony-arm64@1.2.15':
optional: true
@@ -2458,28 +2384,15 @@ snapshots:
'@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
- '@rolldown/binding-wasm32-wasi@1.0.3':
- dependencies:
- '@emnapi/core': 1.10.0
- '@emnapi/runtime': 1.10.0
- '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
- optional: true
-
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.0.3':
- optional: true
-
'@rolldown/binding-win32-arm64-msvc@1.2.15':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.0.3':
- optional: true
-
'@rolldown/binding-win32-x64-msvc@1.2.15':
optional: true
@@ -2488,6 +2401,8 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@termless/core@0.10.2(node-pty@1.2.0-beta.15)':
optionalDependencies:
'@napi-rs/canvas': 1.0.11
@@ -2499,10 +2414,10 @@ snapshots:
playwright: 1.64.1
upng-js: 2.1.0
- '@termless/test@0.10.2(@termless/core@0.10.2(node-pty@1.2.0-beta.15))(playwright@1.64.1)(vitest@5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))':
+ '@termless/test@0.10.2(@termless/core@0.10.2(node-pty@1.2.0-beta.15))(playwright@1.64.1)(vitest@5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)))':
dependencies:
'@termless/core': 0.10.2(node-pty@1.2.0-beta.15)
- vitest: 5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+ vitest: 5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
optionalDependencies:
'@napi-rs/canvas': 1.0.11
'@resvg/resvg-js': 2.6.2
@@ -2589,17 +2504,36 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
- '@vitest/mocker@5.0.5(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))':
+ '@vitest/expect@5.0.5':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 5.0.5
+ '@vitest/utils': 5.0.5
+ chai: 6.3.0
+ tinyrainbow: 3.2.0
+
+ '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.5
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
- vite: 8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)
+ vite: 8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)
+
+ '@vitest/pretty-format@5.0.5':
+ dependencies:
+ tinyrainbow: 3.2.0
'@vitest/spy@5.0.5': {}
+ '@vitest/utils@5.0.5':
+ dependencies:
+ '@vitest/pretty-format': 5.0.5
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.2.0
+
'@yuku-core/binding-android-arm64@0.19.0':
optional: true
@@ -2671,6 +2605,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
+ convert-source-map@2.0.0: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2816,54 +2752,54 @@ snapshots:
yaml: 2.9.1
zod: 4.4.3
- lightningcss-android-arm64@1.32.0:
+ lightningcss-android-arm64@1.33.0:
optional: true
- lightningcss-darwin-arm64@1.32.0:
+ lightningcss-darwin-arm64@1.33.0:
optional: true
- lightningcss-darwin-x64@1.32.0:
+ lightningcss-darwin-x64@1.33.0:
optional: true
- lightningcss-freebsd-x64@1.32.0:
+ lightningcss-freebsd-x64@1.33.0:
optional: true
- lightningcss-linux-arm-gnueabihf@1.32.0:
+ lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
- lightningcss-linux-arm64-gnu@1.32.0:
+ lightningcss-linux-arm64-gnu@1.33.0:
optional: true
- lightningcss-linux-arm64-musl@1.32.0:
+ lightningcss-linux-arm64-musl@1.33.0:
optional: true
- lightningcss-linux-x64-gnu@1.32.0:
+ lightningcss-linux-x64-gnu@1.33.0:
optional: true
- lightningcss-linux-x64-musl@1.32.0:
+ lightningcss-linux-x64-musl@1.33.0:
optional: true
- lightningcss-win32-arm64-msvc@1.32.0:
+ lightningcss-win32-arm64-msvc@1.33.0:
optional: true
- lightningcss-win32-x64-msvc@1.32.0:
+ lightningcss-win32-x64-msvc@1.33.0:
optional: true
- lightningcss@1.32.0:
+ lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
- lightningcss-android-arm64: 1.32.0
- lightningcss-darwin-arm64: 1.32.0
- lightningcss-darwin-x64: 1.32.0
- lightningcss-freebsd-x64: 1.32.0
- lightningcss-linux-arm-gnueabihf: 1.32.0
- lightningcss-linux-arm64-gnu: 1.32.0
- lightningcss-linux-arm64-musl: 1.32.0
- lightningcss-linux-x64-gnu: 1.32.0
- lightningcss-linux-x64-musl: 1.32.0
- lightningcss-win32-arm64-msvc: 1.32.0
- lightningcss-win32-x64-msvc: 1.32.0
+ lightningcss-android-arm64: 1.33.0
+ lightningcss-darwin-arm64: 1.33.0
+ lightningcss-darwin-x64: 1.33.0
+ lightningcss-freebsd-x64: 1.33.0
+ lightningcss-linux-arm-gnueabihf: 1.33.0
+ lightningcss-linux-arm64-gnu: 1.33.0
+ lightningcss-linux-arm64-musl: 1.33.0
+ lightningcss-linux-x64-gnu: 1.33.0
+ lightningcss-linux-x64-musl: 1.33.0
+ lightningcss-win32-arm64-msvc: 1.33.0
+ lightningcss-win32-x64-msvc: 1.33.0
magic-string@1.4.3:
dependencies:
@@ -2881,7 +2817,7 @@ snapshots:
ms@2.1.3: {}
- nanoid@3.3.12: {}
+ nanoid@3.3.20: {}
node-addon-api@7.1.1: {}
@@ -2995,19 +2931,17 @@ snapshots:
picomatch@4.0.7: {}
- playwright-core@1.64.1:
- optional: true
+ playwright-core@1.64.1: {}
playwright@1.64.1:
dependencies:
playwright-core: 1.64.1
- optional: true
- postcss@8.5.15:
+ postcss@8.5.32:
dependencies:
- nanoid: 3.3.12
+ nanoid: 3.3.20
picocolors: 1.1.1
- source-map-js: 1.2.1
+ source-map-js: 1.2.2
proxy-from-env@2.1.0: {}
@@ -3058,27 +2992,6 @@ snapshots:
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
optional: true
- rolldown@1.0.3:
- dependencies:
- '@oxc-project/types': 0.133.0
- '@rolldown/pluginutils': 1.0.1
- optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.3
- '@rolldown/binding-darwin-arm64': 1.0.3
- '@rolldown/binding-darwin-x64': 1.0.3
- '@rolldown/binding-freebsd-x64': 1.0.3
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.3
- '@rolldown/binding-linux-arm64-gnu': 1.0.3
- '@rolldown/binding-linux-arm64-musl': 1.0.3
- '@rolldown/binding-linux-ppc64-gnu': 1.0.3
- '@rolldown/binding-linux-s390x-gnu': 1.0.3
- '@rolldown/binding-linux-x64-gnu': 1.0.3
- '@rolldown/binding-linux-x64-musl': 1.0.3
- '@rolldown/binding-openharmony-arm64': 1.0.3
- '@rolldown/binding-wasm32-wasi': 1.0.3
- '@rolldown/binding-win32-arm64-msvc': 1.0.3
- '@rolldown/binding-win32-x64-msvc': 1.0.3
-
rolldown@1.2.15:
dependencies:
'@oxc-project/types': 0.155.0
@@ -3106,7 +3019,7 @@ snapshots:
smol-toml@1.6.1: {}
- source-map-js@1.2.1: {}
+ source-map-js@1.2.2: {}
stack-trace@1.0.0-pre2: {}
@@ -3125,6 +3038,8 @@ snapshots:
tinypool@2.2.0: {}
+ tinyrainbow@3.2.0: {}
+
tree-kill@1.2.2: {}
tsdown@0.23.1(oxc-resolver@11.20.0)(publint@0.3.21)(typescript@7.0.2)(unrun@0.2.39):
@@ -3157,6 +3072,10 @@ snapshots:
tslib@2.8.1:
optional: true
+ typebox@1.4.3: {}
+
+ typescript@6.0.3: {}
+
typescript@7.0.2:
optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2
@@ -3201,12 +3120,12 @@ snapshots:
verkit@0.5.0: {}
- vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1):
+ vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1):
dependencies:
- lightningcss: 1.32.0
+ lightningcss: 1.33.0
picomatch: 4.0.7
- postcss: 8.5.15
- rolldown: 1.0.3
+ postcss: 8.5.32
+ rolldown: 1.2.15
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.20.0
@@ -3214,14 +3133,14 @@ snapshots:
jiti: 2.7.0
yaml: 2.9.1
- vitest-ansi-serializer@0.3.1(vitest@5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))):
+ vitest-ansi-serializer@0.3.1(vitest@5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))):
dependencies:
- vitest: 5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+ vitest: 5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
- vitest@5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)):
+ vitest@5.0.5(@types/node@22.20.0)(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
- '@vitest/mocker': 5.0.5(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
+ '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -3232,7 +3151,7 @@ snapshots:
tinybench: 6.2.2
tinyexec: 1.3.1
tinyglobby: 0.2.17
- vite: 8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)
+ vite: 8.3.5(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 22.20.0