diff --git a/.changeset/element-query-methods.md b/.changeset/element-query-methods.md new file mode 100644 index 00000000..8826c5da --- /dev/null +++ b/.changeset/element-query-methods.md @@ -0,0 +1,7 @@ +--- +'@remote-dom/polyfill': minor +--- + +Add `getElementById()` to `Document` and `DocumentFragment`, with reflected `Element.id` properties. Add `getElementsByTagName()` to `Document` and `Element`, supporting HTML, non-HTML, and wildcard descendant searches. `querySelector` and `querySelectorAll` accept a pre-parsed `Matcher[]` in addition to string selectors, with `MatcherType`, `Combinator`, `Matcher`, and `Part` exported from `selectors.ts`; `getElementById` and `getElementsByTagName` delegate to this shared selector engine instead of independent tree-walk implementations. + +Fixed `insertBefore()` leaving the previous sibling pointing at the reference node when inserting before a middle child, causing `NEXT` traversals (including `getElementById`) to skip the inserted subtree even though `childNodes` contained it, and return the inserted child as required by the DOM specification. Fixed `appendChild()` to return the appended child and `NodeList.item()` to return `null` for out-of-range indexes. Fixed case-insensitive HTML tag-name matching in the selector engine so `querySelector('DIV')` now matches `
` per the CSS spec. Fixed CSS-escaping issues so `getElementById` matches ids containing special characters (`.`, `:`, `#`, etc.) literally instead of treating them as selector syntax. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1eb9a6f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +packages/wpt-runner/capabilities.tsv whitespace=-blank-at-eol diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index daa811b7..c2cbe5e8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -46,3 +46,19 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 + + wpt: + name: Web Platform Tests 🌐 + runs-on: ubuntu-22.04 + timeout-minutes: 15 + env: + WPT_CACHE_DIR: ${{ github.workspace }}/.cache/wpt + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ./.github/workflows/actions/prepare + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .cache/wpt + key: ${{ runner.os }}-wpt-${{ hashFiles('packages/wpt-runner/wpt.lock.json') }} + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm run test:wpt diff --git a/.gitignore b/.gitignore index 44f54502..af97f49a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/ node_modules/ +/.cache/wpt/ .DS_STORE packages/*/bin/ *.log diff --git a/.prettierignore b/.prettierignore index b0259c7d..93a396f1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ build/ node_modules/ +.cache/wpt/ pnpm-lock.yaml diff --git a/package.json b/package.json index 58a885d4..7d37eb07 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "format": "prettier --write --cache .", "lint": "prettier --check --cache .", "test": "vitest", + "test:wpt": "pnpm --filter @remote-dom/wpt-runner wpt", "type-check": "tsc --build --pretty", + "wpt:dev": "pnpm --filter @remote-dom/wpt-runner dev", + "wpt:format": "pnpm --filter @remote-dom/wpt-runner format:capabilities", + "wpt:prepare": "pnpm --filter @remote-dom/wpt-runner wpt:prepare", "version-bump": "changeset version && pnpm install --no-frozen-lockfile", "deploy": "pnpm publish -r", "example:getting-started": "pnpm run --filter example-getting-started start", diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 688a8896..625cde69 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -17,7 +17,12 @@ import {Text} from './Text.ts'; import {Comment} from './Comment.ts'; import {DocumentFragment} from './DocumentFragment.ts'; import {HTMLTemplateElement} from './HTMLTemplateElement.ts'; -import {isParentNode, cloneNode} from './shared.ts'; +import { + isParentNode, + cloneNode, + getElementById as findElementById, + getElementsByTagName as findElementsByTagName, +} from './shared.ts'; import {HTMLBodyElement} from './HTMLBodyElement.ts'; import {HTMLHeadElement} from './HTMLHeadElement.ts'; import {HTMLHtmlElement} from './HTMLHtmlElement.ts'; @@ -70,6 +75,14 @@ export class Document extends ParentNode { return new Event(''); } + getElementById(elementId: string) { + return findElementById(this, elementId); + } + + getElementsByTagName(qualifiedName: string) { + return findElementsByTagName(this, qualifiedName); + } + importNode(node: Node, deep?: boolean) { return cloneNode(node, deep, this); } diff --git a/packages/polyfill/source/DocumentFragment.ts b/packages/polyfill/source/DocumentFragment.ts index 77bc33c3..0853da10 100644 --- a/packages/polyfill/source/DocumentFragment.ts +++ b/packages/polyfill/source/DocumentFragment.ts @@ -1,5 +1,6 @@ import {NAME, OWNER_DOCUMENT, NodeType} from './constants.ts'; import {ParentNode} from './ParentNode.ts'; +import {getElementById as findElementById} from './shared.ts'; export class DocumentFragment extends ParentNode { nodeType = NodeType.DOCUMENT_FRAGMENT_NODE; @@ -7,4 +8,8 @@ export class DocumentFragment extends ParentNode { [OWNER_DOCUMENT] = (typeof window !== 'undefined' ? window.document : null) as any; + + getElementById(elementId: string) { + return findElementById(this, elementId); + } } diff --git a/packages/polyfill/source/Element.ts b/packages/polyfill/source/Element.ts index d649b66a..8f6e3837 100644 --- a/packages/polyfill/source/Element.ts +++ b/packages/polyfill/source/Element.ts @@ -3,6 +3,7 @@ import {ParentNode} from './ParentNode.ts'; import {NamedNodeMap} from './NamedNodeMap.ts'; import {Attr} from './Attr.ts'; import {serializeNode, serializeChildren, parseHtml} from './serialization.ts'; +import {getElementsByTagName as findElementsByTagName} from './shared.ts'; export class Element extends ParentNode { static readonly observedAttributes?: string[]; @@ -22,6 +23,14 @@ export class Element extends ParentNode { [anyProperty: string]: any; + get id() { + return this.getAttribute('id') ?? ''; + } + + set id(id: string) { + this.setAttribute('id', String(id)); + } + get slot() { return this.getAttribute('slot') ?? ''; } @@ -47,6 +56,10 @@ export class Element extends ParentNode { return [...this.attributes].map((attr) => attr.name); } + getElementsByTagName(qualifiedName: string) { + return findElementsByTagName(this, qualifiedName); + } + get firstElementChild() { return this.children[0] ?? null; } diff --git a/packages/polyfill/source/NodeList.ts b/packages/polyfill/source/NodeList.ts index 2f977f21..7fcbf0fc 100644 --- a/packages/polyfill/source/NodeList.ts +++ b/packages/polyfill/source/NodeList.ts @@ -1,5 +1,5 @@ export class NodeList extends Array { item(index: number) { - return this[index]; + return this[index] ?? null; } } diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index b40d0c34..7ed9bd81 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -18,12 +18,14 @@ export class ParentNode extends ChildNode { readonly childNodes = new NodeList(); readonly children = new NodeList(); - appendChild(child: Node) { + appendChild(child: T) { this.insertInto(child, null); + return child; } - insertBefore(child: Node, ref?: Node | null) { + insertBefore(child: T, ref?: Node | null) { this.insertInto(child, ref || null); + return child; } append(...nodes: (Node | string)[]) { @@ -121,9 +123,11 @@ export class ParentNode extends ChildNode { if (before.parentNode !== this) { throw Error('reference node is not a child of this parent'); } + const previous = before[PREV]; child[NEXT] = before; - child[PREV] = before[PREV]; - if (before[PREV] === null) this[CHILD] = child; + child[PREV] = previous; + if (previous) previous[NEXT] = child; + else this[CHILD] = child; before[PREV] = child; } else { child[NEXT] = null; diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index b00ac065..c53d6220 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -1,11 +1,11 @@ -import {CHILD, NEXT, PARENT, PREV} from './constants.ts'; +import {CHILD, NEXT, PARENT, PREV, NamespaceURI} from './constants.ts'; import {isElementNode} from './shared.ts'; import type {Node} from './Node.ts'; import type {Element} from './Element.ts'; import type {ParentNode} from './ParentNode.ts'; -const enum Combinator { +export const enum Combinator { Descendant, Child, Sibling, @@ -13,7 +13,7 @@ const enum Combinator { Inner, } -const enum MatcherType { +export const enum MatcherType { Unknown, Element, Id, @@ -23,21 +23,27 @@ const enum MatcherType { Function, } -interface Part { +export interface Part { combinator: Combinator; matchers: Matcher[]; } -interface Matcher { +export interface Matcher { type: MatcherType; name: string; value?: string; } -const ELEMENT_SELECTOR_TEST = /[a-z]/; +const ELEMENT_SELECTOR_TEST = /[a-zA-Z]/; -export function querySelector(within: ParentNode, selector: string) { - const parts = parseSelector(selector); +export function querySelector( + within: ParentNode, + selector: string | Matcher[], +): Element | null { + const parts = + typeof selector === 'string' + ? parseSelector(selector) + : [{combinator: Combinator.Inner, matchers: selector}]; let result: Element | null = null; const child = within[CHILD]; @@ -50,8 +56,14 @@ export function querySelector(within: ParentNode, selector: string) { return result; } -export function querySelectorAll(within: ParentNode, selector: string) { - const parts = parseSelector(selector); +export function querySelectorAll( + within: ParentNode, + selector: string | Matcher[], +): Element[] { + const parts = + typeof selector === 'string' + ? parseSelector(selector) + : [{combinator: Combinator.Inner, matchers: selector}]; const results: Element[] = []; const child = within[CHILD]; @@ -239,7 +251,9 @@ function matchesSelectorMatcher( case MatcherType.Unknown: return name === '*'; // Universal selector case MatcherType.Element: - return element.localName === name; + return element.namespaceURI === NamespaceURI.XHTML + ? element.localName.toLowerCase() === name.toLowerCase() + : element.localName === name; case MatcherType.Id: return element.getAttribute('id') === name; case MatcherType.Class: diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 587abe22..12abb36a 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -14,6 +14,8 @@ import type {ParentNode} from './ParentNode.ts'; import type {Element} from './Element.ts'; import type {CharacterData} from './CharacterData.ts'; import type {Text} from './Text.ts'; +import {NodeList} from './NodeList.ts'; +import {querySelector, querySelectorAll, MatcherType} from './selectors.ts'; export function isCharacterData(node: Node): node is CharacterData { return DATA in node; @@ -86,6 +88,28 @@ export function cloneNode( } } +export function getElementById(within: ParentNode, elementId: string) { + const id = String(elementId); + if (id === '') return null; + + return querySelector(within, [{type: MatcherType.Id, name: id}]); +} + +export function getElementsByTagName( + within: ParentNode, + qualifiedName: string, +) { + const name = String(qualifiedName); + + const results = querySelectorAll(within, [ + {type: name === '*' ? MatcherType.Unknown : MatcherType.Element, name}, + ]); + + const matches = new NodeList(); + matches.push(...results); + return matches; +} + export function descendants(node: Node) { const nodes: Node[] = []; const walk = (node: Node) => { diff --git a/packages/polyfill/source/tests/document.test.ts b/packages/polyfill/source/tests/document.test.ts new file mode 100644 index 00000000..6213af6e --- /dev/null +++ b/packages/polyfill/source/tests/document.test.ts @@ -0,0 +1,122 @@ +import {Window} from '../index.ts'; + +import {beforeEach, describe, expect, it} from 'vitest'; + +beforeEach(() => { + const window = new Window(); + Window.setGlobalThis(window); +}); + +describe('NonElementParentNode.getElementById', () => { + describe('Document', () => { + it('returns the first matching element in tree order after arbitrary insertions', () => { + const container = document.createElement('div'); + const createTarget = () => { + const element = document.createElement('span'); + element.id = 'target'; + return element; + }; + const first = createTarget(); + const second = createTarget(); + const third = createTarget(); + const fourth = createTarget(); + + container.appendChild(second); + container.appendChild(fourth); + container.insertBefore(third, fourth); + container.insertBefore(first, second); + document.body.appendChild(container); + + for (const element of [first, second, third, fourth]) { + expect(document.getElementById('target')).toBe(element); + element.remove(); + } + + expect(document.getElementById('target')).toBeNull(); + }); + + it('only returns elements connected to the document', () => { + const container = document.createElement('div'); + const target = document.createElement('span'); + target.id = 'target'; + container.appendChild(target); + + expect(document.getElementById('target')).toBeNull(); + + document.body.appendChild(container); + expect(document.getElementById('target')).toBe(target); + + container.remove(); + expect(document.getElementById('target')).toBeNull(); + }); + + it('reflects id property and attribute changes immediately', () => { + const target = document.createElement('div'); + document.body.appendChild(target); + + target.id = 'before'; + expect(target.getAttribute('id')).toBe('before'); + expect(document.getElementById('before')).toBe(target); + + target.setAttribute('id', 'after'); + expect(target.id).toBe('after'); + expect(document.getElementById('before')).toBeNull(); + expect(document.getElementById('after')).toBe(target); + + target.removeAttribute('id'); + expect(target.id).toBe(''); + expect(document.getElementById('after')).toBeNull(); + }); + + it('coerces identifiers to strings but never matches an empty id', () => { + document.body.innerHTML = ` +
+
+
+ `; + + expect(document.getElementById('')).toBeNull(); + expect(document.getElementById(null as any)?.id).toBe('null'); + expect(document.getElementById(undefined as any)?.id).toBe('undefined'); + }); + + it('matches identifiers literally instead of parsing them as selectors', () => { + const ids = ['a.b', 'a b', 'a[b]', 'a:b', 'a#b']; + + for (const id of ids) { + const element = document.createElement('div'); + element.id = id; + document.body.appendChild(element); + } + + for (const id of ids) { + expect(document.getElementById(id)?.id).toBe(id); + } + }); + }); + + describe('DocumentFragment', () => { + it('finds the first matching element within the fragment', () => { + const fragment = document.createDocumentFragment(); + const first = document.createElement('div'); + const second = document.createElement('div'); + first.id = 'target'; + second.id = 'target'; + fragment.append(first, second); + + expect(fragment.getElementById('target')).toBe(first); + expect(fragment.getElementById('missing')).toBeNull(); + }); + + it('does not find elements outside the fragment', () => { + const target = document.createElement('div'); + target.id = 'target'; + document.body.appendChild(target); + + const fragment = document.createDocumentFragment(); + + expect(fragment.getElementById('target')).toBeNull(); + expect((target as any).getElementById).toBeUndefined(); + }); + }); +}); diff --git a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts new file mode 100644 index 00000000..47824f06 --- /dev/null +++ b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts @@ -0,0 +1,51 @@ +import {NamespaceURI} from '../constants.ts'; +import {Window} from '../index.ts'; +import {NodeList} from '../NodeList.ts'; + +import {beforeEach, describe, expect, it} from 'vitest'; + +beforeEach(() => { + const window = new Window(); + Window.setGlobalThis(window); +}); + +describe('getElementsByTagName', () => { + it('is exposed on Document and Element but not DocumentFragment', () => { + expect(document.getElementsByTagName).toBeTypeOf('function'); + expect(document.body.getElementsByTagName).toBeTypeOf('function'); + expect( + (document.createDocumentFragment() as any).getElementsByTagName, + ).toBeUndefined(); + }); + + it('finds descendant HTML elements case-insensitively', () => { + document.body.innerHTML = '
'; + + expect(document.getElementsByTagName('body')[0]).toBe(document.body); + expect(document.body.getElementsByTagName('DIV')).toHaveLength(2); + expect(document.body.getElementsByTagName('*')).toHaveLength(3); + }); + + it('matches non-HTML tag names case-sensitively', () => { + const svg = document.createElementNS(NamespaceURI.SVG, 'svg'); + const gradient = document.createElementNS( + NamespaceURI.SVG, + 'linearGradient', + ); + svg.appendChild(gradient); + document.body.appendChild(svg); + + expect(document.getElementsByTagName('linearGradient')[0]).toBe(gradient); + expect(document.getElementsByTagName('lineargradient')).toHaveLength(0); + }); + + it('returns a NodeList with an item() method', () => { + document.body.innerHTML = '

'; + const results = document.body.getElementsByTagName('*'); + expect(results).toBeInstanceOf(NodeList); + expect(results.item).toBeTypeOf('function'); + expect(results.item(0)?.localName).toBe('div'); + expect(results.item(1)?.localName).toBe('span'); + expect(results.item(3)).toBeNull(); + }); +}); diff --git a/packages/polyfill/source/tests/parent-node.test.ts b/packages/polyfill/source/tests/parent-node.test.ts new file mode 100644 index 00000000..d4149948 --- /dev/null +++ b/packages/polyfill/source/tests/parent-node.test.ts @@ -0,0 +1,36 @@ +import {Window} from '../index.ts'; + +import {beforeEach, describe, expect, it} from 'vitest'; + +beforeEach(() => { + const window = new Window(); + Window.setGlobalThis(window); +}); + +describe('ParentNode.appendChild', () => { + it('returns the appended child', () => { + const parent = document.createElement('div'); + const child = document.createElement('span'); + + expect(parent.appendChild(child)).toBe(child); + }); +}); + +describe('ParentNode.insertBefore', () => { + it('links an element inserted before a middle child into tree order', () => { + const parent = document.createElement('div'); + const first = document.createElement('span'); + const middle = document.createElement('em'); + const last = document.createElement('strong'); + + parent.appendChild(first); + parent.appendChild(last); + + expect(parent.insertBefore(middle, last)).toBe(middle); + expect(Array.from(parent.childNodes)).toEqual([first, middle, last]); + expect(first.nextSibling).toBe(middle); + expect(middle.previousSibling).toBe(first); + expect(middle.nextSibling).toBe(last); + expect(last.previousSibling).toBe(middle); + }); +}); diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts index 067b551d..bd9e5ff7 100644 --- a/packages/polyfill/source/tests/selectors.test.ts +++ b/packages/polyfill/source/tests/selectors.test.ts @@ -1,5 +1,11 @@ import {Window} from '../index.ts'; -import {parseSelector} from '../selectors.ts'; +import { + parseSelector, + querySelector, + querySelectorAll, + MatcherType, +} from '../selectors.ts'; +import type {ParentNode} from '../ParentNode.ts'; import {describe, it, expect, beforeEach} from 'vitest'; @@ -177,8 +183,8 @@ describe('selector parsing and matching', () => { `; }); - it('selects by element name', () => { - const articles = container.querySelectorAll('article'); + it('selects HTML element names case-insensitively', () => { + const articles = container.querySelectorAll('ARTICLE'); expect(articles).toHaveLength(1); expect(articles[0]!.getAttribute('class')).toBe('post'); @@ -277,4 +283,99 @@ describe('selector parsing and matching', () => { expect(allElements.length).toBeGreaterThan(0); }); }); + + describe('querySelector and querySelectorAll with Matcher[] argument', () => { + let container: Element; + + beforeEach(() => { + container = document.createElement('div'); + container.innerHTML = ` +
+

Main Title

+
+

First paragraph

+ + Highlighted text +
+
+ `; + }); + + // The standalone querySelector/querySelectorAll functions expect the polyfill's + // ParentNode, but `container` is typed as the global Element (lib.dom.d.ts). + // At runtime, Window.setGlobalThis replaces globals with polyfill instances. + const asPolyfill = (node: Element) => node as unknown as ParentNode; + + it('selects by ID matcher without parsing', () => { + const main = querySelector(asPolyfill(container), [ + {type: MatcherType.Id, name: 'main-post'}, + ]); + expect(main?.tagName.toLowerCase()).toBe('article'); + }); + + it('selects by element matcher without parsing', () => { + const paragraphs = querySelectorAll(asPolyfill(container), [ + {type: MatcherType.Element, name: 'p'}, + ]); + expect(paragraphs).toHaveLength(2); + }); + + it('matches ids with special characters literally (no CSS escaping)', () => { + const element = document.createElement('div'); + element.id = 'a.b:c#d'; + container.appendChild(element); + + const result = querySelector(asPolyfill(container), [ + {type: MatcherType.Id, name: 'a.b:c#d'}, + ]); + expect(result).toBe(element); + }); + + it('matches HTML tag names case-insensitively via element matcher', () => { + const upper = querySelectorAll(asPolyfill(container), [ + {type: MatcherType.Element, name: 'ARTICLE'}, + ]); + expect(upper).toHaveLength(1); + + const mixed = querySelectorAll(asPolyfill(container), [ + {type: MatcherType.Element, name: 'SpAn'}, + ]); + expect(mixed).toHaveLength(1); + expect(mixed[0]!.getAttribute('class')).toBe('highlight'); + }); + + it('returns same results as string selectors for compound queries', () => { + const byString = container.querySelectorAll('p.text.hidden'); + const byObject = querySelectorAll(asPolyfill(container), [ + {type: MatcherType.Element, name: 'p'}, + {type: MatcherType.Class, name: 'text'}, + {type: MatcherType.Class, name: 'hidden'}, + ]); + + expect(byObject).toHaveLength(byString.length); + expect(byObject[0]?.textContent?.trim()).toBe('Hidden paragraph'); + }); + + it('wildcard matcher returns all elements', () => { + const all = querySelectorAll(asPolyfill(container), [ + {type: MatcherType.Unknown, name: '*'}, + ]); + expect(all.length).toBeGreaterThan(0); + // Should include article, h1, div, p, p, span + expect(all.length).toBe(6); + }); + + it('returns null/empty for non-matching matchers', () => { + expect( + querySelector(asPolyfill(container), [ + {type: MatcherType.Id, name: 'nope'}, + ]), + ).toBeNull(); + expect( + querySelectorAll(asPolyfill(container), [ + {type: MatcherType.Element, name: 'table'}, + ]), + ).toHaveLength(0); + }); + }); }); diff --git a/packages/wpt-runner/README.md b/packages/wpt-runner/README.md new file mode 100644 index 00000000..ead20642 --- /dev/null +++ b/packages/wpt-runner/README.md @@ -0,0 +1,80 @@ +# Remote DOM WPT runner + +Private test infrastructure for running selected upstream Web Platform Tests unchanged against the workspace copy of `@remote-dom/polyfill`. + +The runner downloads a pinned WPT archive, parses selected `testharness.js` HTML files in a browser control page, and executes their markup and scripts in a fresh module worker containing `new Window()` from the polyfill. It does not construct a Remote DOM host receiver; transport and host rendering are separate integration concerns. + +## Commands + +From the repository root: + +```bash +# Prepare or reuse the pinned WPT source. +pnpm wpt:prepare + +# Run every classified file with strict capability enforcement. +pnpm test:wpt + +# Explore an arbitrary test without claiming support. +pnpm test:wpt -- dom/nodes/Document-getElementById.html + +# Enforce the table for one classified file. +pnpm test:wpt -- --capabilities '__runner__/runner-smoke.html?runner=smoke' + +# Open the debug page and inspect original and generated source. +pnpm wpt:dev + +# Canonically sort capabilities.tsv after editing it. +pnpm wpt:format +``` + +Useful runner options include `--headed`, `--verbose`, `--timeout 60s`, `--port 5174`, and `--strict-port`. + +Set `WPT_ROOT=/path/to/wpt` to use an existing checkout. The runner verifies `resources/testharness.js` and skips all downloads. Set `WPT_CACHE_DIR` to override the download cache. + +## Pinned source and cache + +`wpt.lock.json` pins an immutable WPT revision and SHA-256 archive checksum. Pin updates must change and review both values. + +The cache root resolves in this order: + +1. `WPT_CACHE_DIR` +2. `/.cache/wpt` when `CI` is set +3. `${XDG_CACHE_HOME}/remote-dom/wpt` +4. `${HOME}/.cache/remote-dom/wpt` + +Each revision installs under `//source`, with the runner-owned completion marker beside `source` at the revision root. Download and extraction happen in a process-unique temporary revision directory, which is moved into place atomically so concurrent worktrees can safely race to populate the shared cache. Keeping the marker outside `source` leaves the extracted WPT checkout untouched. Old revisions are not deleted automatically. + +WPT files are pinned but remain untrusted test inputs. The preparation script validates archive paths and checksums before extraction, and the browser server rejects traversal. Do not execute downloaded repository scripts outside the isolated runner. + +## Capability inventory + +`capabilities.tsv` contains one physical row per WPT subtest with four columns: + +```text +pathstatuscasenote +``` + +- `path`: WPT path, including a query string when applicable +- `status`: exactly `supported` or `deferred` +- `case`: exact `testharness.js` subtest name +- `note`: required for deferred cases and normally empty for supported cases + +Use `\\t`, `\\n`, `\\r`, and `\\\\` for literal tab, newline, carriage return, and backslash characters. Other escapes, malformed rows, duplicate `(path, case)` pairs, and noncanonical ordering fail validation. + +The formatter sorts by path and case with locale-independent code-unit ordering and writes LF line endings with one final newline. The runner rejects an unformatted table and prints the formatter command. + +During an enforced run: + +- failed supported cases fail the command; +- failed deferred cases remain visible with their notes but do not fail it; +- passing deferred cases are printed as promotion candidates; +- missing or unlisted cases, harness errors, worker errors, and timeouts always fail. + +The same schema can later be split mechanically into `dom.tsv`, `html.tsv`, `svg.tsv`, and similar files if one table becomes unwieldy. + +## Initial limitations + +The first version supports `testharness.js` HTML files, classic top-level scripts, static HTML/SVG markup, and absolute or relative in-repository script resources. It skips `testharnessreport.js` and captures completion programmatically. + +It intentionally does not support `.window.js`, WebIDL preloading, modules, nested scripts or browsing contexts, reftests, crashtests, WPT server substitutions, navigation, or layout assertions. Add execution infrastructure only when a selected capability requires it; never patch a claimed DOM API in runner shims. diff --git a/packages/wpt-runner/capabilities.tsv b/packages/wpt-runner/capabilities.tsv new file mode 100644 index 00000000..992dce48 --- /dev/null +++ b/packages/wpt-runner/capabilities.tsv @@ -0,0 +1,57 @@ +path status case note +__runner__/runner-smoke.html?runner=smoke supported runner smoke +dom/nodes/Document-getElementById.html supported Calling document.getElementById with a null argument. +dom/nodes/Document-getElementById.html supported Calling document.getElementById with an empty string argument. +dom/nodes/Document-getElementById.html supported Calling document.getElementById with an undefined argument. +dom/nodes/Document-getElementById.html supported Document.getElementById must not return nodes not present in document +dom/nodes/Document-getElementById.html deferred Document.getElementById with a script-inserted element Requires concrete `HTMLDivElement` constructor support. +dom/nodes/Document-getElementById.html supported Ensure that the id attribute only affects elements present in a document +dom/nodes/Document-getElementById.html supported Inserting an id by inserting its parent node +dom/nodes/Document-getElementById.html supported Modern browsers optimize this method with using internal id cache. This test checks that their optimization should effect only append to `Document`, not append to `Node`. +dom/nodes/Document-getElementById.html deferred add id attribute via innerHTML Requires concrete `HTMLDivElement` constructor support. +dom/nodes/Document-getElementById.html deferred add id attribute via outerHTML Requires an `outerHTML` setter. +dom/nodes/Document-getElementById.html deferred changing attribute's value via `Attr` gotten from `Element.attribute`. Requires indexed `NamedNodeMap` access. +dom/nodes/Document-getElementById.html supported in tree order, within the context object's tree +dom/nodes/Document-getElementById.html deferred on static page Requires concrete `HTMLDivElement` constructor support. +dom/nodes/Document-getElementById.html supported remove id attribute via innerHTML +dom/nodes/Document-getElementById.html deferred remove id attribute via outerHTML Requires an `outerHTML` setter. +dom/nodes/Document-getElementById.html supported update `id` attribute via element.id +dom/nodes/Document-getElementById.html supported update `id` attribute via setAttribute/removeAttribute +dom/nodes/Document-getElementById.html supported where insertion order and tree order don't match +dom/nodes/Document-getElementsByTagName.html supported Caching is allowed +dom/nodes/Document-getElementsByTagName.html deferred Element in HTML namespace, no prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names. +dom/nodes/Document-getElementsByTagName.html deferred Element in HTML namespace, prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names. +dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, lowercase name +dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, uppercase name +dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, non-ascii characters in name +dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, prefix, lowercase name +dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, prefix, non-ascii characters in name +dom/nodes/Document-getElementsByTagName.html supported Element in non-HTML namespace, prefix, uppercase name +dom/nodes/Document-getElementsByTagName.html deferred HTML element with uppercase tagName never matches in HTML Documents Requires HTML-document qualified-name matching for uppercase-created XHTML elements. +dom/nodes/Document-getElementsByTagName.html deferred Interfaces Requires `HTMLCollection` and exposed collection constructors. +dom/nodes/Document-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (item) Requires `HTMLCollection.prototype.item`. +dom/nodes/Document-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (namedItem) Requires `HTMLCollection.prototype.namedItem`. +dom/nodes/Document-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (non-strict mode) Requires Web IDL indexed `HTMLCollection` properties. +dom/nodes/Document-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (strict mode) Requires Web IDL indexed `HTMLCollection` properties. +dom/nodes/Document-getElementsByTagName.html deferred getElementsByTagName('*') Requires `Node.ELEMENT_NODE` constant support. +dom/nodes/Document-getElementsByTagName.html deferred getElementsByTagName() should be a live collection Requires a live `HTMLCollection`. +dom/nodes/Document-getElementsByTagName.html deferred hasOwnProperty, getOwnPropertyDescriptor, getOwnPropertyNames Requires `HTMLCollection` named property semantics. +dom/nodes/Element-getElementsByTagName.html supported Caching is allowed +dom/nodes/Element-getElementsByTagName.html deferred Element in HTML namespace, no prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names. +dom/nodes/Element-getElementsByTagName.html deferred Element in HTML namespace, prefix, non-ascii characters in name Requires ASCII-only case folding for HTML qualified names. +dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, lowercase name +dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, no prefix, uppercase name +dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, non-ascii characters in name +dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, prefix, lowercase name +dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, prefix, non-ascii characters in name +dom/nodes/Element-getElementsByTagName.html supported Element in non-HTML namespace, prefix, uppercase name +dom/nodes/Element-getElementsByTagName.html deferred HTML element with uppercase tagName never matches in HTML Documents Requires HTML-document qualified-name matching for uppercase-created XHTML elements. +dom/nodes/Element-getElementsByTagName.html deferred Interfaces Requires `HTMLCollection` and exposed collection constructors. +dom/nodes/Element-getElementsByTagName.html supported Matching the context object +dom/nodes/Element-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (item) Requires `HTMLCollection.prototype.item`. +dom/nodes/Element-getElementsByTagName.html deferred Should be able to set expando shadowing a proto prop (namedItem) Requires `HTMLCollection.prototype.namedItem`. +dom/nodes/Element-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (non-strict mode) Requires Web IDL indexed `HTMLCollection` properties. +dom/nodes/Element-getElementsByTagName.html deferred Shouldn't be able to set unsigned properties on a HTMLCollection (strict mode) Requires Web IDL indexed `HTMLCollection` properties. +dom/nodes/Element-getElementsByTagName.html deferred getElementsByTagName('*') Requires `Node.ELEMENT_NODE` constant support. +dom/nodes/Element-getElementsByTagName.html deferred getElementsByTagName() should be a live collection Requires a live `HTMLCollection`. +dom/nodes/Element-getElementsByTagName.html deferred hasOwnProperty, getOwnPropertyDescriptor, getOwnPropertyNames Requires `HTMLCollection` named property semantics. diff --git a/packages/wpt-runner/fixtures/runner-smoke.html b/packages/wpt-runner/fixtures/runner-smoke.html new file mode 100644 index 00000000..9f6cf7be --- /dev/null +++ b/packages/wpt-runner/fixtures/runner-smoke.html @@ -0,0 +1,24 @@ + +Remote DOM WPT runner smoke test + + +
Static HTML
+ + + + diff --git a/packages/wpt-runner/index.html b/packages/wpt-runner/index.html new file mode 100644 index 00000000..90d63cc4 --- /dev/null +++ b/packages/wpt-runner/index.html @@ -0,0 +1,70 @@ + + + + + + + Remote DOM WPT runner + + +
+
+
+

Remote DOM WPT runner

+

+ Runs selected upstream testharness.js HTML files against + @remote-dom/polyfill. +

+
+
+ +
+ + +
+ +
+
+

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..ba233bd9 --- /dev/null +++ b/packages/wpt-runner/package.json @@ -0,0 +1,19 @@ +{ + "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": { + "@playwright/test": "^1.49.0", + "tar": "7.5.10", + "vite": "^5.4.0" + } +} 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..3c8814ae --- /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 '../vite.config.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..88760b8a --- /dev/null +++ b/packages/wpt-runner/scripts/prepare-wpt.mjs @@ -0,0 +1,303 @@ +#!/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 isCompletedRevision(revisionRoot, lock)) { + log(`[wpt] using cached WPT ${lock.revision}`); + return sourceRoot; + } + + 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( + 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}`); + const actualChecksum = await downloadArchive(lock.archiveUrl, archivePath); + assertChecksum(actualChecksum, lock.sha256); + await validateArchive(archivePath, lock.revision); + + await fs.mkdir(temporarySource, {recursive: true}); + await extract({ + cwd: temporarySource, + file: archivePath, + preservePaths: false, + strip: 1, + }); + await verifySentinels(temporarySource); + await fs.writeFile( + path.join(temporaryRevision, completionMarker), + `${JSON.stringify({revision: lock.revision, sha256: lock.sha256}, null, 2)}\n`, + {flag: 'wx'}, + ); + + await publishPreparedRevision(temporaryRevision, revisionRoot, lock, log); + return sourceRoot; + } finally { + await Promise.allSettled([ + fs.rm(archivePath, {force: true}), + fs.rm(temporaryRevision, {force: true, recursive: true}), + ]); + } +} + +export async function publishPreparedRevision( + temporaryRevision, + revisionRoot, + lock, + log = console.log, +) { + try { + await fs.rename(temporaryRevision, revisionRoot); + log(`[wpt] installed WPT ${lock.revision}`); + } catch (error) { + if (!isDestinationExistsError(error)) throw error; + if (!(await isCompletedRevision(revisionRoot, lock))) { + throw new Error( + `Another process created an invalid WPT cache entry at ${revisionRoot}. 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}.`, + ); + } +} + +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; +} + +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)}.`); + } + 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 isCompletedRevision(revisionRoot, lock) { + try { + await verifySentinels(path.join(revisionRoot, 'source')); + const marker = JSON.parse( + await fs.readFile(path.join(revisionRoot, 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..37fdddf8 --- /dev/null +++ b/packages/wpt-runner/scripts/prepare-wpt.test.mjs @@ -0,0 +1,139 @@ +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, + publishPreparedRevision, + 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 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 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, 'source/resources'), { + recursive: true, + }); + await fs.writeFile( + 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), + ); + } + + await Promise.all( + candidates.map((candidate) => + publishPreparedRevision(candidate, revisionRoot, lock, () => {}), + ), + ); + await expect( + 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', () => { + 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..1030b36c --- /dev/null +++ b/packages/wpt-runner/scripts/run-wpt.mjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node + +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 { + 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 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, + }); + + 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) { + 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 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}`); + + 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) { + 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/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/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..8be73782 --- /dev/null +++ b/packages/wpt-runner/src/main.ts @@ -0,0 +1,180 @@ +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.append(`${message}\n`); +} + +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/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..5c4cea78 --- /dev/null +++ b/packages/wpt-runner/vite.config.ts @@ -0,0 +1,85 @@ +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'; + +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; + +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'); + 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..737b9f91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,22 @@ importers: specifier: ^1.8.0 version: 1.8.0 + packages/wpt-runner: + dependencies: + '@remote-dom/polyfill': + 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: '@ampproject/remapping@2.2.1': @@ -1236,6 +1252,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 +1902,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 +2614,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 +3103,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 +3373,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 +4596,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 +5341,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + chownr@3.0.0: {} + ci-info@3.8.0: {} cliui@8.0.1: @@ -5995,6 +6041,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 +6509,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 +6745,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"} ] }