From 1458eaf79c063f7fe9d3203a172c14a8f191fbf1 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 08:59:08 -0700 Subject: [PATCH 01/14] Add Web Platform Test runner infrastructure --- .gitattributes | 1 + .github/workflows/checks.yml | 16 + .gitignore | 1 + .prettierignore | 1 + package.json | 4 + packages/wpt-runner/README.md | 80 +++++ packages/wpt-runner/capabilities.tsv | 2 + .../wpt-runner/fixtures/runner-smoke.html | 24 ++ packages/wpt-runner/index.html | 70 ++++ packages/wpt-runner/package.json | 17 + .../public/wpt-runner/harness-setup.js | 34 ++ .../public/wpt-runner/runtime-shims.js | 40 +++ packages/wpt-runner/scripts/capabilities.mjs | 142 ++++++++ .../wpt-runner/scripts/capabilities.test.mjs | 69 ++++ packages/wpt-runner/scripts/dev.mjs | 37 ++ .../scripts/evaluate-capabilities.mjs | 49 +++ .../scripts/evaluate-capabilities.test.mjs | 79 +++++ .../scripts/format-capabilities.mjs | 18 + packages/wpt-runner/scripts/paths.test.mjs | 26 ++ packages/wpt-runner/scripts/prepare-wpt.mjs | 285 ++++++++++++++++ .../wpt-runner/scripts/prepare-wpt.test.mjs | 124 +++++++ packages/wpt-runner/scripts/run-wpt.mjs | 320 +++++++++++++++++ packages/wpt-runner/src/adapter.ts | 323 ++++++++++++++++++ packages/wpt-runner/src/main.ts | 181 ++++++++++ packages/wpt-runner/src/paths.ts | 27 ++ packages/wpt-runner/src/style.css | 74 ++++ packages/wpt-runner/src/types.ts | 46 +++ packages/wpt-runner/src/worker.ts | 76 +++++ packages/wpt-runner/tsconfig.json | 9 + packages/wpt-runner/vite.config.ts | 60 ++++ packages/wpt-runner/wpt.lock.json | 6 + pnpm-lock.yaml | 56 +++ tsconfig.json | 3 +- 33 files changed, 2299 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 packages/wpt-runner/README.md create mode 100644 packages/wpt-runner/capabilities.tsv create mode 100644 packages/wpt-runner/fixtures/runner-smoke.html create mode 100644 packages/wpt-runner/index.html create mode 100644 packages/wpt-runner/package.json create mode 100644 packages/wpt-runner/public/wpt-runner/harness-setup.js create mode 100644 packages/wpt-runner/public/wpt-runner/runtime-shims.js create mode 100644 packages/wpt-runner/scripts/capabilities.mjs create mode 100644 packages/wpt-runner/scripts/capabilities.test.mjs create mode 100644 packages/wpt-runner/scripts/dev.mjs create mode 100644 packages/wpt-runner/scripts/evaluate-capabilities.mjs create mode 100644 packages/wpt-runner/scripts/evaluate-capabilities.test.mjs create mode 100644 packages/wpt-runner/scripts/format-capabilities.mjs create mode 100644 packages/wpt-runner/scripts/paths.test.mjs create mode 100644 packages/wpt-runner/scripts/prepare-wpt.mjs create mode 100644 packages/wpt-runner/scripts/prepare-wpt.test.mjs create mode 100644 packages/wpt-runner/scripts/run-wpt.mjs create mode 100644 packages/wpt-runner/src/adapter.ts create mode 100644 packages/wpt-runner/src/main.ts create mode 100644 packages/wpt-runner/src/paths.ts create mode 100644 packages/wpt-runner/src/style.css create mode 100644 packages/wpt-runner/src/types.ts create mode 100644 packages/wpt-runner/src/worker.ts create mode 100644 packages/wpt-runner/tsconfig.json create mode 100644 packages/wpt-runner/vite.config.ts create mode 100644 packages/wpt-runner/wpt.lock.json 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/wpt-runner/README.md b/packages/wpt-runner/README.md new file mode 100644 index 00000000..cef736a2 --- /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`. Download and extraction happen in process-unique temporary paths. A completed source tree is moved into place atomically, so concurrent worktrees can safely race to populate the shared cache. 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..3130fd0d --- /dev/null +++ b/packages/wpt-runner/capabilities.tsv @@ -0,0 +1,2 @@ +path status case note +__runner__/runner-smoke.html?runner=smoke supported runner smoke 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. +

+
+
+ +
+ + +
+ +
+
+

Status

+

+        
+
+

testharness result

+
Not run yet.
+
+
+ +
+

Adapter warnings and console

+

+      
+ +
+

Sources

+
+ Original WPT markup +
Run a test to load its source.
+
+
+ Runner and testharness source +
Run a test to generate source.
+
+
+ Generated test source +
Run a test to generate source.
+
+
+
+ + + diff --git a/packages/wpt-runner/package.json b/packages/wpt-runner/package.json new file mode 100644 index 00000000..6b7727ad --- /dev/null +++ b/packages/wpt-runner/package.json @@ -0,0 +1,17 @@ +{ + "name": "@remote-dom/wpt-runner", + "private": true, + "type": "module", + "scripts": { + "dev": "node scripts/dev.mjs", + "wpt:prepare": "node scripts/prepare-wpt.mjs", + "format:capabilities": "node scripts/format-capabilities.mjs", + "wpt": "node scripts/run-wpt.mjs" + }, + "dependencies": { + "@remote-dom/polyfill": "workspace:*" + }, + "devDependencies": { + "tar": "7.5.10" + } +} diff --git a/packages/wpt-runner/public/wpt-runner/harness-setup.js b/packages/wpt-runner/public/wpt-runner/harness-setup.js new file mode 100644 index 00000000..92f92148 --- /dev/null +++ b/packages/wpt-runner/public/wpt-runner/harness-setup.js @@ -0,0 +1,34 @@ +let __harnessReady = false; + +function __afterWptHarness() { + if (__harnessReady) return; + __harnessReady = true; + + const originalSetup = setup; + globalThis.setup = globalThis.window.setup = function (functionOrProperties) { + if (arguments.length === 1 && typeof functionOrProperties === 'function') { + return originalSetup(functionOrProperties, {}); + } + return originalSetup.apply(this, arguments); + }; + + setup({output: false}); + add_completion_callback((tests, status) => { + postMessage({ + type: 'complete', + result: { + tests: tests.map((test) => ({ + name: test.name, + status: test.status, + message: test.message, + stack: test.stack, + })), + status: { + status: status.status, + message: status.message, + stack: status.stack, + }, + }, + }); + }); +} diff --git a/packages/wpt-runner/public/wpt-runner/runtime-shims.js b/packages/wpt-runner/public/wpt-runner/runtime-shims.js new file mode 100644 index 00000000..a0016502 --- /dev/null +++ b/packages/wpt-runner/public/wpt-runner/runtime-shims.js @@ -0,0 +1,40 @@ +const __WPT_SVG_NAMESPACE__ = 'http://www.w3.org/2000/svg'; +const __wptLocation = new URL(__WPT_CONTEXT__.href, 'https://wpt.local/'); + +for (const target of new Set([ + globalThis, + globalThis.window, + globalThis.self, +])) { + if (!target) continue; + Object.defineProperty(target, 'location', { + configurable: true, + value: __wptLocation, + }); +} + +if ( + globalThis.location.pathname !== __WPT_CONTEXT__.pathname || + globalThis.location.search !== __WPT_CONTEXT__.search +) { + throw new Error('The worker could not install the selected WPT location.'); +} + +function __appendWptNode(parent, spec) { + if (spec.kind === 'text') { + const text = document.createTextNode(spec.text ?? ''); + parent.appendChild(text); + return text; + } + + const element = + spec.namespace === 'svg' + ? document.createElementNS(__WPT_SVG_NAMESPACE__, spec.name) + : document.createElement(spec.name); + + for (const [name, value] of spec.attributes) + element.setAttribute(name, value); + parent.appendChild(element); + for (const child of spec.children) __appendWptNode(element, child); + return element; +} diff --git a/packages/wpt-runner/scripts/capabilities.mjs b/packages/wpt-runner/scripts/capabilities.mjs new file mode 100644 index 00000000..01352a25 --- /dev/null +++ b/packages/wpt-runner/scripts/capabilities.mjs @@ -0,0 +1,142 @@ +import fs from 'node:fs/promises'; + +export const CAPABILITY_HEADER = ['path', 'status', 'case', 'note']; +const VALID_STATUSES = new Set(['supported', 'deferred']); + +export async function readCapabilities(file) { + const source = await fs.readFile(file, 'utf8'); + const rows = parseCapabilities(source, file); + const canonical = serializeCapabilities(rows); + + if (source !== canonical) { + throw new Error( + `${file} is not canonical. Run \`pnpm wpt:format\` and commit the result.`, + ); + } + + return rows; +} + +export function parseCapabilities(source, label = 'capabilities.tsv') { + if (source.includes('\r')) { + throw new Error( + `${label}: literal carriage returns are not allowed; use \\r escapes.`, + ); + } + + const lines = source.split('\n'); + if (lines.at(-1) === '') lines.pop(); + if (lines.length === 0 || lines[0] !== CAPABILITY_HEADER.join('\t')) { + throw new Error( + `${label}: expected header ${CAPABILITY_HEADER.join('\\t')}.`, + ); + } + + const rows = []; + const seen = new Set(); + + for (let index = 1; index < lines.length; index += 1) { + const lineNumber = index + 1; + const fields = lines[index].split('\t'); + if (fields.length !== CAPABILITY_HEADER.length) { + throw new Error( + `${label}:${lineNumber}: expected ${CAPABILITY_HEADER.length} columns, got ${fields.length}.`, + ); + } + + const [rawPath, rawStatus, rawCase, rawNote] = fields; + const row = { + path: unescapeField(rawPath, label, lineNumber), + status: unescapeField(rawStatus, label, lineNumber), + case: unescapeField(rawCase, label, lineNumber), + note: unescapeField(rawNote, label, lineNumber), + }; + + if (!row.path) throw new Error(`${label}:${lineNumber}: path is required.`); + if (!VALID_STATUSES.has(row.status)) { + throw new Error( + `${label}:${lineNumber}: status must be exactly supported or deferred.`, + ); + } + if (!row.case) throw new Error(`${label}:${lineNumber}: case is required.`); + if (row.status === 'deferred' && !row.note) { + throw new Error(`${label}:${lineNumber}: deferred rows require a note.`); + } + + const key = `${row.path}\0${row.case}`; + if (seen.has(key)) { + throw new Error( + `${label}:${lineNumber}: duplicate capability (${JSON.stringify(row.path)}, ${JSON.stringify(row.case)}).`, + ); + } + seen.add(key); + rows.push(row); + } + + return rows; +} + +export function serializeCapabilities(rows) { + const sorted = [...rows].sort(compareCapabilityRows); + const lines = [ + CAPABILITY_HEADER.join('\t'), + ...sorted.map((row) => + [row.path, row.status, row.case, row.note] + .map((field) => escapeField(field)) + .join('\t'), + ), + ]; + return `${lines.join('\n')}\n`; +} + +export function compareCapabilityRows(left, right) { + return ( + compareCodeUnits(left.path, right.path) || + compareCodeUnits(left.case, right.case) + ); +} + +export function compareCodeUnits(left, right) { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +export function rowsByPath(rows) { + const grouped = new Map(); + for (const row of rows) { + const pathRows = grouped.get(row.path); + if (pathRows) pathRows.push(row); + else grouped.set(row.path, [row]); + } + return grouped; +} + +function escapeField(value) { + return String(value) + .replaceAll('\\', '\\\\') + .replaceAll('\t', '\\t') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r'); +} + +function unescapeField(value, label, lineNumber) { + let result = ''; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character !== '\\') { + result += character; + continue; + } + + const escaped = value[++index]; + if (escaped === '\\') result += '\\'; + else if (escaped === 't') result += '\t'; + else if (escaped === 'n') result += '\n'; + else if (escaped === 'r') result += '\r'; + else { + const display = escaped === undefined ? 'end of field' : `\\${escaped}`; + throw new Error(`${label}:${lineNumber}: malformed escape ${display}.`); + } + } + return result; +} diff --git a/packages/wpt-runner/scripts/capabilities.test.mjs b/packages/wpt-runner/scripts/capabilities.test.mjs new file mode 100644 index 00000000..abce6e99 --- /dev/null +++ b/packages/wpt-runner/scripts/capabilities.test.mjs @@ -0,0 +1,69 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; +import { + parseCapabilities, + readCapabilities, + serializeCapabilities, +} from './capabilities.mjs'; + +const header = 'path\tstatus\tcase\tnote\n'; + +describe('capabilities.tsv', () => { + it('round-trips escaped fields and sorts by code units', () => { + const rows = parseCapabilities( + `${header}z.html\tdeferred\tcase\\t2\tline\\n2\\\\done\na.html\tsupported\tcase 1\t\n`, + ); + + expect(rows[0]).toMatchObject({ + case: 'case\t2', + note: 'line\n2\\done', + }); + expect(serializeCapabilities(rows)).toBe( + `${header}a.html\tsupported\tcase 1\t\nz.html\tdeferred\tcase\\t2\tline\\n2\\\\done\n`, + ); + }); + + it('rejects malformed escapes and column counts', () => { + expect(() => + parseCapabilities(`${header}a.html\tsupported\tbad\\q\t\n`), + ).toThrow('malformed escape'); + expect(() => + parseCapabilities(`${header}a.html\tsupported\tcase\n`), + ).toThrow('expected 4 columns'); + }); + + it('requires deferred notes and unique path-case pairs', () => { + expect(() => + parseCapabilities(`${header}a.html\tdeferred\tcase\t\n`), + ).toThrow('deferred rows require a note'); + expect(() => + parseCapabilities( + `${header}a.html\tsupported\tcase\t\na.html\tdeferred\tcase\treason\n`, + ), + ).toThrow('duplicate capability'); + }); + + it('rejects valid but noncanonical checked-in ordering', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'capabilities-')); + const file = path.join(directory, 'capabilities.tsv'); + try { + await fs.writeFile( + file, + `${header}z.html\tsupported\tcase z\t\na.html\tsupported\tcase a\t\n`, + ); + await expect(readCapabilities(file)).rejects.toThrow('pnpm wpt:format'); + } finally { + await fs.rm(directory, {force: true, recursive: true}); + } + }); + + it('rejects noncanonical carriage-return line endings', () => { + expect(() => + parseCapabilities( + 'path\tstatus\tcase\tnote\r\na.html\tsupported\tcase\t\r\n', + ), + ).toThrow('literal carriage returns'); + }); +}); diff --git a/packages/wpt-runner/scripts/dev.mjs b/packages/wpt-runner/scripts/dev.mjs new file mode 100644 index 00000000..76565df0 --- /dev/null +++ b/packages/wpt-runner/scripts/dev.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import {spawn} from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import {prepareWpt} from './prepare-wpt.mjs'; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); + +try { + const wptRoot = await prepareWpt(); + const child = spawn( + 'pnpm', + ['exec', 'vite', '--host', '127.0.0.1', '--open'], + { + cwd: packageRoot, + env: {...process.env, WPT_ROOT: wptRoot}, + stdio: 'inherit', + }, + ); + + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => child.kill(signal)); + } + child.on('exit', (code, signal) => { + process.exitCode = signal ? 1 : (code ?? 1); + }); +} catch (error) { + console.error( + error instanceof Error ? error.stack || error.message : String(error), + ); + process.exitCode = 1; +} diff --git a/packages/wpt-runner/scripts/evaluate-capabilities.mjs b/packages/wpt-runner/scripts/evaluate-capabilities.mjs new file mode 100644 index 00000000..a7679101 --- /dev/null +++ b/packages/wpt-runner/scripts/evaluate-capabilities.mjs @@ -0,0 +1,49 @@ +import {compareCodeUnits} from './capabilities.mjs'; + +export function evaluateCapabilities(run, rows) { + const tests = run.result?.tests ?? []; + const testByName = new Map(); + const duplicateResults = []; + for (const test of tests) { + if (testByName.has(test.name)) duplicateResults.push(test.name); + else testByName.set(test.name, test); + } + + const rowByName = new Map(rows.map((row) => [row.case, row])); + const missing = rows.filter((row) => !testByName.has(row.case)); + const unlisted = tests + .filter((test) => !rowByName.has(test.name)) + .sort((left, right) => compareCodeUnits(left.name, right.name)); + const supportedPassed = []; + const supportedFailures = []; + const deferredFailures = []; + const promotionCandidates = []; + + for (const row of rows) { + const test = testByName.get(row.case); + if (!test) continue; + if (row.status === 'supported' && test.status === 0) + supportedPassed.push({row, test}); + else if (row.status === 'supported') supportedFailures.push({row, test}); + else if (test.status === 0) promotionCandidates.push({row, test}); + else deferredFailures.push({row, test}); + } + + return { + deferredFailures, + duplicateResults: [...new Set(duplicateResults)].sort(compareCodeUnits), + failed: + run.state === 'error' || + !run.result || + run.result.status.status !== 0 || + duplicateResults.length > 0 || + missing.length > 0 || + unlisted.length > 0 || + supportedFailures.length > 0, + missing, + promotionCandidates, + supportedFailures, + supportedPassed, + unlisted, + }; +} diff --git a/packages/wpt-runner/scripts/evaluate-capabilities.test.mjs b/packages/wpt-runner/scripts/evaluate-capabilities.test.mjs new file mode 100644 index 00000000..38ba5781 --- /dev/null +++ b/packages/wpt-runner/scripts/evaluate-capabilities.test.mjs @@ -0,0 +1,79 @@ +import {describe, expect, it} from 'vitest'; +import {evaluateCapabilities} from './evaluate-capabilities.mjs'; + +const rows = [ + {path: 'test.html', status: 'supported', case: 'supported case', note: ''}, + { + path: 'test.html', + status: 'deferred', + case: 'deferred case', + note: 'blocked', + }, +]; + +function run(tests, status = 0) { + return { + state: tests.some((test) => test.status !== 0) ? 'failed' : 'passed', + path: 'test.html', + result: {tests, status: {status}}, + }; +} + +describe('capability evaluation', () => { + it('allows deferred failures and reports deferred passes for promotion', () => { + const failedDeferred = evaluateCapabilities( + run([ + {name: 'supported case', status: 0}, + {name: 'deferred case', status: 1}, + ]), + rows, + ); + expect(failedDeferred.failed).toBe(false); + expect(failedDeferred.deferredFailures).toHaveLength(1); + + const passingDeferred = evaluateCapabilities( + run([ + {name: 'supported case', status: 0}, + {name: 'deferred case', status: 0}, + ]), + rows, + ); + expect(passingDeferred.promotionCandidates).toHaveLength(1); + }); + + it('fails supported, missing, unlisted, duplicate, harness, and worker errors', () => { + expect( + evaluateCapabilities( + run([ + {name: 'supported case', status: 1}, + {name: 'deferred case', status: 1}, + ]), + rows, + ).failed, + ).toBe(true); + + const drift = evaluateCapabilities( + run([ + {name: 'supported case', status: 0}, + {name: 'new case', status: 0}, + ]), + rows, + ); + expect(drift.failed).toBe(true); + expect(drift.missing.map((row) => row.case)).toEqual(['deferred case']); + expect(drift.unlisted.map((test) => test.name)).toEqual(['new case']); + + expect( + evaluateCapabilities( + run([ + {name: 'supported case', status: 0}, + {name: 'supported case', status: 0}, + {name: 'deferred case', status: 1}, + ]), + rows, + ).duplicateResults, + ).toEqual(['supported case']); + expect(evaluateCapabilities(run([], 1), []).failed).toBe(true); + expect(evaluateCapabilities({state: 'error'}, []).failed).toBe(true); + }); +}); diff --git a/packages/wpt-runner/scripts/format-capabilities.mjs b/packages/wpt-runner/scripts/format-capabilities.mjs new file mode 100644 index 00000000..450fb50b --- /dev/null +++ b/packages/wpt-runner/scripts/format-capabilities.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {parseCapabilities, serializeCapabilities} from './capabilities.mjs'; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const capabilitiesPath = path.join(packageRoot, 'capabilities.tsv'); +const source = await fs.readFile(capabilitiesPath, 'utf8'); +const rows = parseCapabilities(source, capabilitiesPath); +await fs.writeFile(capabilitiesPath, serializeCapabilities(rows)); +console.log( + `[wpt] formatted ${path.relative(process.cwd(), capabilitiesPath)}`, +); diff --git a/packages/wpt-runner/scripts/paths.test.mjs b/packages/wpt-runner/scripts/paths.test.mjs new file mode 100644 index 00000000..aa6cbeff --- /dev/null +++ b/packages/wpt-runner/scripts/paths.test.mjs @@ -0,0 +1,26 @@ +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; +import {resolveServedWptFile} from '../src/paths.ts'; + +const roots = {fixtureRoot: '/fixtures', wptRoot: '/wpt'}; + +describe('WPT file serving paths', () => { + it('resolves runner fixtures and upstream files inside their roots', () => { + expect(resolveServedWptFile('__runner__/smoke.html', roots)).toBe( + path.resolve('/fixtures/smoke.html'), + ); + expect(resolveServedWptFile('resources/testharness.js', roots)).toBe( + path.resolve('/wpt/resources/testharness.js'), + ); + }); + + it.each([ + '../secret', + 'resources/../../secret', + '/absolute', + String.raw`resources\secret`, + 'resources//testharness.js', + ])('rejects unsafe path %s', (unsafePath) => { + expect(resolveServedWptFile(unsafePath, roots)).toBeNull(); + }); +}); diff --git a/packages/wpt-runner/scripts/prepare-wpt.mjs b/packages/wpt-runner/scripts/prepare-wpt.mjs new file mode 100644 index 00000000..83b835e3 --- /dev/null +++ b/packages/wpt-runner/scripts/prepare-wpt.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node + +import {createHash, randomBytes} from 'node:crypto'; +import fs from 'node:fs/promises'; +import {createWriteStream} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {Readable, Transform} from 'node:stream'; +import {pipeline} from 'node:stream/promises'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import {extract, list} from 'tar'; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const repositoryRoot = path.resolve(packageRoot, '../..'); +const defaultLockPath = path.join(packageRoot, 'wpt.lock.json'); +const completionMarker = '.remote-dom-wpt-complete.json'; +const requiredFiles = ['resources/testharness.js']; + +export async function prepareWpt({ + env = process.env, + lockPath = defaultLockPath, + log = console.log, +} = {}) { + if (env.WPT_ROOT) { + const override = path.resolve(env.WPT_ROOT); + await verifySentinels(override); + log(`[wpt] WPT root override: ${override}`); + return override; + } + + const lock = await readLock(lockPath); + const cacheRoot = resolveCacheRoot(env); + const revisionRoot = path.join(cacheRoot, lock.revision); + const sourceRoot = path.join(revisionRoot, 'source'); + log(`[wpt] cache root: ${cacheRoot}`); + + if (await isCompletedSource(sourceRoot, lock)) { + log(`[wpt] using cached WPT ${lock.revision}`); + return sourceRoot; + } + + await fs.mkdir(revisionRoot, {recursive: true}); + const nonce = `${process.pid}-${randomBytes(8).toString('hex')}`; + const archivePath = path.join(revisionRoot, `.archive-${nonce}.tar.gz`); + const temporarySource = path.join(revisionRoot, `.source-${nonce}`); + + try { + log(`[wpt] downloading ${lock.archiveUrl}`); + const actualChecksum = await downloadArchive(lock.archiveUrl, archivePath); + assertChecksum(actualChecksum, lock.sha256); + await validateArchive(archivePath, lock.revision); + + await fs.mkdir(temporarySource); + await extract({ + cwd: temporarySource, + file: archivePath, + preservePaths: false, + strip: 1, + }); + await verifySentinels(temporarySource); + await fs.writeFile( + path.join(temporarySource, completionMarker), + `${JSON.stringify({revision: lock.revision, sha256: lock.sha256}, null, 2)}\n`, + {flag: 'wx'}, + ); + + await publishPreparedSource(temporarySource, sourceRoot, lock, log); + return sourceRoot; + } finally { + await Promise.allSettled([ + fs.rm(archivePath, {force: true}), + fs.rm(temporarySource, {force: true, recursive: true}), + ]); + } +} + +export async function publishPreparedSource( + temporarySource, + sourceRoot, + lock, + log = console.log, +) { + try { + await fs.rename(temporarySource, sourceRoot); + log(`[wpt] installed WPT ${lock.revision}`); + } catch (error) { + if (!isDestinationExistsError(error)) throw error; + if (!(await isCompletedSource(sourceRoot, lock))) { + throw new Error( + `Another process created an invalid WPT cache entry at ${sourceRoot}. Remove it and retry.`, + {cause: error}, + ); + } + log(`[wpt] another process installed WPT ${lock.revision}; reusing it`); + } +} + +export function resolveCacheRoot(env = process.env) { + if (env.WPT_CACHE_DIR) return path.resolve(env.WPT_CACHE_DIR); + if (env.CI) return path.join(repositoryRoot, '.cache/wpt'); + if (env.XDG_CACHE_HOME) + return path.resolve(env.XDG_CACHE_HOME, 'remote-dom/wpt'); + + const home = env.HOME || os.homedir(); + if (!home) { + throw new Error( + 'Cannot resolve the WPT cache: HOME is unset. Set WPT_CACHE_DIR.', + ); + } + return path.resolve(home, '.cache/remote-dom/wpt'); +} + +export function assertChecksum(actual, expected) { + if (actual !== expected) { + throw new Error( + `WPT archive checksum mismatch: expected ${expected}, got ${actual}.`, + ); + } +} + +export function validateArchiveEntry(entry, revision) { + const expectedRoot = `wpt-${revision}`; + const entryPath = validateArchivePath( + entry.path, + expectedRoot, + 'archive entry', + ); + const allowedTypes = new Set([ + 'File', + 'OldFile', + 'Directory', + 'SymbolicLink', + 'ExtendedHeader', + 'GlobalExtendedHeader', + ]); + + if (!allowedTypes.has(entry.type)) { + throw new Error( + `Unsafe WPT archive entry type ${JSON.stringify(entry.type)} for ${JSON.stringify(entry.path)}.`, + ); + } + + if (entry.type === 'SymbolicLink') { + const linkPath = entry.linkpath; + if ( + !linkPath || + path.posix.isAbsolute(linkPath) || + linkPath.includes('\\') + ) { + throw new Error( + `Unsafe WPT archive symlink target for ${JSON.stringify(entry.path)}.`, + ); + } + const target = path.posix.normalize( + path.posix.join(path.posix.dirname(entryPath), linkPath), + ); + validateArchivePath(target, expectedRoot, 'archive symlink target'); + } +} + +async function readLock(lockPath) { + const lock = JSON.parse(await fs.readFile(lockPath, 'utf8')); + for (const key of ['repository', 'revision', 'archiveUrl', 'sha256']) { + if (typeof lock[key] !== 'string' || !lock[key]) { + throw new Error(`Invalid WPT lock ${lockPath}: ${key} is required.`); + } + } + if (!/^[0-9a-f]{40}$/.test(lock.revision)) { + throw new Error( + `Invalid WPT lock ${lockPath}: revision must be a full commit SHA.`, + ); + } + if (!/^[0-9a-f]{64}$/.test(lock.sha256)) { + throw new Error( + `Invalid WPT lock ${lockPath}: sha256 must be a lowercase SHA-256 digest.`, + ); + } + const archiveUrl = new URL(lock.archiveUrl); + if (archiveUrl.protocol !== 'https:') { + throw new Error(`Invalid WPT lock ${lockPath}: archiveUrl must use HTTPS.`); + } + return lock; +} + +async function downloadArchive(url, destination) { + const response = await fetch(url, {redirect: 'follow'}); + if (!response.ok || !response.body) { + throw new Error( + `Failed to download WPT archive: ${response.status} ${response.statusText}`, + ); + } + + const hash = createHash('sha256'); + const hasher = new Transform({ + transform(chunk, _encoding, callback) { + hash.update(chunk); + callback(null, chunk); + }, + }); + + await pipeline( + Readable.fromWeb(response.body), + hasher, + createWriteStream(destination, {flags: 'wx'}), + ); + return hash.digest('hex'); +} + +async function validateArchive(archivePath, revision) { + let validationError; + await list({ + file: archivePath, + onentry(entry) { + try { + validateArchiveEntry(entry, revision); + } catch (error) { + validationError ??= error; + } + entry.resume(); + }, + }); + if (validationError) throw validationError; +} + +function validateArchivePath(value, expectedRoot, label) { + if (!value || value.includes('\\') || path.posix.isAbsolute(value)) { + throw new Error(`Unsafe WPT ${label}: ${JSON.stringify(value)}.`); + } + const withoutTrailingSlash = value.replace(/\/$/, ''); + if (withoutTrailingSlash.split('/').includes('..')) { + throw new Error(`Unsafe WPT ${label}: ${JSON.stringify(value)}.`); + } + const normalized = path.posix.normalize(withoutTrailingSlash); + if ( + normalized !== expectedRoot && + !normalized.startsWith(`${expectedRoot}/`) + ) { + throw new Error(`Unsafe WPT ${label}: ${JSON.stringify(value)}.`); + } + return normalized; +} + +async function verifySentinels(sourceRoot) { + for (const relativePath of requiredFiles) { + const sentinel = path.join(sourceRoot, relativePath); + const stat = await fs.stat(sentinel).catch(() => null); + if (!stat?.isFile()) { + throw new Error( + `Invalid WPT root ${sourceRoot}: missing ${relativePath}.`, + ); + } + } +} + +async function isCompletedSource(sourceRoot, lock) { + try { + await verifySentinels(sourceRoot); + const marker = JSON.parse( + await fs.readFile(path.join(sourceRoot, completionMarker), 'utf8'), + ); + return marker.revision === lock.revision && marker.sha256 === lock.sha256; + } catch { + return false; + } +} + +function isDestinationExistsError(error) { + return error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY'; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const root = await prepareWpt(); + console.log(root); + } catch (error) { + console.error( + error instanceof Error ? error.stack || error.message : String(error), + ); + process.exitCode = 1; + } +} diff --git a/packages/wpt-runner/scripts/prepare-wpt.test.mjs b/packages/wpt-runner/scripts/prepare-wpt.test.mjs new file mode 100644 index 00000000..5cc53bf8 --- /dev/null +++ b/packages/wpt-runner/scripts/prepare-wpt.test.mjs @@ -0,0 +1,124 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; +import { + assertChecksum, + prepareWpt, + publishPreparedSource, + resolveCacheRoot, + validateArchiveEntry, +} from './prepare-wpt.mjs'; + +const temporaryDirectories = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, {force: true, recursive: true})), + ); +}); + +describe('WPT preparation', () => { + it('resolves overrides, CI, XDG, and home caches in order', () => { + expect(resolveCacheRoot({WPT_CACHE_DIR: './override', CI: 'true'})).toBe( + path.resolve('./override'), + ); + expect(resolveCacheRoot({CI: 'true', HOME: '/home/test'})).toMatch( + /remote-dom[\\/]\.cache[\\/]wpt$/, + ); + expect( + resolveCacheRoot({XDG_CACHE_HOME: '/cache', HOME: '/home/test'}), + ).toBe(path.resolve('/cache/remote-dom/wpt')); + expect(resolveCacheRoot({HOME: '/home/test'})).toBe( + path.resolve('/home/test/.cache/remote-dom/wpt'), + ); + }); + + it('uses a verified WPT_ROOT without downloading', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'remote-dom-wpt-root-'), + ); + temporaryDirectories.push(root); + await fs.mkdir(path.join(root, 'resources')); + await fs.writeFile( + path.join(root, 'resources/testharness.js'), + '/* harness */', + ); + const logs = []; + + await expect( + prepareWpt({env: {WPT_ROOT: root}, log: (message) => logs.push(message)}), + ).resolves.toBe(root); + expect(logs.join('\n')).toContain('WPT root override'); + }); + + it('publishes one complete source when concurrent prepares race', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'remote-dom-wpt-race-'), + ); + temporaryDirectories.push(root); + const lock = {revision: 'a'.repeat(40), sha256: 'b'.repeat(64)}; + const source = path.join(root, 'source'); + const candidates = [ + path.join(root, 'candidate-1'), + path.join(root, 'candidate-2'), + ]; + + for (const candidate of candidates) { + await fs.mkdir(path.join(candidate, 'resources'), {recursive: true}); + await fs.writeFile( + path.join(candidate, 'resources/testharness.js'), + '/* harness */', + ); + await fs.writeFile( + path.join(candidate, '.remote-dom-wpt-complete.json'), + JSON.stringify(lock), + ); + } + + await Promise.all( + candidates.map((candidate) => + publishPreparedSource(candidate, source, lock, () => {}), + ), + ); + await expect( + fs.readFile(path.join(source, 'resources/testharness.js'), 'utf8'), + ).resolves.toBe('/* harness */'); + }); + + it('rejects checksum mismatches', () => { + expect(() => assertChecksum('actual', 'expected')).toThrow( + 'checksum mismatch', + ); + }); + + it('rejects traversal and unsafe links before extraction', () => { + const revision = 'a'.repeat(40); + const root = `wpt-${revision}`; + expect(() => + validateArchiveEntry({path: `${root}/../escape`, type: 'File'}, revision), + ).toThrow('Unsafe WPT archive entry'); + expect(() => + validateArchiveEntry( + { + path: `${root}/nested/link`, + type: 'SymbolicLink', + linkpath: '../../../escape', + }, + revision, + ), + ).toThrow('Unsafe WPT archive symlink target'); + expect(() => + validateArchiveEntry( + { + path: `${root}/nested/link`, + type: 'SymbolicLink', + linkpath: '../inside', + }, + revision, + ), + ).not.toThrow(); + }); +}); diff --git a/packages/wpt-runner/scripts/run-wpt.mjs b/packages/wpt-runner/scripts/run-wpt.mjs new file mode 100644 index 00000000..e7358a7d --- /dev/null +++ b/packages/wpt-runner/scripts/run-wpt.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import {chromium} from '@playwright/test'; +import {createServer} from 'vite'; +import { + compareCodeUnits, + readCapabilities, + rowsByPath, +} from './capabilities.mjs'; +import {evaluateCapabilities} from './evaluate-capabilities.mjs'; +import {prepareWpt} from './prepare-wpt.mjs'; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const capabilitiesPath = path.join(packageRoot, 'capabilities.tsv'); +const terminalStates = ['passed', 'failed', 'error']; +const options = parseArguments(process.argv.slice(2)); + +if (options.help) { + printHelp(); + process.exit(0); +} + +let server; +let browser; +let page; + +try { + const capabilities = await readCapabilities(capabilitiesPath); + const groupedCapabilities = rowsByPath(capabilities); + const selectedPaths = selectPaths(options, groupedCapabilities); + if (selectedPaths.length === 0) throw new Error('No WPT files selected.'); + + const wptRoot = await prepareWpt(); + process.env.WPT_ROOT = wptRoot; + + server = await createServer({ + root: packageRoot, + configFile: path.join(packageRoot, 'vite.config.ts'), + logLevel: options.verbose ? 'info' : 'warn', + server: { + host: options.host, + port: options.port, + strictPort: options.strictPort, + open: false, + }, + }); + await server.listen(); + const baseUrl = server.resolvedUrls?.local[0]; + if (!baseUrl) throw new Error('Vite did not report a local server URL.'); + console.log(`[wpt] serving ${baseUrl}`); + + browser = await chromium.launch({headless: !options.headed}); + page = await browser.newPage(); + if (options.verbose) { + page.on('console', (message) => + console.log(`[browser:${message.type()}] ${message.text()}`), + ); + } + page.on('pageerror', (error) => + console.error(`[browser:error] ${formatError(error)}`), + ); + + let failures = 0; + for (const testPath of selectedPaths) { + const run = await runWpt(page, baseUrl, testPath, options.timeoutMs); + const rows = options.enforceCapabilities + ? groupedCapabilities.get(testPath) + : undefined; + const failed = rows + ? printCapabilityResult(run, rows) + : printExploratoryResult(run); + if (failed) failures += 1; + } + + console.log( + `\n[wpt] ${selectedPaths.length - failures}/${selectedPaths.length} file(s) passed`, + ); + process.exitCode = failures === 0 ? 0 : 1; +} catch (error) { + console.error(`[wpt] ${formatError(error)}`); + process.exitCode = 1; +} finally { + await page?.close().catch(() => {}); + await browser?.close().catch(() => {}); + await server?.close().catch(() => {}); +} + +function parseArguments(arguments_) { + const parsed = { + enforceCapabilities: false, + headed: false, + help: false, + host: '127.0.0.1', + port: undefined, + strictPort: false, + testPaths: [], + timeoutMs: 30_000, + verbose: false, + }; + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === '--') continue; + if (argument === '-h' || argument === '--help') parsed.help = true; + else if (argument === '--capabilities') parsed.enforceCapabilities = true; + else if (argument === '--headed') parsed.headed = true; + else if (argument === '--strict-port') parsed.strictPort = true; + else if (argument === '--verbose') parsed.verbose = true; + else if (argument === '--host') + parsed.host = requireValue(arguments_, ++index, argument); + else if (argument.startsWith('--host=')) + parsed.host = argument.slice('--host='.length); + else if (argument === '--port') + parsed.port = parsePort(requireValue(arguments_, ++index, argument)); + else if (argument.startsWith('--port=')) + parsed.port = parsePort(argument.slice('--port='.length)); + else if (argument === '--timeout') { + parsed.timeoutMs = parseDuration( + requireValue(arguments_, ++index, argument), + ); + } else if (argument.startsWith('--timeout=')) { + parsed.timeoutMs = parseDuration(argument.slice('--timeout='.length)); + } else if (argument.startsWith('-')) + throw new Error(`Unknown option: ${argument}`); + else parsed.testPaths.push(argument); + } + + if (parsed.testPaths.length === 0) parsed.enforceCapabilities = true; + return parsed; +} + +function selectPaths(options, groupedCapabilities) { + if (options.testPaths.length === 0) return [...groupedCapabilities.keys()]; + + const paths = [...new Set(options.testPaths)].sort(compareCodeUnits); + if (options.enforceCapabilities) { + const missing = paths.filter( + (testPath) => !groupedCapabilities.has(testPath), + ); + if (missing.length > 0) { + throw new Error( + `WPT path(s) are not in capabilities.tsv: ${missing.map(JSON.stringify).join(', ')}`, + ); + } + } + return paths; +} + +async function runWpt(browserPage, baseUrl, testPath, timeoutMs) { + const url = new URL(baseUrl); + url.searchParams.set('path', testPath); + url.searchParams.set('autorun', '1'); + url.searchParams.set('timeout', String(timeoutMs)); + console.log(`\n[wpt] RUN ${testPath}`); + await browserPage.goto(url.href, {waitUntil: 'domcontentloaded'}); + + try { + await browserPage.waitForFunction( + (states) => states.includes(window.__WPT_LAST_RUN__?.state), + terminalStates, + {timeout: timeoutMs + 1_000}, + ); + return await browserPage.evaluate(() => window.__WPT_LAST_RUN__); + } catch { + const lastRun = await browserPage.evaluate( + (path) => + window.__WPT_LAST_RUN__ ?? { + state: 'error', + path, + warnings: [], + logs: [], + }, + testPath, + ); + return { + ...lastRun, + state: 'error', + error: `Timed out after ${timeoutMs}ms. Last state: ${lastRun.state}`, + }; + } +} + +function printExploratoryResult(run) { + const tests = run.result?.tests ?? []; + const failedTests = tests.filter((test) => test.status !== 0); + const failed = + run.state === 'error' || + run.result?.status.status !== 0 || + failedTests.length > 0; + console.log( + `[wpt] ${failed ? 'FAIL' : 'PASS'} ${run.path} (${tests.length} test(s))`, + ); + printWarnings(run); + printHarnessFailure(run); + for (const test of failedTests) printTestFailure(test, ' - '); + printRunError(run); + return failed; +} + +function printCapabilityResult(run, rows) { + const tests = run.result?.tests ?? []; + const summary = evaluateCapabilities(run, rows); + + console.log( + `[wpt] ${summary.failed ? 'FAIL' : 'PASS'} ${run.path} (${tests.length} test(s), classified)`, + ); + console.log( + ` supported: ${summary.supportedPassed.length} passed, ${summary.supportedFailures.length} failed`, + ); + console.log( + ` deferred: ${summary.promotionCandidates.length} passed, ${summary.deferredFailures.length} failed`, + ); + console.log( + ` unlisted: ${summary.unlisted.length}; missing: ${summary.missing.length}`, + ); + printWarnings(run); + printHarnessFailure(run); + + for (const {test} of summary.supportedFailures) + printTestFailure(test, ' - '); + for (const name of summary.duplicateResults) + console.log(` duplicate result: ${name}`); + for (const test of summary.unlisted) console.log(` unlisted: ${test.name}`); + for (const row of summary.missing) console.log(` missing: ${row.case}`); + for (const {row} of summary.deferredFailures) { + console.log(` deferred failure: ${row.case} β€” ${row.note}`); + } + for (const {row} of summary.promotionCandidates) { + console.log(` promotion candidate: ${row.case} β€” ${row.note}`); + } + printRunError(run); + return summary.failed; +} + +function printWarnings(run) { + for (const warning of run.warnings ?? []) + console.log(` warning: ${warning}`); +} + +function printHarnessFailure(run) { + if (run.result && run.result.status.status !== 0) { + console.log( + ` harness status ${run.result.status.status}: ${run.result.status.message || ''}`, + ); + } +} + +function printTestFailure(test, prefix) { + console.log(`${prefix}${test.name}: status ${test.status}`); + if (test.message) console.log(indent(test.message, ' ')); + if (test.stack) console.log(indent(test.stack, ' ')); +} + +function printRunError(run) { + if (run.error) console.log(indent(run.error, ' ')); + if (run.state === 'error' && run.logs?.length > 0) { + console.log(' recent logs:'); + for (const line of run.logs.slice(-20)) console.log(indent(line, ' ')); + } +} + +function requireValue(arguments_, index, flag) { + const value = arguments_[index]; + if (!value) throw new Error(`${flag} requires a value.`); + return value; +} + +function parsePort(value) { + const port = Number(value); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new Error(`Invalid --port value: ${value}`); + } + return port; +} + +function parseDuration(value) { + const match = /^(\d+)(ms|s)?$/.exec(value); + if (!match) throw new Error(`Invalid --timeout value: ${value}`); + return Number(match[1]) * (match[2] === 's' ? 1000 : 1); +} + +function indent(text, prefix) { + return String(text) + .split('\n') + .map((line) => `${prefix}${line}`) + .join('\n'); +} + +function formatError(error) { + return error instanceof Error ? error.stack || error.message : String(error); +} + +function printHelp() { + console.log(`Run selected WPT testharness HTML files against @remote-dom/polyfill. + +Usage: + pnpm test:wpt + pnpm test:wpt -- [options] [wpt-path ...] + +Options: + --capabilities Enforce capabilities.tsv for explicit paths. + --headed Show Playwright Chromium. + --verbose Print browser console and Vite logs. + --timeout Per-file timeout (30000, 30000ms, or 30s). + --host Vite host (default: 127.0.0.1). + --port Vite port. + --strict-port Fail when the requested port is unavailable. + -h, --help Show this help. + +With no paths, the runner executes every unique capabilities.tsv path in canonical +order and enforces every row. Explicit paths are exploratory unless --capabilities +is present. Set WPT_ROOT to reuse an existing checkout or WPT_CACHE_DIR to override +the revision-addressed download cache.`); +} diff --git a/packages/wpt-runner/src/adapter.ts b/packages/wpt-runner/src/adapter.ts new file mode 100644 index 00000000..6cccff20 --- /dev/null +++ b/packages/wpt-runner/src/adapter.ts @@ -0,0 +1,323 @@ +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; +const WPT_ORIGIN = 'https://wpt.local'; +const RUNNER_SOURCE_PATHS = { + harnessSetup: `${import.meta.env.BASE_URL}wpt-runner/harness-setup.js`, + runtimeShims: `${import.meta.env.BASE_URL}wpt-runner/runtime-shims.js`, +}; + +interface WptUrl { + path: string; + pathname: string; + search: string; + href: string; +} + +interface NodeSpec { + kind: 'element' | 'text'; + name?: string; + namespace?: 'html' | 'svg'; + attributes?: Array<[string, string]>; + children?: NodeSpec[]; + text?: string; +} + +interface AppendOperation { + type: 'append'; + target: 'head' | 'body'; + node: NodeSpec; +} + +interface ScriptOperation { + type: 'script'; + label: string; + source: string; + harness: boolean; +} + +type Operation = AppendOperation | ScriptOperation; + +export interface WptBundle { + generatedSource: string; + harnessSource: string; + sourceHtml: string; + testSource: string; + warnings: string[]; +} + +export async function buildWptBundle(testPath: string): Promise { + const testUrl = parseWptUrl(testPath); + if (!testUrl.path.endsWith('.html') && !testUrl.path.endsWith('.htm')) { + throw new Error( + `Only testharness.js HTML files are supported: ${testPath}`, + ); + } + + const [sourceHtml, runtimeShims, harnessSetup] = await Promise.all([ + fetchWptFile(testUrl.path), + fetchRunnerSource(RUNNER_SOURCE_PATHS.runtimeShims), + fetchRunnerSource(RUNNER_SOURCE_PATHS.harnessSetup), + ]); + const warnings: string[] = []; + const operations = await collectOperations( + sourceHtml, + testUrl.path, + warnings, + ); + + if ( + !operations.some( + (operation) => operation.type === 'script' && operation.harness, + ) + ) { + throw new Error(`${testPath} does not load /resources/testharness.js.`); + } + + const context = JSON.stringify({ + path: testUrl.path, + pathname: testUrl.pathname, + search: testUrl.search, + href: testUrl.href, + }); + const operationSource = operations.map(emitOperation).join('\n\n'); + const harnessSource = operations + .filter((operation) => operation.type === 'script' && operation.harness) + .map(emitOperation) + .join('\n\n'); + const testSource = operations + .filter((operation) => !(operation.type === 'script' && operation.harness)) + .map(emitOperation) + .join('\n\n'); + + return { + generatedSource: [ + `const __WPT_CONTEXT__ = ${context};`, + runtimeShims, + harnessSetup, + operationSource, + `dispatchEvent(new Event('load'));`, + ].join('\n\n'), + harnessSource: [runtimeShims, harnessSetup, harnessSource].join('\n\n'), + sourceHtml, + testSource, + warnings, + }; +} + +async function collectOperations( + source: string, + ownerPath: string, + warnings: string[], +): Promise { + const parsed = new DOMParser().parseFromString(source, 'text/html'); + const operations: Operation[] = []; + await collectNodes( + parsed.head.childNodes, + 'head', + ownerPath, + operations, + warnings, + ); + await collectNodes( + parsed.body.childNodes, + 'body', + ownerPath, + operations, + warnings, + ); + return operations; +} + +async function collectNodes( + nodes: NodeListOf, + target: 'head' | 'body', + ownerPath: string, + operations: Operation[], + warnings: string[], +) { + for (const node of nodes) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? ''; + if (text.trim()) + operations.push({type: 'append', target, node: {kind: 'text', text}}); + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) continue; + + const element = node as Element; + if (element.localName === 'script') { + const script = element as HTMLScriptElement; + const type = script.getAttribute('type'); + if ( + type && + type !== 'text/javascript' && + type !== 'application/javascript' + ) { + throw new Error( + `Unsupported script type ${JSON.stringify(type)} in ${ownerPath}.`, + ); + } + + const src = script.getAttribute('src'); + if (!src) { + operations.push({ + type: 'script', + label: `${ownerPath} inline script`, + source: script.textContent ?? '', + harness: false, + }); + continue; + } + + const resolvedPath = resolveWptPath(src, ownerPath); + if (isHarnessResource(resolvedPath, 'testharnessreport.js')) { + warnings.push( + 'Skipped testharnessreport.js; results are captured programmatically.', + ); + continue; + } + const harness = isHarnessResource(resolvedPath, 'testharness.js'); + operations.push({ + type: 'script', + label: resolvedPath, + source: await fetchWptFile(resolvedPath), + harness, + }); + continue; + } + + operations.push({ + type: 'append', + target, + node: serializeNode(element, ownerPath), + }); + } +} + +function serializeNode(node: ChildNode, ownerPath: string): NodeSpec { + if (node.nodeType === Node.TEXT_NODE) { + return {kind: 'text', text: node.textContent ?? ''}; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + throw new Error(`Unsupported node type ${node.nodeType} in ${ownerPath}.`); + } + + const element = node as Element; + if (element.localName === 'script') { + throw new Error(`Nested scripts are not supported in ${ownerPath}.`); + } + + return { + kind: 'element', + name: element.localName, + namespace: element.namespaceURI === SVG_NAMESPACE ? 'svg' : 'html', + attributes: Array.from(element.attributes, (attribute) => [ + attribute.name, + attribute.value, + ]), + children: Array.from(element.childNodes, (child) => + serializeNode(child, ownerPath), + ), + }; +} + +function parseWptUrl(input: string): WptUrl { + const trimmed = input.trim(); + if (!trimmed) throw new Error('Missing WPT path.'); + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('//')) { + throw new Error(`External WPT paths are not supported: ${input}`); + } + + const suffixIndex = trimmed.search(/[?#]/); + const rawPath = suffixIndex < 0 ? trimmed : trimmed.slice(0, suffixIndex); + const decodedPath = decodeURIComponent(rawPath).replace(/^\/+/, ''); + if ( + !decodedPath || + decodedPath.includes('\\') || + decodedPath.split('/').some((segment) => segment === '..') + ) { + throw new Error(`Invalid WPT path: ${input}`); + } + + const url = new URL(trimmed.replace(/^\/+/, ''), `${WPT_ORIGIN}/`); + if (url.origin !== WPT_ORIGIN) { + throw new Error(`External WPT paths are not supported: ${input}`); + } + + const normalizedPath = decodeURIComponent(url.pathname).replace(/^\/+/, ''); + return { + path: normalizedPath, + pathname: `/${normalizedPath}`, + search: url.search, + href: `/${normalizedPath}${url.search}`, + }; +} + +function resolveWptPath(source: string, ownerPath: string) { + if (/^[a-z][a-z0-9+.-]*:/i.test(source) || source.startsWith('//')) { + throw new Error(`External WPT resource is not supported: ${source}`); + } + + const url = new URL(source, new URL(`/${ownerPath}`, WPT_ORIGIN)); + if (url.origin !== WPT_ORIGIN) { + throw new Error(`External WPT resource is not supported: ${source}`); + } + const resolved = decodeURIComponent(url.pathname).replace(/^\/+/, ''); + if (resolved.includes('\\') || !resolved) { + throw new Error(`Invalid WPT resource path: ${source}`); + } + return resolved; +} + +function isHarnessResource(resourcePath: string, filename: string) { + return ( + resourcePath === `resources/${filename}` || + resourcePath.endsWith(`/resources/${filename}`) + ); +} + +async function fetchWptFile(filePath: string) { + const response = await fetch( + `/__wpt-file?path=${encodeURIComponent(filePath)}`, + ); + if (!response.ok) throw new Error(await response.text()); + return await response.text(); +} + +async function fetchRunnerSource(sourcePath: string) { + const response = await fetch(sourcePath); + if (!response.ok) { + throw new Error( + `Failed to fetch runner source ${sourcePath}: ${await response.text()}`, + ); + } + return await response.text(); +} + +function emitOperation(operation: Operation) { + if (operation.type === 'append') { + return `__appendWptNode(document.${operation.target}, ${JSON.stringify(operation.node)});`; + } + + if (operation.harness) { + // The real harness selects its browser-window environment whenever it sees + // `document`, which requires unrelated DOM APIs such as getElementsByTagName. + // It also makes dedicated workers wait for an explicit done() call. Expose + // neither shape during harness initialization so it selects its shell + // environment, then restore the untouched polyfill globals for the test. + return [ + `// ${operation.label}`, + `const __wptEnvironmentNames = ['document', 'DedicatedWorkerGlobalScope', 'WorkerGlobalScope'];`, + `const __wptEnvironmentDescriptors = new Map(__wptEnvironmentNames.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));`, + `delete globalThis.document;`, + `for (const name of __wptEnvironmentNames.slice(1)) Object.defineProperty(globalThis, name, {configurable: true, value: function WptNonWorkerGlobal() {}});`, + operation.source, + `for (const [name, descriptor] of __wptEnvironmentDescriptors) {`, + ` if (descriptor) Object.defineProperty(globalThis, name, descriptor);`, + ` else delete globalThis[name];`, + `}`, + `__afterWptHarness();`, + ].join('\n'); + } + + return [`// ${operation.label}`, operation.source].join('\n'); +} diff --git a/packages/wpt-runner/src/main.ts b/packages/wpt-runner/src/main.ts new file mode 100644 index 00000000..251d4f6a --- /dev/null +++ b/packages/wpt-runner/src/main.ts @@ -0,0 +1,181 @@ +import {buildWptBundle} from './adapter.ts'; +import type {WorkerResponse, WptHarnessResult, WptRunRecord} from './types.ts'; +import './style.css'; + +declare const __WPT_ROOT__: string; + +declare global { + interface Window { + __WPT_LAST_RUN__: WptRunRecord; + __WPT_RUN_TEST__: (path: string) => Promise; + } +} + +const elements = { + run: requireElement('run'), + path: requireElement('path'), + status: requireElement('status'), + result: requireElement('result'), + log: requireElement('log'), + original: requireElement('original'), + generated: requireElement('generated'), + harness: requireElement('harness'), +}; + +let activeWorker: Worker | undefined; +let activeTimeout: ReturnType | undefined; +let currentRun: WptRunRecord = createRun('idle', ''); +window.__WPT_LAST_RUN__ = currentRun; +window.__WPT_RUN_TEST__ = runWptTest; +elements.status.textContent = `WPT root: ${__WPT_ROOT__}`; + +const parameters = new URLSearchParams(location.search); +const initialPath = parameters.get('path'); +const timeoutMs = parseTimeout(parameters.get('timeout')); +if (initialPath) elements.path.value = initialPath; + +parameters.get('autorun') === '1' && + queueMicrotask(() => void runCurrentPath()); +elements.run.addEventListener('click', () => void runCurrentPath()); +elements.path.addEventListener('keydown', (event) => { + if (event.key === 'Enter') void runCurrentPath(); +}); + +async function runCurrentPath() { + const testPath = elements.path.value.trim(); + if (!testPath) return currentRun; + elements.run.disabled = true; + try { + return await runWptTest(testPath); + } finally { + elements.run.disabled = false; + } +} + +async function runWptTest(testPath: string): Promise { + reset(testPath); + + try { + const bundle = await buildWptBundle(testPath); + currentRun.warnings = bundle.warnings; + sync(); + elements.original.textContent = bundle.sourceHtml; + elements.harness.textContent = bundle.harnessSource; + elements.generated.textContent = bundle.testSource; + for (const warning of bundle.warnings) appendLog(`warning: ${warning}`); + + const worker = new Worker(new URL('./worker.ts', import.meta.url), { + type: 'module', + }); + activeWorker = worker; + worker.onerror = (event) => finishWithError(event.error ?? event.message); + worker.onmessage = (event: MessageEvent) => { + if (worker !== activeWorker) return; + const message = event.data; + if (message.type === 'ready') { + currentRun.state = 'waiting'; + elements.status.textContent = + 'Worker ready; waiting for testharness completion…'; + sync(); + } else if (message.type === 'log') { + appendLog(`[${message.level}] ${message.text}`); + } else if (message.type === 'complete') { + finishWithResult(message.result); + } else if (message.type === 'error') { + finishWithError(message.error); + } + }; + worker.postMessage({ + type: 'run', + path: testPath, + source: bundle.generatedSource, + }); + activeTimeout = setTimeout(() => { + worker.terminate(); + finishWithError( + `Timed out after ${timeoutMs}ms waiting for testharness completion.`, + ); + }, timeoutMs); + } catch (error) { + finishWithError(error); + } + + return currentRun; +} + +function finishWithResult(result: WptHarnessResult) { + clearActiveTimeout(); + currentRun.result = result; + currentRun.state = hasFailures(result) ? 'failed' : 'passed'; + elements.status.textContent = + currentRun.state === 'passed' ? 'PASS' : 'Completed with failures.'; + elements.result.textContent = JSON.stringify(result, null, 2); + sync(); +} + +function finishWithError(error: unknown) { + clearActiveTimeout(); + const message = + error instanceof Error ? error.stack || error.message : String(error); + currentRun.state = 'error'; + currentRun.error = message; + elements.status.textContent = 'Runner error.'; + elements.result.textContent = message; + appendLog(message); + sync(); +} + +function reset(testPath: string) { + clearActiveTimeout(); + activeWorker?.terminate(); + activeWorker = undefined; + currentRun = createRun('running', testPath); + sync(); + elements.status.textContent = 'Preparing WPT source…'; + elements.result.textContent = 'Waiting for testharness completion…'; + elements.log.textContent = ''; + elements.original.textContent = 'Loading…'; + elements.harness.textContent = 'Loading…'; + elements.generated.textContent = 'Loading…'; +} + +function createRun(state: WptRunRecord['state'], path: string): WptRunRecord { + return {state, path, warnings: [], logs: []}; +} + +function appendLog(message: string) { + currentRun.logs.push(message); + elements.log.textContent += `${message}\n`; + sync(); +} + +function sync() { + window.__WPT_LAST_RUN__ = { + ...currentRun, + warnings: [...currentRun.warnings], + logs: [...currentRun.logs], + }; +} + +function hasFailures(result: WptHarnessResult) { + return ( + result.status.status !== 0 || result.tests.some((test) => test.status !== 0) + ); +} + +function clearActiveTimeout() { + if (activeTimeout) clearTimeout(activeTimeout); + activeTimeout = undefined; +} + +function parseTimeout(value: string | null) { + if (!value) return 30_000; + const timeout = Number(value); + return Number.isFinite(timeout) && timeout > 0 ? timeout : 30_000; +} + +function requireElement(id: string) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing #${id}.`); + return element as ElementType; +} diff --git a/packages/wpt-runner/src/paths.ts b/packages/wpt-runner/src/paths.ts new file mode 100644 index 00000000..7123cb2d --- /dev/null +++ b/packages/wpt-runner/src/paths.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; + +export function resolveServedWptFile( + rawPath: string | null, + {fixtureRoot, wptRoot}: {fixtureRoot: string; wptRoot?: string}, +) { + if (!rawPath || rawPath.includes('\\') || path.posix.isAbsolute(rawPath)) { + return null; + } + const segments = rawPath.split('/'); + if (segments.some((segment) => segment === '..' || segment === '')) { + return null; + } + + if (rawPath.startsWith('__runner__/')) { + return containedPath(fixtureRoot, rawPath.slice('__runner__/'.length)); + } + return wptRoot ? containedPath(wptRoot, rawPath) : null; +} + +function containedPath(root: string, relativePath: string) { + const resolved = path.resolve(root, relativePath); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + return null; + } + return resolved; +} diff --git a/packages/wpt-runner/src/style.css b/packages/wpt-runner/src/style.css new file mode 100644 index 00000000..b1aee871 --- /dev/null +++ b/packages/wpt-runner/src/style.css @@ -0,0 +1,74 @@ +:root { + color: #202223; + background: #f6f6f7; + font-family: system-ui, sans-serif; +} + +body { + margin: 0; +} + +main { + display: grid; + gap: 1rem; + margin: 0 auto; + max-width: 90rem; + padding: 1.5rem; +} + +header, +.controls { + align-items: end; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +h1, +h2, +p { + margin-block: 0; +} + +label { + display: grid; + flex: 1; + gap: 0.35rem; +} + +input, +button { + border: 1px solid #8c9196; + border-radius: 0.4rem; + font: inherit; + padding: 0.6rem 0.75rem; +} + +button { + background: #202223; + color: white; + cursor: pointer; +} + +.grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); +} + +.panel { + background: white; + border: 1px solid #e1e3e5; + border-radius: 0.6rem; + min-width: 0; + padding: 1rem; +} + +pre { + overflow: auto; + white-space: pre-wrap; +} + +details + details { + margin-top: 0.75rem; +} diff --git a/packages/wpt-runner/src/types.ts b/packages/wpt-runner/src/types.ts new file mode 100644 index 00000000..955ee04e --- /dev/null +++ b/packages/wpt-runner/src/types.ts @@ -0,0 +1,46 @@ +export interface WptHarnessTestResult { + name: string; + status: number; + message?: string; + stack?: string; +} + +export interface WptHarnessStatus { + status: number; + message?: string; + stack?: string; +} + +export interface WptHarnessResult { + tests: WptHarnessTestResult[]; + status: WptHarnessStatus; +} + +export type WptRunState = + | 'idle' + | 'running' + | 'waiting' + | 'passed' + | 'failed' + | 'error'; + +export interface WptRunRecord { + state: WptRunState; + path: string; + warnings: string[]; + logs: string[]; + result?: WptHarnessResult; + error?: string; +} + +export type WorkerRequest = { + type: 'run'; + path: string; + source: string; +}; + +export type WorkerResponse = + | {type: 'ready'} + | {type: 'log'; level: string; text: string} + | {type: 'complete'; result: WptHarnessResult} + | {type: 'error'; error: string}; diff --git a/packages/wpt-runner/src/worker.ts b/packages/wpt-runner/src/worker.ts new file mode 100644 index 00000000..d0903a7f --- /dev/null +++ b/packages/wpt-runner/src/worker.ts @@ -0,0 +1,76 @@ +/// + +import {Window} from '@remote-dom/polyfill'; +import type {WorkerRequest, WorkerResponse} from './types.ts'; + +const workerGlobal = globalThis as unknown as DedicatedWorkerGlobalScope; +const nativePostMessage = workerGlobal.postMessage.bind(workerGlobal); +const nativeConsole = globalThis.console; +const nativeAddEventListener = workerGlobal.addEventListener.bind(workerGlobal); + +function respond(message: WorkerResponse) { + nativePostMessage(message); +} + +nativeAddEventListener('error', (event) => { + respond({type: 'error', error: formatError(event.error ?? event.message)}); +}); + +nativeAddEventListener('unhandledrejection', (event) => { + respond({type: 'error', error: formatError(event.reason)}); +}); + +workerGlobal.onmessage = (event: MessageEvent) => { + if (event.data.type !== 'run') return; + void run(event.data); +}; + +async function run(request: Extract) { + try { + const window = new Window(); + Window.setGlobalThis(window); + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + value: nativePostMessage, + writable: true, + }); + installConsoleTransport(); + respond({type: 'ready'}); + + const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as new (source: string) => () => Promise; + await new AsyncFunction( + `${request.source}\n//# sourceURL=wpt:${request.path}`, + )(); + } catch (error) { + respond({type: 'error', error: formatError(error)}); + } +} + +function installConsoleTransport() { + const transported = Object.create(nativeConsole) as Console; + for (const level of ['debug', 'info', 'log', 'warn', 'error'] as const) { + transported[level] = (...values: unknown[]) => { + respond({type: 'log', level, text: values.map(formatValue).join(' ')}); + nativeConsole[level](...values); + }; + } + Object.defineProperty(globalThis, 'console', { + configurable: true, + value: transported, + writable: true, + }); +} + +function formatValue(value: unknown) { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function formatError(error: unknown) { + return error instanceof Error ? error.stack || error.message : String(error); +} diff --git a/packages/wpt-runner/tsconfig.json b/packages/wpt-runner/tsconfig.json new file mode 100644 index 00000000..93cdbe39 --- /dev/null +++ b/packages/wpt-runner/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@quilted/typescript/tsconfig.package.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "vite/client"] + }, + "include": ["src", "vite.config.ts"], + "references": [{"path": "../polyfill"}] +} diff --git a/packages/wpt-runner/vite.config.ts b/packages/wpt-runner/vite.config.ts new file mode 100644 index 00000000..c077c99b --- /dev/null +++ b/packages/wpt-runner/vite.config.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import type {ServerResponse} from 'node:http'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {defineConfig, type Plugin} from 'vite'; +import {resolveServedWptFile} from './src/paths.ts'; + +const packageRoot = path.dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = path.join(packageRoot, 'fixtures'); +const wptRoot = process.env.WPT_ROOT + ? path.resolve(process.env.WPT_ROOT) + : undefined; + +function sendText(response: ServerResponse, status: number, text: string) { + response.statusCode = status; + response.setHeader('content-type', 'text/plain; charset=utf-8'); + response.end(text); +} + +function wptFilesPlugin(): Plugin { + return { + name: 'wpt-files', + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (!request.url) return next(); + const url = new URL(request.url, 'http://localhost'); + if (url.pathname !== '/__wpt-file') return next(); + + const file = resolveServedWptFile(url.searchParams.get('path'), { + fixtureRoot, + wptRoot, + }); + if (!file) return sendText(response, 400, 'Invalid WPT path.'); + const stat = fs.statSync(file, {throwIfNoEntry: false}); + if (!stat?.isFile()) + return sendText(response, 404, `Missing WPT file: ${file}`); + + response.statusCode = 200; + response.setHeader('content-type', 'text/plain; charset=utf-8'); + fs.createReadStream(file).pipe(response); + }); + }, + }; +} + +export default defineConfig({ + plugins: [wptFilesPlugin()], + resolve: { + conditions: ['quilt:source'], + }, + server: { + host: '127.0.0.1', + fs: { + allow: [packageRoot, fixtureRoot, ...(wptRoot ? [wptRoot] : [])], + }, + }, + define: { + __WPT_ROOT__: JSON.stringify(wptRoot ?? 'not prepared'), + }, +}); diff --git a/packages/wpt-runner/wpt.lock.json b/packages/wpt-runner/wpt.lock.json new file mode 100644 index 00000000..7d26fd1a --- /dev/null +++ b/packages/wpt-runner/wpt.lock.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/web-platform-tests/wpt", + "revision": "0e52f363dea9dd38ffc3c3b4ec3ea054e93ba5bc", + "archiveUrl": "https://codeload.github.com/web-platform-tests/wpt/tar.gz/0e52f363dea9dd38ffc3c3b4ec3ea054e93ba5bc", + "sha256": "252b9837a3d70c7fd42c959b581cce617ed0ac68cf17637a868707ce756a55b0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f90b71d..83c9748c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,16 @@ importers: specifier: ^1.8.0 version: 1.8.0 + packages/wpt-runner: + dependencies: + '@remote-dom/polyfill': + specifier: workspace:* + version: link:../polyfill + devDependencies: + tar: + specifier: 7.5.10 + version: 7.5.10 + packages: '@ampproject/remapping@2.2.1': @@ -1236,6 +1246,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1882,6 +1896,10 @@ packages: check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + ci-info@3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} @@ -2590,6 +2608,14 @@ packages: resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mlly@1.4.2: resolution: {integrity: sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==} @@ -3071,6 +3097,10 @@ packages: systemjs@6.14.2: resolution: {integrity: sha512-1TlOwvKWdXxAY9vba+huLu99zrQURDWA8pUTYsRIYDZYQbGyK+pyEP4h4dlySsqo7ozyJBmYD20F+iUHhAltEg==} + tar@7.5.10: + resolution: {integrity: sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==} + engines: {node: '>=18'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -3337,6 +3367,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4556,6 +4590,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -5297,6 +5335,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + chownr@3.0.0: {} + ci-info@3.8.0: {} cliui@8.0.1: @@ -5995,6 +6035,12 @@ snapshots: minipass@7.0.4: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mlly@1.4.2: dependencies: acorn: 8.10.0 @@ -6457,6 +6503,14 @@ snapshots: systemjs@6.14.2: {} + tar@7.5.10: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + term-size@2.2.1: {} tinybench@2.5.1: {} @@ -6685,6 +6739,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/tsconfig.json b/tsconfig.json index f47669ea..5dc61f76 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ {"path": "./packages/polyfill"}, {"path": "./packages/preact"}, {"path": "./packages/react"}, - {"path": "./packages/signals"} + {"path": "./packages/signals"}, + {"path": "./packages/wpt-runner"} ] } From 0db973bf75b83f79b9ea5d6ba699cdfdf759f9b7 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 10:23:01 -0700 Subject: [PATCH 02/14] Address WPT runner review feedback --- packages/wpt-runner/README.md | 2 +- packages/wpt-runner/scripts/paths.test.mjs | 2 +- packages/wpt-runner/scripts/prepare-wpt.mjs | 132 ++++++++++-------- .../wpt-runner/scripts/prepare-wpt.test.mjs | 29 +++- packages/wpt-runner/scripts/run-wpt.mjs | 77 ++++------ packages/wpt-runner/src/paths.ts | 27 ---- packages/wpt-runner/vite.config.ts | 27 +++- 7 files changed, 156 insertions(+), 140 deletions(-) delete mode 100644 packages/wpt-runner/src/paths.ts diff --git a/packages/wpt-runner/README.md b/packages/wpt-runner/README.md index cef736a2..ead20642 100644 --- a/packages/wpt-runner/README.md +++ b/packages/wpt-runner/README.md @@ -43,7 +43,7 @@ The cache root resolves in this order: 3. `${XDG_CACHE_HOME}/remote-dom/wpt` 4. `${HOME}/.cache/remote-dom/wpt` -Each revision installs under `//source`. Download and extraction happen in process-unique temporary paths. A completed source tree is moved into place atomically, so concurrent worktrees can safely race to populate the shared cache. Old revisions are not deleted automatically. +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. diff --git a/packages/wpt-runner/scripts/paths.test.mjs b/packages/wpt-runner/scripts/paths.test.mjs index aa6cbeff..3c8814ae 100644 --- a/packages/wpt-runner/scripts/paths.test.mjs +++ b/packages/wpt-runner/scripts/paths.test.mjs @@ -1,6 +1,6 @@ import path from 'node:path'; import {describe, expect, it} from 'vitest'; -import {resolveServedWptFile} from '../src/paths.ts'; +import {resolveServedWptFile} from '../vite.config.ts'; const roots = {fixtureRoot: '/fixtures', wptRoot: '/wpt'}; diff --git a/packages/wpt-runner/scripts/prepare-wpt.mjs b/packages/wpt-runner/scripts/prepare-wpt.mjs index 83b835e3..88760b8a 100644 --- a/packages/wpt-runner/scripts/prepare-wpt.mjs +++ b/packages/wpt-runner/scripts/prepare-wpt.mjs @@ -38,15 +38,33 @@ export async function prepareWpt({ const sourceRoot = path.join(revisionRoot, 'source'); log(`[wpt] cache root: ${cacheRoot}`); - if (await isCompletedSource(sourceRoot, lock)) { + if (await isCompletedRevision(revisionRoot, lock)) { log(`[wpt] using cached WPT ${lock.revision}`); return sourceRoot; } - await fs.mkdir(revisionRoot, {recursive: true}); + const existingRevision = await fs.stat(revisionRoot).catch(() => null); + if (existingRevision) { + if (await isCompletedRevision(revisionRoot, lock)) { + log(`[wpt] another process installed WPT ${lock.revision}; reusing it`); + return sourceRoot; + } + throw new Error( + `Invalid WPT cache entry at ${revisionRoot}. Remove it and retry.`, + ); + } + + await fs.mkdir(cacheRoot, {recursive: true}); const nonce = `${process.pid}-${randomBytes(8).toString('hex')}`; - const archivePath = path.join(revisionRoot, `.archive-${nonce}.tar.gz`); - const temporarySource = path.join(revisionRoot, `.source-${nonce}`); + const archivePath = path.join( + cacheRoot, + `.archive-${lock.revision}-${nonce}.tar.gz`, + ); + const temporaryRevision = path.join( + cacheRoot, + `.revision-${lock.revision}-${nonce}`, + ); + const temporarySource = path.join(temporaryRevision, 'source'); try { log(`[wpt] downloading ${lock.archiveUrl}`); @@ -54,7 +72,7 @@ export async function prepareWpt({ assertChecksum(actualChecksum, lock.sha256); await validateArchive(archivePath, lock.revision); - await fs.mkdir(temporarySource); + await fs.mkdir(temporarySource, {recursive: true}); await extract({ cwd: temporarySource, file: archivePath, @@ -63,35 +81,35 @@ export async function prepareWpt({ }); await verifySentinels(temporarySource); await fs.writeFile( - path.join(temporarySource, completionMarker), + path.join(temporaryRevision, completionMarker), `${JSON.stringify({revision: lock.revision, sha256: lock.sha256}, null, 2)}\n`, {flag: 'wx'}, ); - await publishPreparedSource(temporarySource, sourceRoot, lock, log); + await publishPreparedRevision(temporaryRevision, revisionRoot, lock, log); return sourceRoot; } finally { await Promise.allSettled([ fs.rm(archivePath, {force: true}), - fs.rm(temporarySource, {force: true, recursive: true}), + fs.rm(temporaryRevision, {force: true, recursive: true}), ]); } } -export async function publishPreparedSource( - temporarySource, - sourceRoot, +export async function publishPreparedRevision( + temporaryRevision, + revisionRoot, lock, log = console.log, ) { try { - await fs.rename(temporarySource, sourceRoot); + await fs.rename(temporaryRevision, revisionRoot); log(`[wpt] installed WPT ${lock.revision}`); } catch (error) { if (!isDestinationExistsError(error)) throw error; - if (!(await isCompletedSource(sourceRoot, lock))) { + if (!(await isCompletedRevision(revisionRoot, lock))) { throw new Error( - `Another process created an invalid WPT cache entry at ${sourceRoot}. Remove it and retry.`, + `Another process created an invalid WPT cache entry at ${revisionRoot}. Remove it and retry.`, {cause: error}, ); } @@ -122,46 +140,6 @@ export function assertChecksum(actual, expected) { } } -export function validateArchiveEntry(entry, revision) { - const expectedRoot = `wpt-${revision}`; - const entryPath = validateArchivePath( - entry.path, - expectedRoot, - 'archive entry', - ); - const allowedTypes = new Set([ - 'File', - 'OldFile', - 'Directory', - 'SymbolicLink', - 'ExtendedHeader', - 'GlobalExtendedHeader', - ]); - - if (!allowedTypes.has(entry.type)) { - throw new Error( - `Unsafe WPT archive entry type ${JSON.stringify(entry.type)} for ${JSON.stringify(entry.path)}.`, - ); - } - - if (entry.type === 'SymbolicLink') { - const linkPath = entry.linkpath; - if ( - !linkPath || - path.posix.isAbsolute(linkPath) || - linkPath.includes('\\') - ) { - throw new Error( - `Unsafe WPT archive symlink target for ${JSON.stringify(entry.path)}.`, - ); - } - const target = path.posix.normalize( - path.posix.join(path.posix.dirname(entryPath), linkPath), - ); - validateArchivePath(target, expectedRoot, 'archive symlink target'); - } -} - async function readLock(lockPath) { const lock = JSON.parse(await fs.readFile(lockPath, 'utf8')); for (const key of ['repository', 'revision', 'archiveUrl', 'sha256']) { @@ -226,6 +204,46 @@ async function validateArchive(archivePath, revision) { if (validationError) throw validationError; } +export function validateArchiveEntry(entry, revision) { + const expectedRoot = `wpt-${revision}`; + const entryPath = validateArchivePath( + entry.path, + expectedRoot, + 'archive entry', + ); + const allowedTypes = new Set([ + 'File', + 'OldFile', + 'Directory', + 'SymbolicLink', + 'ExtendedHeader', + 'GlobalExtendedHeader', + ]); + + if (!allowedTypes.has(entry.type)) { + throw new Error( + `Unsafe WPT archive entry type ${JSON.stringify(entry.type)} for ${JSON.stringify(entry.path)}.`, + ); + } + + if (entry.type === 'SymbolicLink') { + const linkPath = entry.linkpath; + if ( + !linkPath || + path.posix.isAbsolute(linkPath) || + linkPath.includes('\\') + ) { + throw new Error( + `Unsafe WPT archive symlink target for ${JSON.stringify(entry.path)}.`, + ); + } + const target = path.posix.normalize( + path.posix.join(path.posix.dirname(entryPath), linkPath), + ); + validateArchivePath(target, expectedRoot, 'archive symlink target'); + } +} + function validateArchivePath(value, expectedRoot, label) { if (!value || value.includes('\\') || path.posix.isAbsolute(value)) { throw new Error(`Unsafe WPT ${label}: ${JSON.stringify(value)}.`); @@ -256,11 +274,11 @@ async function verifySentinels(sourceRoot) { } } -async function isCompletedSource(sourceRoot, lock) { +async function isCompletedRevision(revisionRoot, lock) { try { - await verifySentinels(sourceRoot); + await verifySentinels(path.join(revisionRoot, 'source')); const marker = JSON.parse( - await fs.readFile(path.join(sourceRoot, completionMarker), 'utf8'), + await fs.readFile(path.join(revisionRoot, completionMarker), 'utf8'), ); return marker.revision === lock.revision && marker.sha256 === lock.sha256; } catch { diff --git a/packages/wpt-runner/scripts/prepare-wpt.test.mjs b/packages/wpt-runner/scripts/prepare-wpt.test.mjs index 5cc53bf8..37fdddf8 100644 --- a/packages/wpt-runner/scripts/prepare-wpt.test.mjs +++ b/packages/wpt-runner/scripts/prepare-wpt.test.mjs @@ -5,7 +5,7 @@ import {afterEach, describe, expect, it} from 'vitest'; import { assertChecksum, prepareWpt, - publishPreparedSource, + publishPreparedRevision, resolveCacheRoot, validateArchiveEntry, } from './prepare-wpt.mjs'; @@ -54,24 +54,30 @@ describe('WPT preparation', () => { expect(logs.join('\n')).toContain('WPT root override'); }); - it('publishes one complete source when concurrent prepares race', async () => { + it('publishes one revision when concurrent prepares race and ignores source markers', async () => { const root = await fs.mkdtemp( path.join(os.tmpdir(), 'remote-dom-wpt-race-'), ); temporaryDirectories.push(root); const lock = {revision: 'a'.repeat(40), sha256: 'b'.repeat(64)}; - const source = path.join(root, 'source'); + const revisionRoot = path.join(root, lock.revision); const candidates = [ path.join(root, 'candidate-1'), path.join(root, 'candidate-2'), ]; for (const candidate of candidates) { - await fs.mkdir(path.join(candidate, 'resources'), {recursive: true}); + await fs.mkdir(path.join(candidate, 'source/resources'), { + recursive: true, + }); await fs.writeFile( - path.join(candidate, 'resources/testharness.js'), + path.join(candidate, 'source/resources/testharness.js'), '/* harness */', ); + await fs.writeFile( + path.join(candidate, 'source/.remote-dom-wpt-complete.json'), + JSON.stringify({revision: 'archive-provided'}), + ); await fs.writeFile( path.join(candidate, '.remote-dom-wpt-complete.json'), JSON.stringify(lock), @@ -80,12 +86,21 @@ describe('WPT preparation', () => { await Promise.all( candidates.map((candidate) => - publishPreparedSource(candidate, source, lock, () => {}), + publishPreparedRevision(candidate, revisionRoot, lock, () => {}), ), ); await expect( - fs.readFile(path.join(source, 'resources/testharness.js'), 'utf8'), + fs.readFile( + path.join(revisionRoot, 'source/resources/testharness.js'), + 'utf8', + ), ).resolves.toBe('/* harness */'); + await expect( + fs.readFile( + path.join(revisionRoot, '.remote-dom-wpt-complete.json'), + 'utf8', + ), + ).resolves.toContain(lock.revision); }); it('rejects checksum mismatches', () => { diff --git a/packages/wpt-runner/scripts/run-wpt.mjs b/packages/wpt-runner/scripts/run-wpt.mjs index e7358a7d..932d896a 100644 --- a/packages/wpt-runner/scripts/run-wpt.mjs +++ b/packages/wpt-runner/scripts/run-wpt.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import process from 'node:process'; import {fileURLToPath} from 'node:url'; +import {parseArgs} from 'node:util'; import {chromium} from '@playwright/test'; import {createServer} from 'vite'; import { @@ -92,47 +93,37 @@ try { } function parseArguments(arguments_) { - const parsed = { - enforceCapabilities: false, - headed: false, - help: false, - host: '127.0.0.1', - port: undefined, - strictPort: false, - testPaths: [], - timeoutMs: 30_000, - verbose: false, - }; - - for (let index = 0; index < arguments_.length; index += 1) { - const argument = arguments_[index]; - if (argument === '--') continue; - if (argument === '-h' || argument === '--help') parsed.help = true; - else if (argument === '--capabilities') parsed.enforceCapabilities = true; - else if (argument === '--headed') parsed.headed = true; - else if (argument === '--strict-port') parsed.strictPort = true; - else if (argument === '--verbose') parsed.verbose = true; - else if (argument === '--host') - parsed.host = requireValue(arguments_, ++index, argument); - else if (argument.startsWith('--host=')) - parsed.host = argument.slice('--host='.length); - else if (argument === '--port') - parsed.port = parsePort(requireValue(arguments_, ++index, argument)); - else if (argument.startsWith('--port=')) - parsed.port = parsePort(argument.slice('--port='.length)); - else if (argument === '--timeout') { - parsed.timeoutMs = parseDuration( - requireValue(arguments_, ++index, argument), - ); - } else if (argument.startsWith('--timeout=')) { - parsed.timeoutMs = parseDuration(argument.slice('--timeout='.length)); - } else if (argument.startsWith('-')) - throw new Error(`Unknown option: ${argument}`); - else parsed.testPaths.push(argument); - } + const normalizedArguments = + arguments_[0] === '--' ? arguments_.slice(1) : arguments_; + const {values, positionals} = parseArgs({ + args: normalizedArguments, + allowPositionals: true, + options: { + capabilities: {type: 'boolean'}, + headed: {type: 'boolean'}, + help: {type: 'boolean', short: 'h'}, + host: {type: 'string'}, + port: {type: 'string'}, + 'strict-port': {type: 'boolean'}, + timeout: {type: 'string'}, + verbose: {type: 'boolean'}, + }, + strict: true, + }); - if (parsed.testPaths.length === 0) parsed.enforceCapabilities = true; - return parsed; + return { + enforceCapabilities: + Boolean(values.capabilities) || positionals.length === 0, + headed: Boolean(values.headed), + help: Boolean(values.help), + host: values.host ?? '127.0.0.1', + port: values.port === undefined ? undefined : parsePort(values.port), + strictPort: Boolean(values['strict-port']), + testPaths: positionals, + timeoutMs: + values.timeout === undefined ? 30_000 : parseDuration(values.timeout), + verbose: Boolean(values.verbose), + }; } function selectPaths(options, groupedCapabilities) { @@ -265,12 +256,6 @@ function printRunError(run) { } } -function requireValue(arguments_, index, flag) { - const value = arguments_[index]; - if (!value) throw new Error(`${flag} requires a value.`); - return value; -} - function parsePort(value) { const port = Number(value); if (!Number.isInteger(port) || port < 0 || port > 65_535) { diff --git a/packages/wpt-runner/src/paths.ts b/packages/wpt-runner/src/paths.ts deleted file mode 100644 index 7123cb2d..00000000 --- a/packages/wpt-runner/src/paths.ts +++ /dev/null @@ -1,27 +0,0 @@ -import path from 'node:path'; - -export function resolveServedWptFile( - rawPath: string | null, - {fixtureRoot, wptRoot}: {fixtureRoot: string; wptRoot?: string}, -) { - if (!rawPath || rawPath.includes('\\') || path.posix.isAbsolute(rawPath)) { - return null; - } - const segments = rawPath.split('/'); - if (segments.some((segment) => segment === '..' || segment === '')) { - return null; - } - - if (rawPath.startsWith('__runner__/')) { - return containedPath(fixtureRoot, rawPath.slice('__runner__/'.length)); - } - return wptRoot ? containedPath(wptRoot, rawPath) : null; -} - -function containedPath(root: string, relativePath: string) { - const resolved = path.resolve(root, relativePath); - if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { - return null; - } - return resolved; -} diff --git a/packages/wpt-runner/vite.config.ts b/packages/wpt-runner/vite.config.ts index c077c99b..5c4cea78 100644 --- a/packages/wpt-runner/vite.config.ts +++ b/packages/wpt-runner/vite.config.ts @@ -3,7 +3,6 @@ import type {ServerResponse} from 'node:http'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {defineConfig, type Plugin} from 'vite'; -import {resolveServedWptFile} from './src/paths.ts'; const packageRoot = path.dirname(fileURLToPath(import.meta.url)); const fixtureRoot = path.join(packageRoot, 'fixtures'); @@ -11,6 +10,32 @@ const wptRoot = process.env.WPT_ROOT ? path.resolve(process.env.WPT_ROOT) : undefined; +export function resolveServedWptFile( + rawPath: string | null, + {fixtureRoot, wptRoot}: {fixtureRoot: string; wptRoot?: string}, +) { + if (!rawPath || rawPath.includes('\\') || path.posix.isAbsolute(rawPath)) { + return null; + } + const segments = rawPath.split('/'); + if (segments.some((segment) => segment === '..' || segment === '')) { + return null; + } + + if (rawPath.startsWith('__runner__/')) { + return containedPath(fixtureRoot, rawPath.slice('__runner__/'.length)); + } + return wptRoot ? containedPath(wptRoot, rawPath) : null; +} + +function containedPath(root: string, relativePath: string) { + const resolved = path.resolve(root, relativePath); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + return null; + } + return resolved; +} + function sendText(response: ServerResponse, status: number, text: string) { response.statusCode = status; response.setHeader('content-type', 'text/plain; charset=utf-8'); From cb18d7225312ffa450a9fc2ebab712a0b02cb728 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 14:19:07 -0700 Subject: [PATCH 03/14] Harden WPT runner execution --- packages/wpt-runner/package.json | 4 ++- packages/wpt-runner/scripts/run-wpt.mjs | 9 +++++- packages/wpt-runner/scripts/run-wpt.test.mjs | 34 ++++++++++++++++++++ packages/wpt-runner/src/main.ts | 3 +- pnpm-lock.yaml | 6 ++++ 5 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 packages/wpt-runner/scripts/run-wpt.test.mjs diff --git a/packages/wpt-runner/package.json b/packages/wpt-runner/package.json index 6b7727ad..ba233bd9 100644 --- a/packages/wpt-runner/package.json +++ b/packages/wpt-runner/package.json @@ -12,6 +12,8 @@ "@remote-dom/polyfill": "workspace:*" }, "devDependencies": { - "tar": "7.5.10" + "@playwright/test": "^1.49.0", + "tar": "7.5.10", + "vite": "^5.4.0" } } diff --git a/packages/wpt-runner/scripts/run-wpt.mjs b/packages/wpt-runner/scripts/run-wpt.mjs index 932d896a..1030b36c 100644 --- a/packages/wpt-runner/scripts/run-wpt.mjs +++ b/packages/wpt-runner/scripts/run-wpt.mjs @@ -267,7 +267,14 @@ function parsePort(value) { function parseDuration(value) { const match = /^(\d+)(ms|s)?$/.exec(value); if (!match) throw new Error(`Invalid --timeout value: ${value}`); - return Number(match[1]) * (match[2] === 's' ? 1000 : 1); + + const duration = Number(match[1]) * (match[2] === 's' ? 1000 : 1); + if (!Number.isSafeInteger(duration) || duration <= 0) { + throw new Error( + `Invalid --timeout value: ${value}. Expected a positive duration.`, + ); + } + return duration; } function indent(text, prefix) { diff --git a/packages/wpt-runner/scripts/run-wpt.test.mjs b/packages/wpt-runner/scripts/run-wpt.test.mjs new file mode 100644 index 00000000..562d249c --- /dev/null +++ b/packages/wpt-runner/scripts/run-wpt.test.mjs @@ -0,0 +1,34 @@ +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +const runnerPath = fileURLToPath(new URL('./run-wpt.mjs', import.meta.url)); + +describe('WPT runner arguments', () => { + it.each(['0', '0ms', '0s'])('rejects a zero timeout (%s)', (timeout) => { + const result = spawnSync( + process.execPath, + [runnerPath, '--timeout', timeout], + {encoding: 'utf8'}, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + `Invalid --timeout value: ${timeout}. Expected a positive duration.`, + ); + }); + + it('rejects a duration that cannot be represented safely', () => { + const timeout = `${Number.MAX_SAFE_INTEGER}s`; + const result = spawnSync( + process.execPath, + [runnerPath, '--timeout', timeout], + {encoding: 'utf8'}, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + `Invalid --timeout value: ${timeout}. Expected a positive duration.`, + ); + }); +}); diff --git a/packages/wpt-runner/src/main.ts b/packages/wpt-runner/src/main.ts index 251d4f6a..8be73782 100644 --- a/packages/wpt-runner/src/main.ts +++ b/packages/wpt-runner/src/main.ts @@ -145,8 +145,7 @@ function createRun(state: WptRunRecord['state'], path: string): WptRunRecord { function appendLog(message: string) { currentRun.logs.push(message); - elements.log.textContent += `${message}\n`; - sync(); + elements.log.append(`${message}\n`); } function sync() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83c9748c..737b9f91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,9 +229,15 @@ importers: specifier: workspace:* version: link:../polyfill devDependencies: + '@playwright/test': + specifier: ^1.49.0 + version: 1.49.0 tar: specifier: 7.5.10 version: 7.5.10 + vite: + specifier: ^5.4.0 + version: 5.4.2(@types/node@20.11.16)(lightningcss@1.22.1) packages: From e05c0d44d2e10dc3587f1fa868fe2a2ca083deda Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 14:51:46 -0700 Subject: [PATCH 04/14] === BEGIN === wpt-get-element-by-id From 50e70e3998fdfc721ca2a7b1527fdc7c11d7551a Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 20:42:55 -0700 Subject: [PATCH 05/14] Fix insertBefore middle-child links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserting before a child that already had a previous sibling updated the inserted and reference nodes, but left the previous sibling pointing at the reference node. Traversals following NEXT therefore skipped the inserted subtree even though childNodes contained it.\n\nThe Document-getElementById WPT case 'where insertion order and tree order don’t match' exposed this by inserting duplicate-ID elements between existing siblings and expecting lookup to follow tree order. --- packages/polyfill/source/ParentNode.ts | 6 +++-- .../polyfill/source/tests/parent-node.test.ts | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/polyfill/source/tests/parent-node.test.ts diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index b40d0c34..d96ceef6 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -121,9 +121,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/tests/parent-node.test.ts b/packages/polyfill/source/tests/parent-node.test.ts new file mode 100644 index 00000000..61b65d28 --- /dev/null +++ b/packages/polyfill/source/tests/parent-node.test.ts @@ -0,0 +1,27 @@ +import {Window} from '../index.ts'; + +import {beforeEach, describe, expect, it} from 'vitest'; + +beforeEach(() => { + const window = new Window(); + Window.setGlobalThis(window); +}); + +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); + parent.insertBefore(middle, last); + + 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); + }); +}); From 78ac2084db2202dbfa5f41d2a86dd37df2b508e7 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 20:44:40 -0700 Subject: [PATCH 06/14] Add getElementById support Implement the NonElementParentNode lookup on Document and DocumentFragment through a shared tree-order traversal. Reflect Element.id, match identifiers literally, preserve string coercion, and reject empty identifiers as required by the WPT coverage.\n\nClassify all Document-getElementById WPT cases, supporting the common mutation and tree-order paths while deferring cases that require adjacent HTML element, NamedNodeMap, or outerHTML support. --- .changeset/quick-ids-find.md | 5 + packages/polyfill/source/Document.ts | 10 +- packages/polyfill/source/DocumentFragment.ts | 5 + packages/polyfill/source/Element.ts | 8 ++ packages/polyfill/source/shared.ts | 23 ++++ .../polyfill/source/tests/document.test.ts | 122 ++++++++++++++++++ packages/wpt-runner/capabilities.tsv | 18 +++ 7 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 .changeset/quick-ids-find.md create mode 100644 packages/polyfill/source/tests/document.test.ts diff --git a/.changeset/quick-ids-find.md b/.changeset/quick-ids-find.md new file mode 100644 index 00000000..00a1c3b5 --- /dev/null +++ b/.changeset/quick-ids-find.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': minor +--- + +Add `getElementById()` to `Document` and `DocumentFragment`, with reflected `Element.id` properties. diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 688a8896..9ad46a06 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -17,7 +17,11 @@ 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, +} from './shared.ts'; import {HTMLBodyElement} from './HTMLBodyElement.ts'; import {HTMLHeadElement} from './HTMLHeadElement.ts'; import {HTMLHtmlElement} from './HTMLHtmlElement.ts'; @@ -70,6 +74,10 @@ export class Document extends ParentNode { return new Event(''); } + getElementById(elementId: string) { + return findElementById(this, elementId); + } + 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..1402616b 100644 --- a/packages/polyfill/source/Element.ts +++ b/packages/polyfill/source/Element.ts @@ -22,6 +22,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') ?? ''; } diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 587abe22..246d1795 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -86,6 +86,29 @@ export function cloneNode( } } +export function getElementById(within: ParentNode, elementId: string) { + const id = String(elementId); + if (id === '') return null; + + const child = within[CHILD]; + return child ? findElementById(child, id) : null; +} + +function findElementById(node: Node, id: string): Element | null { + if (isElementNode(node)) { + if (node.getAttribute('id') === id) return node; + + const child = node[CHILD]; + if (child) { + const found = findElementById(child, id); + if (found) return found; + } + } + + const next = node[NEXT]; + return next ? findElementById(next, id) : null; +} + 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/wpt-runner/capabilities.tsv b/packages/wpt-runner/capabilities.tsv index 3130fd0d..27141a94 100644 --- a/packages/wpt-runner/capabilities.tsv +++ b/packages/wpt-runner/capabilities.tsv @@ -1,2 +1,20 @@ 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 From 1e858f4655b94583f4af1e082515a4c244649a88 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 20:45:44 -0700 Subject: [PATCH 07/14] Add getElementsByTagName support Expose tag-name traversal on Document and Element, matching the DOM API hosts without adding the method to DocumentFragment. Share the traversal implementation while preserving case-insensitive HTML matching, case-sensitive non-HTML matching, and wildcard lookups. --- .changeset/tall-tags-search.md | 5 +++ packages/polyfill/source/Document.ts | 5 +++ packages/polyfill/source/Element.ts | 5 +++ packages/polyfill/source/shared.ts | 25 ++++++++++++ .../tests/get-elements-by-tag-name.test.ts | 40 +++++++++++++++++++ 5 files changed, 80 insertions(+) create mode 100644 .changeset/tall-tags-search.md create mode 100644 packages/polyfill/source/tests/get-elements-by-tag-name.test.ts diff --git a/.changeset/tall-tags-search.md b/.changeset/tall-tags-search.md new file mode 100644 index 00000000..f7db8429 --- /dev/null +++ b/.changeset/tall-tags-search.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': minor +--- + +Add `getElementsByTagName()` to `Document` and `Element`. diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 9ad46a06..625cde69 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -21,6 +21,7 @@ import { isParentNode, cloneNode, getElementById as findElementById, + getElementsByTagName as findElementsByTagName, } from './shared.ts'; import {HTMLBodyElement} from './HTMLBodyElement.ts'; import {HTMLHeadElement} from './HTMLHeadElement.ts'; @@ -78,6 +79,10 @@ export class Document extends ParentNode { 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/Element.ts b/packages/polyfill/source/Element.ts index 1402616b..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[]; @@ -55,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/shared.ts b/packages/polyfill/source/shared.ts index 246d1795..1bbf79e1 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -3,6 +3,7 @@ import { OWNER_DOCUMENT, ATTRIBUTES, NodeType, + NamespaceURI, CHILD, NEXT, } from './constants.ts'; @@ -14,6 +15,7 @@ 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'; export function isCharacterData(node: Node): node is CharacterData { return DATA in node; @@ -109,6 +111,29 @@ function findElementById(node: Node, id: string): Element | null { return next ? findElementById(next, id) : null; } +export function getElementsByTagName( + within: ParentNode, + qualifiedName: string, +) { + const name = String(qualifiedName); + const htmlName = name.toLowerCase(); + const matches = new NodeList(); + + for (const node of descendants(within)) { + if ( + isElementNode(node) && + (name === '*' || + (node.namespaceURI === NamespaceURI.XHTML + ? node.localName.toLowerCase() === htmlName + : node.localName === name)) + ) { + matches.push(node); + } + } + + return matches; +} + export function descendants(node: Node) { const nodes: Node[] = []; const walk = (node: Node) => { 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..704d191b --- /dev/null +++ b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts @@ -0,0 +1,40 @@ +import {NamespaceURI} from '../constants.ts'; +import {Window} from '../index.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); + }); +}); From e105efeb437dbc38b0616b4eed59d022ca586df0 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 21:33:43 -0700 Subject: [PATCH 08/14] Return appended children from appendChild Match the DOM mutation contract by returning the child passed to appendChild. Besides enabling normal chaining, the canonical Element.getElementsByTagName WPT uses this return value while constructing its fixture. --- .changeset/bright-children-return.md | 5 +++++ packages/polyfill/source/ParentNode.ts | 3 ++- packages/polyfill/source/tests/parent-node.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/bright-children-return.md diff --git a/.changeset/bright-children-return.md b/.changeset/bright-children-return.md new file mode 100644 index 00000000..622baf49 --- /dev/null +++ b/.changeset/bright-children-return.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Return the appended child from `appendChild()`. diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index d96ceef6..1702a0bd 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -18,8 +18,9 @@ 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) { diff --git a/packages/polyfill/source/tests/parent-node.test.ts b/packages/polyfill/source/tests/parent-node.test.ts index 61b65d28..2681ea8b 100644 --- a/packages/polyfill/source/tests/parent-node.test.ts +++ b/packages/polyfill/source/tests/parent-node.test.ts @@ -7,6 +7,15 @@ beforeEach(() => { 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'); From d1a82f734a7715c67d52abed5157dde750a0074b Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 15 Jul 2026 21:34:59 -0700 Subject: [PATCH 09/14] Classify getElementsByTagName WPT coverage Run the canonical Document and Element getElementsByTagName suites. Record seven supported Document cases and eight supported Element cases, while deferring HTMLCollection semantics, HTML qualified-name edge cases, and the wildcard test's adjacent Node constant dependency.\n\nExpand the release note to describe the implemented lookup behavior and its intentionally deferred conformance surface. --- .changeset/tall-tags-search.md | 2 ++ packages/wpt-runner/capabilities.tsv | 37 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/.changeset/tall-tags-search.md b/.changeset/tall-tags-search.md index f7db8429..6282a737 100644 --- a/.changeset/tall-tags-search.md +++ b/.changeset/tall-tags-search.md @@ -3,3 +3,5 @@ --- Add `getElementsByTagName()` to `Document` and `Element`. + +The lookup supports common HTML, non-HTML, and wildcard descendant searches. Canonical WPT coverage records the supported matching behavior while deferring live `HTMLCollection` semantics and qualified-name edge cases. diff --git a/packages/wpt-runner/capabilities.tsv b/packages/wpt-runner/capabilities.tsv index 27141a94..992dce48 100644 --- a/packages/wpt-runner/capabilities.tsv +++ b/packages/wpt-runner/capabilities.tsv @@ -18,3 +18,40 @@ dom/nodes/Document-getElementById.html deferred remove id attribute via outerHTM 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. From e27d7e6c158c3e60c45fcd824f2a2610ce7e7621 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Thu, 16 Jul 2026 00:56:25 -0700 Subject: [PATCH 10/14] Delegate getElementById and getElementsByTagName to selector engine querySelector and querySelectorAll now accept a pre-parsed Matcher[] in addition to string selectors. MatcherType, Combinator, Matcher, and Part are exported from selectors.ts. getElementById and getElementsByTagName delegate to the selector engine instead of maintaining independent tree-walk implementations, eliminating duplicate traversal code and fixing CSS-escaping issues for ids with special characters. HTML tag-name matching in the selector engine is now case-insensitive per the CSS spec. --- .changeset/query-selector-object-arg.md | 5 + packages/polyfill/source/selectors.ts | 34 ++++-- packages/polyfill/source/shared.ts | 38 ++----- .../tests/get-elements-by-tag-name.test.ts | 11 ++ .../polyfill/source/tests/selectors.test.ts | 103 +++++++++++++++++- 5 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 .changeset/query-selector-object-arg.md diff --git a/.changeset/query-selector-object-arg.md b/.changeset/query-selector-object-arg.md new file mode 100644 index 00000000..011ce02e --- /dev/null +++ b/.changeset/query-selector-object-arg.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Refactored `getElementById` and `getElementsByTagName` to delegate to the shared selector engine (`querySelector`/`querySelectorAll`) instead of maintaining independent tree-walk implementations. The selector functions now accept a pre-parsed `Matcher[]` in addition to string selectors, and `MatcherType`, `Combinator`, `Matcher`, and `Part` are now exported from `selectors.ts`. This eliminates duplicate traversal code, avoids CSS-escaping issues for ids containing special characters, and fixes case-insensitive HTML tag-name matching in the selector engine. diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index b00ac065..5f8046d7 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,12 +23,12 @@ const enum MatcherType { Function, } -interface Part { +export interface Part { combinator: Combinator; matchers: Matcher[]; } -interface Matcher { +export interface Matcher { type: MatcherType; name: string; value?: string; @@ -36,8 +36,14 @@ interface Matcher { const ELEMENT_SELECTOR_TEST = /[a-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 1bbf79e1..12abb36a 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -3,7 +3,6 @@ import { OWNER_DOCUMENT, ATTRIBUTES, NodeType, - NamespaceURI, CHILD, NEXT, } from './constants.ts'; @@ -16,6 +15,7 @@ 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; @@ -92,23 +92,7 @@ export function getElementById(within: ParentNode, elementId: string) { const id = String(elementId); if (id === '') return null; - const child = within[CHILD]; - return child ? findElementById(child, id) : null; -} - -function findElementById(node: Node, id: string): Element | null { - if (isElementNode(node)) { - if (node.getAttribute('id') === id) return node; - - const child = node[CHILD]; - if (child) { - const found = findElementById(child, id); - if (found) return found; - } - } - - const next = node[NEXT]; - return next ? findElementById(next, id) : null; + return querySelector(within, [{type: MatcherType.Id, name: id}]); } export function getElementsByTagName( @@ -116,21 +100,13 @@ export function getElementsByTagName( qualifiedName: string, ) { const name = String(qualifiedName); - const htmlName = name.toLowerCase(); - const matches = new NodeList(); - for (const node of descendants(within)) { - if ( - isElementNode(node) && - (name === '*' || - (node.namespaceURI === NamespaceURI.XHTML - ? node.localName.toLowerCase() === htmlName - : node.localName === name)) - ) { - matches.push(node); - } - } + const results = querySelectorAll(within, [ + {type: name === '*' ? MatcherType.Unknown : MatcherType.Element, name}, + ]); + const matches = new NodeList(); + matches.push(...results); return matches; } 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 index 704d191b..a0516911 100644 --- a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts +++ b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts @@ -1,5 +1,6 @@ import {NamespaceURI} from '../constants.ts'; import {Window} from '../index.ts'; +import {NodeList} from '../NodeList.ts'; import {beforeEach, describe, expect, it} from 'vitest'; @@ -37,4 +38,14 @@ describe('getElementsByTagName', () => { 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)).toBeUndefined(); + }); }); diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts index 067b551d..7b4331d0 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'; @@ -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

+ + 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); + }); + }); }); From 7f5edb5e21993fa2319c22f30262c321f4657d73 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Thu, 16 Jul 2026 00:56:37 -0700 Subject: [PATCH 11/14] Combine changesets into single minor entry for element query methods --- .changeset/bright-children-return.md | 5 ----- .changeset/element-query-methods.md | 7 +++++++ .changeset/query-selector-object-arg.md | 5 ----- .changeset/quick-ids-find.md | 5 ----- .changeset/tall-tags-search.md | 7 ------- 5 files changed, 7 insertions(+), 22 deletions(-) delete mode 100644 .changeset/bright-children-return.md create mode 100644 .changeset/element-query-methods.md delete mode 100644 .changeset/query-selector-object-arg.md delete mode 100644 .changeset/quick-ids-find.md delete mode 100644 .changeset/tall-tags-search.md diff --git a/.changeset/bright-children-return.md b/.changeset/bright-children-return.md deleted file mode 100644 index 622baf49..00000000 --- a/.changeset/bright-children-return.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@remote-dom/polyfill': patch ---- - -Return the appended child from `appendChild()`. diff --git a/.changeset/element-query-methods.md b/.changeset/element-query-methods.md new file mode 100644 index 00000000..0edd35d7 --- /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. Fixed `appendChild()` to return the appended child, matching the DOM specification. 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/.changeset/query-selector-object-arg.md b/.changeset/query-selector-object-arg.md deleted file mode 100644 index 011ce02e..00000000 --- a/.changeset/query-selector-object-arg.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@remote-dom/polyfill': patch ---- - -Refactored `getElementById` and `getElementsByTagName` to delegate to the shared selector engine (`querySelector`/`querySelectorAll`) instead of maintaining independent tree-walk implementations. The selector functions now accept a pre-parsed `Matcher[]` in addition to string selectors, and `MatcherType`, `Combinator`, `Matcher`, and `Part` are now exported from `selectors.ts`. This eliminates duplicate traversal code, avoids CSS-escaping issues for ids containing special characters, and fixes case-insensitive HTML tag-name matching in the selector engine. diff --git a/.changeset/quick-ids-find.md b/.changeset/quick-ids-find.md deleted file mode 100644 index 00a1c3b5..00000000 --- a/.changeset/quick-ids-find.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@remote-dom/polyfill': minor ---- - -Add `getElementById()` to `Document` and `DocumentFragment`, with reflected `Element.id` properties. diff --git a/.changeset/tall-tags-search.md b/.changeset/tall-tags-search.md deleted file mode 100644 index 6282a737..00000000 --- a/.changeset/tall-tags-search.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@remote-dom/polyfill': minor ---- - -Add `getElementsByTagName()` to `Document` and `Element`. - -The lookup supports common HTML, non-HTML, and wildcard descendant searches. Canonical WPT coverage records the supported matching behavior while deferring live `HTMLCollection` semantics and qualified-name edge cases. From 2d40e2e9d354f0265aaecb9cd8a4c56e311ae129 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Fri, 17 Jul 2026 12:38:59 -0700 Subject: [PATCH 12/14] Return inserted child from insertBefore --- .changeset/element-query-methods.md | 2 +- packages/polyfill/source/ParentNode.ts | 3 ++- packages/polyfill/source/tests/parent-node.test.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.changeset/element-query-methods.md b/.changeset/element-query-methods.md index 0edd35d7..19f26f16 100644 --- a/.changeset/element-query-methods.md +++ b/.changeset/element-query-methods.md @@ -4,4 +4,4 @@ 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. Fixed `appendChild()` to return the appended child, matching the DOM specification. 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. +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, matching the DOM specification. 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/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index 1702a0bd..7ed9bd81 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -23,8 +23,9 @@ export class ParentNode extends ChildNode { 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)[]) { diff --git a/packages/polyfill/source/tests/parent-node.test.ts b/packages/polyfill/source/tests/parent-node.test.ts index 2681ea8b..d4149948 100644 --- a/packages/polyfill/source/tests/parent-node.test.ts +++ b/packages/polyfill/source/tests/parent-node.test.ts @@ -25,8 +25,8 @@ describe('ParentNode.insertBefore', () => { parent.appendChild(first); parent.appendChild(last); - parent.insertBefore(middle, 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); From 842a379e407a57adcc14214c943c0836365725d0 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 22 Jul 2026 12:00:27 -0400 Subject: [PATCH 13/14] Return null for missing NodeList items --- .changeset/element-query-methods.md | 2 +- packages/polyfill/source/NodeList.ts | 2 +- packages/polyfill/source/tests/get-elements-by-tag-name.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/element-query-methods.md b/.changeset/element-query-methods.md index 19f26f16..8826c5da 100644 --- a/.changeset/element-query-methods.md +++ b/.changeset/element-query-methods.md @@ -4,4 +4,4 @@ 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, matching the DOM specification. 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. +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/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/tests/get-elements-by-tag-name.test.ts b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts index a0516911..47824f06 100644 --- a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts +++ b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts @@ -46,6 +46,6 @@ describe('getElementsByTagName', () => { expect(results.item).toBeTypeOf('function'); expect(results.item(0)?.localName).toBe('div'); expect(results.item(1)?.localName).toBe('span'); - expect(results.item(3)).toBeUndefined(); + expect(results.item(3)).toBeNull(); }); }); From ea613c8b7dad8e3ea9d04b71d4f2c461534ec2c6 Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Wed, 22 Jul 2026 14:53:13 -0400 Subject: [PATCH 14/14] Support uppercase element selectors --- packages/polyfill/source/selectors.ts | 2 +- packages/polyfill/source/tests/selectors.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index 5f8046d7..c53d6220 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -34,7 +34,7 @@ export interface Matcher { value?: string; } -const ELEMENT_SELECTOR_TEST = /[a-z]/; +const ELEMENT_SELECTOR_TEST = /[a-zA-Z]/; export function querySelector( within: ParentNode, diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts index 7b4331d0..bd9e5ff7 100644 --- a/packages/polyfill/source/tests/selectors.test.ts +++ b/packages/polyfill/source/tests/selectors.test.ts @@ -183,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');