diff --git a/.changeset/element-query-methods.md b/.changeset/element-query-methods.md
new file mode 100644
index 00000000..8826c5da
--- /dev/null
+++ b/.changeset/element-query-methods.md
@@ -0,0 +1,7 @@
+---
+'@remote-dom/polyfill': minor
+---
+
+Add `getElementById()` to `Document` and `DocumentFragment`, with reflected `Element.id` properties. Add `getElementsByTagName()` to `Document` and `Element`, supporting HTML, non-HTML, and wildcard descendant searches. `querySelector` and `querySelectorAll` accept a pre-parsed `Matcher[]` in addition to string selectors, with `MatcherType`, `Combinator`, `Matcher`, and `Part` exported from `selectors.ts`; `getElementById` and `getElementsByTagName` delegate to this shared selector engine instead of independent tree-walk implementations.
+
+Fixed `insertBefore()` leaving the previous sibling pointing at the reference node when inserting before a middle child, causing `NEXT` traversals (including `getElementById`) to skip the inserted subtree even though `childNodes` contained it, and return the inserted child as required by the DOM specification. Fixed `appendChild()` to return the appended child and `NodeList.item()` to return `null` for out-of-range indexes. Fixed case-insensitive HTML tag-name matching in the selector engine so `querySelector('DIV')` now matches `
` per the CSS spec. Fixed CSS-escaping issues so `getElementById` matches ids containing special characters (`.`, `:`, `#`, etc.) literally instead of treating them as selector syntax.
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..1eb9a6f7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+packages/wpt-runner/capabilities.tsv whitespace=-blank-at-eol
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index daa811b7..c2cbe5e8 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -46,3 +46,19 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
+
+ wpt:
+ name: Web Platform Tests š
+ runs-on: ubuntu-22.04
+ timeout-minutes: 15
+ env:
+ WPT_CACHE_DIR: ${{ github.workspace }}/.cache/wpt
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: ./.github/workflows/actions/prepare
+ - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+ with:
+ path: .cache/wpt
+ key: ${{ runner.os }}-wpt-${{ hashFiles('packages/wpt-runner/wpt.lock.json') }}
+ - run: pnpm exec playwright install --with-deps chromium
+ - run: pnpm run test:wpt
diff --git a/.gitignore b/.gitignore
index 44f54502..af97f49a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
build/
node_modules/
+/.cache/wpt/
.DS_STORE
packages/*/bin/
*.log
diff --git a/.prettierignore b/.prettierignore
index b0259c7d..93a396f1 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,4 @@
build/
node_modules/
+.cache/wpt/
pnpm-lock.yaml
diff --git a/package.json b/package.json
index 58a885d4..7d37eb07 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,11 @@
"format": "prettier --write --cache .",
"lint": "prettier --check --cache .",
"test": "vitest",
+ "test:wpt": "pnpm --filter @remote-dom/wpt-runner wpt",
"type-check": "tsc --build --pretty",
+ "wpt:dev": "pnpm --filter @remote-dom/wpt-runner dev",
+ "wpt:format": "pnpm --filter @remote-dom/wpt-runner format:capabilities",
+ "wpt:prepare": "pnpm --filter @remote-dom/wpt-runner wpt:prepare",
"version-bump": "changeset version && pnpm install --no-frozen-lockfile",
"deploy": "pnpm publish -r",
"example:getting-started": "pnpm run --filter example-getting-started start",
diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts
index 688a8896..625cde69 100644
--- a/packages/polyfill/source/Document.ts
+++ b/packages/polyfill/source/Document.ts
@@ -17,7 +17,12 @@ import {Text} from './Text.ts';
import {Comment} from './Comment.ts';
import {DocumentFragment} from './DocumentFragment.ts';
import {HTMLTemplateElement} from './HTMLTemplateElement.ts';
-import {isParentNode, cloneNode} from './shared.ts';
+import {
+ isParentNode,
+ cloneNode,
+ getElementById as findElementById,
+ getElementsByTagName as findElementsByTagName,
+} from './shared.ts';
import {HTMLBodyElement} from './HTMLBodyElement.ts';
import {HTMLHeadElement} from './HTMLHeadElement.ts';
import {HTMLHtmlElement} from './HTMLHtmlElement.ts';
@@ -70,6 +75,14 @@ export class Document extends ParentNode {
return new Event('');
}
+ getElementById(elementId: string) {
+ return findElementById(this, elementId);
+ }
+
+ getElementsByTagName(qualifiedName: string) {
+ return findElementsByTagName(this, qualifiedName);
+ }
+
importNode(node: Node, deep?: boolean) {
return cloneNode(node, deep, this);
}
diff --git a/packages/polyfill/source/DocumentFragment.ts b/packages/polyfill/source/DocumentFragment.ts
index 77bc33c3..0853da10 100644
--- a/packages/polyfill/source/DocumentFragment.ts
+++ b/packages/polyfill/source/DocumentFragment.ts
@@ -1,5 +1,6 @@
import {NAME, OWNER_DOCUMENT, NodeType} from './constants.ts';
import {ParentNode} from './ParentNode.ts';
+import {getElementById as findElementById} from './shared.ts';
export class DocumentFragment extends ParentNode {
nodeType = NodeType.DOCUMENT_FRAGMENT_NODE;
@@ -7,4 +8,8 @@ export class DocumentFragment extends ParentNode {
[OWNER_DOCUMENT] = (typeof window !== 'undefined'
? window.document
: null) as any;
+
+ getElementById(elementId: string) {
+ return findElementById(this, elementId);
+ }
}
diff --git a/packages/polyfill/source/Element.ts b/packages/polyfill/source/Element.ts
index d649b66a..8f6e3837 100644
--- a/packages/polyfill/source/Element.ts
+++ b/packages/polyfill/source/Element.ts
@@ -3,6 +3,7 @@ import {ParentNode} from './ParentNode.ts';
import {NamedNodeMap} from './NamedNodeMap.ts';
import {Attr} from './Attr.ts';
import {serializeNode, serializeChildren, parseHtml} from './serialization.ts';
+import {getElementsByTagName as findElementsByTagName} from './shared.ts';
export class Element extends ParentNode {
static readonly observedAttributes?: string[];
@@ -22,6 +23,14 @@ export class Element extends ParentNode {
[anyProperty: string]: any;
+ get id() {
+ return this.getAttribute('id') ?? '';
+ }
+
+ set id(id: string) {
+ this.setAttribute('id', String(id));
+ }
+
get slot() {
return this.getAttribute('slot') ?? '';
}
@@ -47,6 +56,10 @@ export class Element extends ParentNode {
return [...this.attributes].map((attr) => attr.name);
}
+ getElementsByTagName(qualifiedName: string) {
+ return findElementsByTagName(this, qualifiedName);
+ }
+
get firstElementChild() {
return this.children[0] ?? null;
}
diff --git a/packages/polyfill/source/NodeList.ts b/packages/polyfill/source/NodeList.ts
index 2f977f21..7fcbf0fc 100644
--- a/packages/polyfill/source/NodeList.ts
+++ b/packages/polyfill/source/NodeList.ts
@@ -1,5 +1,5 @@
export class NodeList extends Array {
item(index: number) {
- return this[index];
+ return this[index] ?? null;
}
}
diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts
index b40d0c34..7ed9bd81 100644
--- a/packages/polyfill/source/ParentNode.ts
+++ b/packages/polyfill/source/ParentNode.ts
@@ -18,12 +18,14 @@ export class ParentNode extends ChildNode {
readonly childNodes = new NodeList();
readonly children = new NodeList();
- appendChild(child: Node) {
+ appendChild(child: T) {
this.insertInto(child, null);
+ return child;
}
- insertBefore(child: Node, ref?: Node | null) {
+ insertBefore(child: T, ref?: Node | null) {
this.insertInto(child, ref || null);
+ return child;
}
append(...nodes: (Node | string)[]) {
@@ -121,9 +123,11 @@ export class ParentNode extends ChildNode {
if (before.parentNode !== this) {
throw Error('reference node is not a child of this parent');
}
+ const previous = before[PREV];
child[NEXT] = before;
- child[PREV] = before[PREV];
- if (before[PREV] === null) this[CHILD] = child;
+ child[PREV] = previous;
+ if (previous) previous[NEXT] = child;
+ else this[CHILD] = child;
before[PREV] = child;
} else {
child[NEXT] = null;
diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts
index b00ac065..c53d6220 100644
--- a/packages/polyfill/source/selectors.ts
+++ b/packages/polyfill/source/selectors.ts
@@ -1,11 +1,11 @@
-import {CHILD, NEXT, PARENT, PREV} from './constants.ts';
+import {CHILD, NEXT, PARENT, PREV, NamespaceURI} from './constants.ts';
import {isElementNode} from './shared.ts';
import type {Node} from './Node.ts';
import type {Element} from './Element.ts';
import type {ParentNode} from './ParentNode.ts';
-const enum Combinator {
+export const enum Combinator {
Descendant,
Child,
Sibling,
@@ -13,7 +13,7 @@ const enum Combinator {
Inner,
}
-const enum MatcherType {
+export const enum MatcherType {
Unknown,
Element,
Id,
@@ -23,21 +23,27 @@ const enum MatcherType {
Function,
}
-interface Part {
+export interface Part {
combinator: Combinator;
matchers: Matcher[];
}
-interface Matcher {
+export interface Matcher {
type: MatcherType;
name: string;
value?: string;
}
-const ELEMENT_SELECTOR_TEST = /[a-z]/;
+const ELEMENT_SELECTOR_TEST = /[a-zA-Z]/;
-export function querySelector(within: ParentNode, selector: string) {
- const parts = parseSelector(selector);
+export function querySelector(
+ within: ParentNode,
+ selector: string | Matcher[],
+): Element | null {
+ const parts =
+ typeof selector === 'string'
+ ? parseSelector(selector)
+ : [{combinator: Combinator.Inner, matchers: selector}];
let result: Element | null = null;
const child = within[CHILD];
@@ -50,8 +56,14 @@ export function querySelector(within: ParentNode, selector: string) {
return result;
}
-export function querySelectorAll(within: ParentNode, selector: string) {
- const parts = parseSelector(selector);
+export function querySelectorAll(
+ within: ParentNode,
+ selector: string | Matcher[],
+): Element[] {
+ const parts =
+ typeof selector === 'string'
+ ? parseSelector(selector)
+ : [{combinator: Combinator.Inner, matchers: selector}];
const results: Element[] = [];
const child = within[CHILD];
@@ -239,7 +251,9 @@ function matchesSelectorMatcher(
case MatcherType.Unknown:
return name === '*'; // Universal selector
case MatcherType.Element:
- return element.localName === name;
+ return element.namespaceURI === NamespaceURI.XHTML
+ ? element.localName.toLowerCase() === name.toLowerCase()
+ : element.localName === name;
case MatcherType.Id:
return element.getAttribute('id') === name;
case MatcherType.Class:
diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts
index 587abe22..12abb36a 100644
--- a/packages/polyfill/source/shared.ts
+++ b/packages/polyfill/source/shared.ts
@@ -14,6 +14,8 @@ import type {ParentNode} from './ParentNode.ts';
import type {Element} from './Element.ts';
import type {CharacterData} from './CharacterData.ts';
import type {Text} from './Text.ts';
+import {NodeList} from './NodeList.ts';
+import {querySelector, querySelectorAll, MatcherType} from './selectors.ts';
export function isCharacterData(node: Node): node is CharacterData {
return DATA in node;
@@ -86,6 +88,28 @@ export function cloneNode(
}
}
+export function getElementById(within: ParentNode, elementId: string) {
+ const id = String(elementId);
+ if (id === '') return null;
+
+ return querySelector(within, [{type: MatcherType.Id, name: id}]);
+}
+
+export function getElementsByTagName(
+ within: ParentNode,
+ qualifiedName: string,
+) {
+ const name = String(qualifiedName);
+
+ const results = querySelectorAll(within, [
+ {type: name === '*' ? MatcherType.Unknown : MatcherType.Element, name},
+ ]);
+
+ const matches = new NodeList();
+ matches.push(...results);
+ return matches;
+}
+
export function descendants(node: Node) {
const nodes: Node[] = [];
const walk = (node: Node) => {
diff --git a/packages/polyfill/source/tests/document.test.ts b/packages/polyfill/source/tests/document.test.ts
new file mode 100644
index 00000000..6213af6e
--- /dev/null
+++ b/packages/polyfill/source/tests/document.test.ts
@@ -0,0 +1,122 @@
+import {Window} from '../index.ts';
+
+import {beforeEach, describe, expect, it} from 'vitest';
+
+beforeEach(() => {
+ const window = new Window();
+ Window.setGlobalThis(window);
+});
+
+describe('NonElementParentNode.getElementById', () => {
+ describe('Document', () => {
+ it('returns the first matching element in tree order after arbitrary insertions', () => {
+ const container = document.createElement('div');
+ const createTarget = () => {
+ const element = document.createElement('span');
+ element.id = 'target';
+ return element;
+ };
+ const first = createTarget();
+ const second = createTarget();
+ const third = createTarget();
+ const fourth = createTarget();
+
+ container.appendChild(second);
+ container.appendChild(fourth);
+ container.insertBefore(third, fourth);
+ container.insertBefore(first, second);
+ document.body.appendChild(container);
+
+ for (const element of [first, second, third, fourth]) {
+ expect(document.getElementById('target')).toBe(element);
+ element.remove();
+ }
+
+ expect(document.getElementById('target')).toBeNull();
+ });
+
+ it('only returns elements connected to the document', () => {
+ const container = document.createElement('div');
+ const target = document.createElement('span');
+ target.id = 'target';
+ container.appendChild(target);
+
+ expect(document.getElementById('target')).toBeNull();
+
+ document.body.appendChild(container);
+ expect(document.getElementById('target')).toBe(target);
+
+ container.remove();
+ expect(document.getElementById('target')).toBeNull();
+ });
+
+ it('reflects id property and attribute changes immediately', () => {
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+
+ target.id = 'before';
+ expect(target.getAttribute('id')).toBe('before');
+ expect(document.getElementById('before')).toBe(target);
+
+ target.setAttribute('id', 'after');
+ expect(target.id).toBe('after');
+ expect(document.getElementById('before')).toBeNull();
+ expect(document.getElementById('after')).toBe(target);
+
+ target.removeAttribute('id');
+ expect(target.id).toBe('');
+ expect(document.getElementById('after')).toBeNull();
+ });
+
+ it('coerces identifiers to strings but never matches an empty id', () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ expect(document.getElementById('')).toBeNull();
+ expect(document.getElementById(null as any)?.id).toBe('null');
+ expect(document.getElementById(undefined as any)?.id).toBe('undefined');
+ });
+
+ it('matches identifiers literally instead of parsing them as selectors', () => {
+ const ids = ['a.b', 'a b', 'a[b]', 'a:b', 'a#b'];
+
+ for (const id of ids) {
+ const element = document.createElement('div');
+ element.id = id;
+ document.body.appendChild(element);
+ }
+
+ for (const id of ids) {
+ expect(document.getElementById(id)?.id).toBe(id);
+ }
+ });
+ });
+
+ describe('DocumentFragment', () => {
+ it('finds the first matching element within the fragment', () => {
+ const fragment = document.createDocumentFragment();
+ const first = document.createElement('div');
+ const second = document.createElement('div');
+ first.id = 'target';
+ second.id = 'target';
+ fragment.append(first, second);
+
+ expect(fragment.getElementById('target')).toBe(first);
+ expect(fragment.getElementById('missing')).toBeNull();
+ });
+
+ it('does not find elements outside the fragment', () => {
+ const target = document.createElement('div');
+ target.id = 'target';
+ document.body.appendChild(target);
+
+ const fragment = document.createDocumentFragment();
+
+ expect(fragment.getElementById('target')).toBeNull();
+ expect((target as any).getElementById).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts
new file mode 100644
index 00000000..47824f06
--- /dev/null
+++ b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts
@@ -0,0 +1,51 @@
+import {NamespaceURI} from '../constants.ts';
+import {Window} from '../index.ts';
+import {NodeList} from '../NodeList.ts';
+
+import {beforeEach, describe, expect, it} from 'vitest';
+
+beforeEach(() => {
+ const window = new Window();
+ Window.setGlobalThis(window);
+});
+
+describe('getElementsByTagName', () => {
+ it('is exposed on Document and Element but not DocumentFragment', () => {
+ expect(document.getElementsByTagName).toBeTypeOf('function');
+ expect(document.body.getElementsByTagName).toBeTypeOf('function');
+ expect(
+ (document.createDocumentFragment() as any).getElementsByTagName,
+ ).toBeUndefined();
+ });
+
+ it('finds descendant HTML elements case-insensitively', () => {
+ document.body.innerHTML = '';
+
+ expect(document.getElementsByTagName('body')[0]).toBe(document.body);
+ expect(document.body.getElementsByTagName('DIV')).toHaveLength(2);
+ expect(document.body.getElementsByTagName('*')).toHaveLength(3);
+ });
+
+ it('matches non-HTML tag names case-sensitively', () => {
+ const svg = document.createElementNS(NamespaceURI.SVG, 'svg');
+ const gradient = document.createElementNS(
+ NamespaceURI.SVG,
+ 'linearGradient',
+ );
+ svg.appendChild(gradient);
+ document.body.appendChild(svg);
+
+ expect(document.getElementsByTagName('linearGradient')[0]).toBe(gradient);
+ expect(document.getElementsByTagName('lineargradient')).toHaveLength(0);
+ });
+
+ it('returns a NodeList with an item() method', () => {
+ document.body.innerHTML = '
';
+ const results = document.body.getElementsByTagName('*');
+ expect(results).toBeInstanceOf(NodeList);
+ expect(results.item).toBeTypeOf('function');
+ expect(results.item(0)?.localName).toBe('div');
+ expect(results.item(1)?.localName).toBe('span');
+ expect(results.item(3)).toBeNull();
+ });
+});
diff --git a/packages/polyfill/source/tests/parent-node.test.ts b/packages/polyfill/source/tests/parent-node.test.ts
new file mode 100644
index 00000000..d4149948
--- /dev/null
+++ b/packages/polyfill/source/tests/parent-node.test.ts
@@ -0,0 +1,36 @@
+import {Window} from '../index.ts';
+
+import {beforeEach, describe, expect, it} from 'vitest';
+
+beforeEach(() => {
+ const window = new Window();
+ Window.setGlobalThis(window);
+});
+
+describe('ParentNode.appendChild', () => {
+ it('returns the appended child', () => {
+ const parent = document.createElement('div');
+ const child = document.createElement('span');
+
+ expect(parent.appendChild(child)).toBe(child);
+ });
+});
+
+describe('ParentNode.insertBefore', () => {
+ it('links an element inserted before a middle child into tree order', () => {
+ const parent = document.createElement('div');
+ const first = document.createElement('span');
+ const middle = document.createElement('em');
+ const last = document.createElement('strong');
+
+ parent.appendChild(first);
+ parent.appendChild(last);
+
+ expect(parent.insertBefore(middle, last)).toBe(middle);
+ expect(Array.from(parent.childNodes)).toEqual([first, middle, last]);
+ expect(first.nextSibling).toBe(middle);
+ expect(middle.previousSibling).toBe(first);
+ expect(middle.nextSibling).toBe(last);
+ expect(last.previousSibling).toBe(middle);
+ });
+});
diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts
index 067b551d..bd9e5ff7 100644
--- a/packages/polyfill/source/tests/selectors.test.ts
+++ b/packages/polyfill/source/tests/selectors.test.ts
@@ -1,5 +1,11 @@
import {Window} from '../index.ts';
-import {parseSelector} from '../selectors.ts';
+import {
+ parseSelector,
+ querySelector,
+ querySelectorAll,
+ MatcherType,
+} from '../selectors.ts';
+import type {ParentNode} from '../ParentNode.ts';
import {describe, it, expect, beforeEach} from 'vitest';
@@ -177,8 +183,8 @@ describe('selector parsing and matching', () => {
`;
});
- it('selects by element name', () => {
- const articles = container.querySelectorAll('article');
+ it('selects HTML element names case-insensitively', () => {
+ const articles = container.querySelectorAll('ARTICLE');
expect(articles).toHaveLength(1);
expect(articles[0]!.getAttribute('class')).toBe('post');
@@ -277,4 +283,99 @@ describe('selector parsing and matching', () => {
expect(allElements.length).toBeGreaterThan(0);
});
});
+
+ describe('querySelector and querySelectorAll with Matcher[] argument', () => {
+ let container: Element;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ container.innerHTML = `
+
+
Main Title
+
+
First paragraph
+
Hidden paragraph
+ Highlighted text
+
+
+ `;
+ });
+
+ // The standalone querySelector/querySelectorAll functions expect the polyfill's
+ // ParentNode, but `container` is typed as the global Element (lib.dom.d.ts).
+ // At runtime, Window.setGlobalThis replaces globals with polyfill instances.
+ const asPolyfill = (node: Element) => node as unknown as ParentNode;
+
+ it('selects by ID matcher without parsing', () => {
+ const main = querySelector(asPolyfill(container), [
+ {type: MatcherType.Id, name: 'main-post'},
+ ]);
+ expect(main?.tagName.toLowerCase()).toBe('article');
+ });
+
+ it('selects by element matcher without parsing', () => {
+ const paragraphs = querySelectorAll(asPolyfill(container), [
+ {type: MatcherType.Element, name: 'p'},
+ ]);
+ expect(paragraphs).toHaveLength(2);
+ });
+
+ it('matches ids with special characters literally (no CSS escaping)', () => {
+ const element = document.createElement('div');
+ element.id = 'a.b:c#d';
+ container.appendChild(element);
+
+ const result = querySelector(asPolyfill(container), [
+ {type: MatcherType.Id, name: 'a.b:c#d'},
+ ]);
+ expect(result).toBe(element);
+ });
+
+ it('matches HTML tag names case-insensitively via element matcher', () => {
+ const upper = querySelectorAll(asPolyfill(container), [
+ {type: MatcherType.Element, name: 'ARTICLE'},
+ ]);
+ expect(upper).toHaveLength(1);
+
+ const mixed = querySelectorAll(asPolyfill(container), [
+ {type: MatcherType.Element, name: 'SpAn'},
+ ]);
+ expect(mixed).toHaveLength(1);
+ expect(mixed[0]!.getAttribute('class')).toBe('highlight');
+ });
+
+ it('returns same results as string selectors for compound queries', () => {
+ const byString = container.querySelectorAll('p.text.hidden');
+ const byObject = querySelectorAll(asPolyfill(container), [
+ {type: MatcherType.Element, name: 'p'},
+ {type: MatcherType.Class, name: 'text'},
+ {type: MatcherType.Class, name: 'hidden'},
+ ]);
+
+ expect(byObject).toHaveLength(byString.length);
+ expect(byObject[0]?.textContent?.trim()).toBe('Hidden paragraph');
+ });
+
+ it('wildcard matcher returns all elements', () => {
+ const all = querySelectorAll(asPolyfill(container), [
+ {type: MatcherType.Unknown, name: '*'},
+ ]);
+ expect(all.length).toBeGreaterThan(0);
+ // Should include article, h1, div, p, p, span
+ expect(all.length).toBe(6);
+ });
+
+ it('returns null/empty for non-matching matchers', () => {
+ expect(
+ querySelector(asPolyfill(container), [
+ {type: MatcherType.Id, name: 'nope'},
+ ]),
+ ).toBeNull();
+ expect(
+ querySelectorAll(asPolyfill(container), [
+ {type: MatcherType.Element, name: 'table'},
+ ]),
+ ).toHaveLength(0);
+ });
+ });
});
diff --git a/packages/wpt-runner/README.md b/packages/wpt-runner/README.md
new file mode 100644
index 00000000..ead20642
--- /dev/null
+++ b/packages/wpt-runner/README.md
@@ -0,0 +1,80 @@
+# Remote DOM WPT runner
+
+Private test infrastructure for running selected upstream Web Platform Tests unchanged against the workspace copy of `@remote-dom/polyfill`.
+
+The runner downloads a pinned WPT archive, parses selected `testharness.js` HTML files in a browser control page, and executes their markup and scripts in a fresh module worker containing `new Window()` from the polyfill. It does not construct a Remote DOM host receiver; transport and host rendering are separate integration concerns.
+
+## Commands
+
+From the repository root:
+
+```bash
+# Prepare or reuse the pinned WPT source.
+pnpm wpt:prepare
+
+# Run every classified file with strict capability enforcement.
+pnpm test:wpt
+
+# Explore an arbitrary test without claiming support.
+pnpm test:wpt -- dom/nodes/Document-getElementById.html
+
+# Enforce the table for one classified file.
+pnpm test:wpt -- --capabilities '__runner__/runner-smoke.html?runner=smoke'
+
+# Open the debug page and inspect original and generated source.
+pnpm wpt:dev
+
+# Canonically sort capabilities.tsv after editing it.
+pnpm wpt:format
+```
+
+Useful runner options include `--headed`, `--verbose`, `--timeout 60s`, `--port 5174`, and `--strict-port`.
+
+Set `WPT_ROOT=/path/to/wpt` to use an existing checkout. The runner verifies `resources/testharness.js` and skips all downloads. Set `WPT_CACHE_DIR` to override the download cache.
+
+## Pinned source and cache
+
+`wpt.lock.json` pins an immutable WPT revision and SHA-256 archive checksum. Pin updates must change and review both values.
+
+The cache root resolves in this order:
+
+1. `WPT_CACHE_DIR`
+2. `/.cache/wpt` when `CI` is set
+3. `${XDG_CACHE_HOME}/remote-dom/wpt`
+4. `${HOME}/.cache/remote-dom/wpt`
+
+Each revision installs under `//source`, with the runner-owned completion marker beside `source` at the revision root. Download and extraction happen in a process-unique temporary revision directory, which is moved into place atomically so concurrent worktrees can safely race to populate the shared cache. Keeping the marker outside `source` leaves the extracted WPT checkout untouched. Old revisions are not deleted automatically.
+
+WPT files are pinned but remain untrusted test inputs. The preparation script validates archive paths and checksums before extraction, and the browser server rejects traversal. Do not execute downloaded repository scripts outside the isolated runner.
+
+## Capability inventory
+
+`capabilities.tsv` contains one physical row per WPT subtest with four columns:
+
+```text
+pathstatuscasenote
+```
+
+- `path`: WPT path, including a query string when applicable
+- `status`: exactly `supported` or `deferred`
+- `case`: exact `testharness.js` subtest name
+- `note`: required for deferred cases and normally empty for supported cases
+
+Use `\\t`, `\\n`, `\\r`, and `\\\\` for literal tab, newline, carriage return, and backslash characters. Other escapes, malformed rows, duplicate `(path, case)` pairs, and noncanonical ordering fail validation.
+
+The formatter sorts by path and case with locale-independent code-unit ordering and writes LF line endings with one final newline. The runner rejects an unformatted table and prints the formatter command.
+
+During an enforced run:
+
+- failed supported cases fail the command;
+- failed deferred cases remain visible with their notes but do not fail it;
+- passing deferred cases are printed as promotion candidates;
+- missing or unlisted cases, harness errors, worker errors, and timeouts always fail.
+
+The same schema can later be split mechanically into `dom.tsv`, `html.tsv`, `svg.tsv`, and similar files if one table becomes unwieldy.
+
+## Initial limitations
+
+The first version supports `testharness.js` HTML files, classic top-level scripts, static HTML/SVG markup, and absolute or relative in-repository script resources. It skips `testharnessreport.js` and captures completion programmatically.
+
+It intentionally does not support `.window.js`, WebIDL preloading, modules, nested scripts or browsing contexts, reftests, crashtests, WPT server substitutions, navigation, or layout assertions. Add execution infrastructure only when a selected capability requires it; never patch a claimed DOM API in runner shims.
diff --git a/packages/wpt-runner/capabilities.tsv b/packages/wpt-runner/capabilities.tsv
new file mode 100644
index 00000000..992dce48
--- /dev/null
+++ b/packages/wpt-runner/capabilities.tsv
@@ -0,0 +1,57 @@
+path status case note
+__runner__/runner-smoke.html?runner=smoke supported runner smoke
+dom/nodes/Document-getElementById.html supported Calling document.getElementById with a null argument.
+dom/nodes/Document-getElementById.html supported Calling document.getElementById with an empty string argument.
+dom/nodes/Document-getElementById.html supported Calling document.getElementById with an undefined argument.
+dom/nodes/Document-getElementById.html supported Document.getElementById must not return nodes not present in document
+dom/nodes/Document-getElementById.html deferred Document.getElementById with a script-inserted element Requires concrete `HTMLDivElement` constructor support.
+dom/nodes/Document-getElementById.html supported Ensure that the id attribute only affects elements present in a document
+dom/nodes/Document-getElementById.html supported Inserting an id by inserting its parent node
+dom/nodes/Document-getElementById.html supported Modern browsers optimize this method with using internal id cache. This test checks that their optimization should effect only append to `Document`, not append to `Node`.
+dom/nodes/Document-getElementById.html deferred add id attribute via innerHTML Requires concrete `HTMLDivElement` constructor support.
+dom/nodes/Document-getElementById.html deferred add id attribute via outerHTML Requires an `outerHTML` setter.
+dom/nodes/Document-getElementById.html deferred changing attribute's value via `Attr` gotten from `Element.attribute`. Requires indexed `NamedNodeMap` access.
+dom/nodes/Document-getElementById.html supported in tree order, within the context object's tree
+dom/nodes/Document-getElementById.html deferred on static page Requires concrete `HTMLDivElement` constructor support.
+dom/nodes/Document-getElementById.html supported remove id attribute via innerHTML
+dom/nodes/Document-getElementById.html deferred remove id attribute via outerHTML Requires an `outerHTML` setter.
+dom/nodes/Document-getElementById.html supported update `id` attribute via element.id
+dom/nodes/Document-getElementById.html supported update `id` attribute via setAttribute/removeAttribute
+dom/nodes/Document-getElementById.html supported where insertion order and tree order don't match
+dom/nodes/Document-getElementsByTagName.html supported Caching is allowed
+dom/nodes/Document-getElementsByTagName.html deferred Element in HTML namespace, no prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names.
+dom/nodes/Document-getElementsByTagName.html deferred Element in HTML namespace, prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names.
+dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, lowercase name
+dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, uppercase name
+dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, non-ascii characters in name
+dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, prefix, lowercase name
+dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, prefix, non-ascii characters in name
+dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, prefix, uppercase name
+dom/nodes/Document-getElementsByTagName.html deferred HTML element with uppercase tagName never matches in HTML Documents Requires HTML-document qualified-name matching for uppercase-created XHTML elements.
+dom/nodes/Document-getElementsByTagName.html deferred Interfaces Requires `HTMLCollection` and exposed collection constructors.
+dom/nodes/Document-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (item) Requires `HTMLCollection.prototype.item`.
+dom/nodes/Document-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (namedItem) Requires `HTMLCollection.prototype.namedItem`.
+dom/nodes/Document-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (non-strict mode) Requires Web IDL indexed `HTMLCollection` properties.
+dom/nodes/Document-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (strict mode) Requires Web IDL indexed `HTMLCollection` properties.
+dom/nodes/Document-getElementsByTagName.html deferred getElementsByTagName('*') Requires `Node.ELEMENT_NODE` constant support.
+dom/nodes/Document-getElementsByTagName.html deferred getElementsByTagName() should be a live collection Requires a live `HTMLCollection`.
+dom/nodes/Document-getElementsByTagName.html deferred hasOwnProperty, getOwnPropertyDescriptor, getOwnPropertyNames Requires `HTMLCollection` named property semantics.
+dom/nodes/Element-getElementsByTagName.html supported Caching is allowed
+dom/nodes/Element-getElementsByTagName.html deferred Element in HTML namespace, no prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names.
+dom/nodes/Element-getElementsByTagName.html deferred Element in HTML namespace, prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names.
+dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, lowercase name
+dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, uppercase name
+dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, non-ascii characters in name
+dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, prefix, lowercase name
+dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, prefix, non-ascii characters in name
+dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, prefix, uppercase name
+dom/nodes/Element-getElementsByTagName.html deferred HTML element with uppercase tagName never matches in HTML Documents Requires HTML-document qualified-name matching for uppercase-created XHTML elements.
+dom/nodes/Element-getElementsByTagName.html deferred Interfaces Requires `HTMLCollection` and exposed collection constructors.
+dom/nodes/Element-getElementsByTagName.html supported Matching the context object
+dom/nodes/Element-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (item) Requires `HTMLCollection.prototype.item`.
+dom/nodes/Element-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (namedItem) Requires `HTMLCollection.prototype.namedItem`.
+dom/nodes/Element-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (non-strict mode) Requires Web IDL indexed `HTMLCollection` properties.
+dom/nodes/Element-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (strict mode) Requires Web IDL indexed `HTMLCollection` properties.
+dom/nodes/Element-getElementsByTagName.html deferred getElementsByTagName('*') Requires `Node.ELEMENT_NODE` constant support.
+dom/nodes/Element-getElementsByTagName.html deferred getElementsByTagName() should be a live collection Requires a live `HTMLCollection`.
+dom/nodes/Element-getElementsByTagName.html deferred hasOwnProperty, getOwnPropertyDescriptor, getOwnPropertyNames Requires `HTMLCollection` named property semantics.
diff --git a/packages/wpt-runner/fixtures/runner-smoke.html b/packages/wpt-runner/fixtures/runner-smoke.html
new file mode 100644
index 00000000..9f6cf7be
--- /dev/null
+++ b/packages/wpt-runner/fixtures/runner-smoke.html
@@ -0,0 +1,24 @@
+
+Remote DOM WPT runner smoke test
+
+
+Static HTML
+
+
diff --git a/packages/wpt-runner/index.html b/packages/wpt-runner/index.html
new file mode 100644
index 00000000..90d63cc4
--- /dev/null
+++ b/packages/wpt-runner/index.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+ Remote DOM WPT runner
+
+
+
+
+
+
Remote DOM WPT runner
+
+ Runs selected upstream testharness.js HTML files against
+ @remote-dom/polyfill.
+