├── .gitignore
├── .gitmodules
├── .editorconfig
├── test
├── runner.js
└── tests
│ ├── testharness.html
│ └── testdriver.html
├── eslint.config.mjs
├── .github
└── workflows
│ └── build.yml
├── LICENSE.txt
├── lib
├── console-reporter.js
├── internal
│ ├── handlers.js
│ ├── sourcefile.js
│ └── serve.js
├── testdriver-dummy.js
└── wpt-runner.js
├── bin
└── wpt-runner.js
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /npm-debug.log
3 | /common/
4 | /testharness/
5 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "wpt"]
2 | path = wpt
3 | url = https://github.com/web-platform-tests/wpt.git
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | trim_trailing_whitespace = true
7 | charset = utf-8
8 | indent_style = space
9 | indent_size = 2
10 |
--------------------------------------------------------------------------------
/test/runner.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const path = require("path");
4 | const wptRunner = require("..");
5 |
6 | const testsDirPath = path.join(__dirname, "./tests/");
7 |
8 | wptRunner(testsDirPath)
9 | .then(failure => process.exit(failure))
10 | .catch(e => {
11 | console.error(e.stack);
12 | process.exit(1);
13 | });
14 |
--------------------------------------------------------------------------------
/test/tests/testharness.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import domenicConfig from "@domenic/eslint-config";
2 | import globals from "globals";
3 | export default [
4 | {
5 | ignores: [
6 | "common/**",
7 | "testharness/**",
8 | "wpt/**"
9 | ]
10 | },
11 | {
12 | files: ["**/*.js"],
13 | languageOptions: {
14 | sourceType: "commonjs",
15 | globals: globals.node
16 | }
17 | },
18 | ...domenicConfig,
19 | {
20 | rules: {
21 | "no-console": "off"
22 | }
23 | }
24 | ];
25 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | pull_request:
4 | branches: [main]
5 | push:
6 | branches: [main]
7 | jobs:
8 | build:
9 | name: Lint and tests
10 | runs-on: ubuntu-latest
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | node-version:
15 | - 22
16 | - latest
17 | steps:
18 | - uses: actions/checkout@v5
19 | with:
20 | submodules: recursive
21 | - uses: actions/setup-node@v5
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 | - run: npm ci
25 | - run: npm run lint
26 | - run: npm test
27 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright © Domenic Denicola
2 |
3 | This work is free. You can redistribute it and/or modify it under the
4 | terms of the Do What The Fuck You Want To Public License, Version 2,
5 | as published by Sam Hocevar. See below for more details.
6 |
7 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
8 | Version 2, December 2004
9 |
10 | Copyright (C) 2004 Sam Hocevar
11 |
12 | Everyone is permitted to copy and distribute verbatim or modified
13 | copies of this license document, and changing it is allowed as long
14 | as the name is changed.
15 |
16 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
17 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
18 |
19 | 0. You just DO WHAT THE FUCK YOU WANT TO.
20 |
--------------------------------------------------------------------------------
/test/tests/testdriver.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/lib/console-reporter.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const { styleText } = require("node:util");
3 |
4 | const INDENT_SIZE = 2;
5 |
6 | exports.startSuite = name => console.log(`\n ${styleText(["bold", "underline"], name)}\n`);
7 |
8 | exports.pass = message => console.log(
9 | indent(
10 | styleText("dim", styleText("green", "√ ") + message),
11 | INDENT_SIZE
12 | )
13 | );
14 |
15 | exports.fail = message => console.log(
16 | indent(
17 | styleText(["bold", "red"], `\u00D7 ${message}`),
18 | INDENT_SIZE
19 | )
20 | );
21 |
22 | exports.reportStack = stack => console.log(
23 | indent(
24 | styleText("dim", stack),
25 | INDENT_SIZE * 2
26 | )
27 | );
28 |
29 | function indent(string, times) {
30 | const prefix = " ".repeat(times);
31 | return string.split("\n").map(l => prefix + l).join("\n");
32 | }
33 |
--------------------------------------------------------------------------------
/lib/internal/handlers.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | // Adapted from wpt tools
4 | // https://github.com/web-platform-tests/wpt/blob/master/tools/wptserve/wptserve/handlers.py
5 |
6 | const path = require("path");
7 | const { URL } = require("url");
8 |
9 | function filesystemPath(basePath, request, urlBase = "/") {
10 | const { pathname } = new URL(request.url, `http://${request.headers.host}`);
11 | let p = decodeURIComponent(pathname);
12 |
13 | if (p.startsWith(urlBase)) {
14 | p = p.slice(urlBase.length);
15 | }
16 |
17 | if (p.includes("..")) {
18 | throw new Error("invalid path");
19 | }
20 |
21 | p = path.join(basePath, p);
22 |
23 | // Otherwise setting path to / allows access outside the root directory
24 | if (!p.startsWith(basePath)) {
25 | throw new Error("invalid path");
26 | }
27 |
28 | return p;
29 | }
30 |
31 | exports.filesystemPath = filesystemPath;
32 |
--------------------------------------------------------------------------------
/bin/wpt-runner.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | "use strict";
3 | const path = require("path");
4 | const wptRunner = require("..");
5 |
6 | const { argv } = require("yargs")
7 | .command("$0 ", "Runs the web platform tests at the given path, e.g. wpt/dom/nodes/")
8 | .option("root-url", {
9 | description: "The relative URL path for the tests at , e.g. dom/nodes/",
10 | alias: "u",
11 | type: "string",
12 | requiresArg: true
13 | })
14 | .option("setup", {
15 | description: "The filename of a setup function module",
16 | alias: "s",
17 | type: "string",
18 | requiresArg: true
19 | });
20 |
21 | const testsPath = argv.path;
22 | const rootURL = argv["root-url"];
23 | const setup = argv.setup ? require(path.resolve(argv.setup)) : () => {};
24 |
25 | wptRunner(testsPath, { rootURL, setup })
26 | .then(failures => process.exit(failures))
27 | .catch(e => {
28 | console.error(e.stack);
29 | process.exit(1);
30 | });
31 |
--------------------------------------------------------------------------------
/lib/testdriver-dummy.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | /* eslint-disable camelcase */
3 | "use strict";
4 |
5 | window.test_driver = {
6 | bless(intent, action) {
7 | return Promise.resolve().then(() => {
8 | if (typeof action === "function") {
9 | return action();
10 | }
11 | return undefined;
12 | });
13 | },
14 |
15 | click(element) {
16 | if (window.top !== window) {
17 | return Promise.reject(new Error("can only click in top-level window"));
18 | }
19 | if (!window.document.contains(element)) {
20 | return Promise.reject(new Error("element in different document or shadow tree"));
21 | }
22 | return Promise.resolve();
23 | },
24 |
25 | send_keys(element) {
26 | if (window.top !== window) {
27 | return Promise.reject(new Error("can only send keys in top-level window"));
28 | }
29 | if (!window.document.contains(element)) {
30 | return Promise.reject(new Error("element in different document or shadow tree"));
31 | }
32 | return Promise.resolve();
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wpt-runner",
3 | "description": "Runs web platform tests in Node.js using jsdom",
4 | "keywords": [
5 | "testing",
6 | "web platform tests",
7 | "wpt",
8 | "test runner",
9 | "jsdom"
10 | ],
11 | "version": "6.1.0",
12 | "author": "Domenic Denicola (https://domenic.me/)",
13 | "license": "WTFPL",
14 | "repository": "domenic/wpt-runner",
15 | "main": "lib/wpt-runner.js",
16 | "bin": {
17 | "wpt-runner": "bin/wpt-runner.js"
18 | },
19 | "files": [
20 | "lib/",
21 | "bin/",
22 | "common/",
23 | "testharness/"
24 | ],
25 | "scripts": {
26 | "test": "node test/runner.js",
27 | "lint": "eslint",
28 | "prepare": "npm run copy-testharness && npm run copy-common",
29 | "copy-testharness": "mkdir -p testharness/ && cp wpt/resources/testharness.js wpt/resources/idlharness.js wpt/resources/webidl2/lib/webidl2.js testharness/",
30 | "copy-common": "mkdir -p common/ && cp wpt/common/gc.js common/"
31 | },
32 | "engines": {
33 | "node": ">= 22"
34 | },
35 | "dependencies": {
36 | "jsdom": "^27.0.0",
37 | "st": "^3.0.3",
38 | "yargs": "^18.0.0"
39 | },
40 | "devDependencies": {
41 | "@domenic/eslint-config": "^4.0.1",
42 | "eslint": "^9.37.0",
43 | "globals": "^16.4.0"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Web Platform Test Runner for Node.js
2 |
3 | This package allows you to run tests written in the style of [web-platform-tests](https://github.com/w3c/web-platform-tests), but from within Node.js. It does this by running your tests inside a [jsdom](https://github.com/tmpvar/jsdom) instance. You can optionally run some setup code beforehand, for example to set up a polyfill that you want to test.
4 |
5 | This is useful to a fairly narrow class of consumer: those who both
6 |
7 | 1. want to write tests in web-platform-tests format; and,
8 | 2. want to develop and test in a Node.js environment instead of a true browser.
9 |
10 | So for example, it might be useful if you're developing a polyfill or reference implementation for a new browser feature, but want to do so in JavaScript, and get the fast no-recompile feedback loop of Node.js.
11 |
12 | ## Command-line Usage
13 |
14 | ```
15 | $ node bin/wpt-runner.js
16 | Runs web platform tests in Node.js using jsdom
17 |
18 | wpt-runner [--root-url=] [--setup=]
19 |
20 | Options:
21 | --root-url, -u the relative URL path for the tests, e.g. dom/nodes/ [string]
22 | --setup, -s the filename of a setup function module [string]
23 | --help Show help [boolean]
24 | --version Show version number [boolean]
25 | ```
26 |
27 | This will run all `.html` files found by recursively crawling the specified directory, optionally mounted to the specified root URL and using the specified setup function. The program's exit code will be the number of failing files encountered (`0` for success).
28 |
29 | ## Programmatic Usage
30 |
31 | The setup is fairly similar. Here is a usage example:
32 |
33 | ```js
34 | const wptRunner = require("wpt-runner");
35 |
36 | wptRunner(testsPath, { rootURL, setup, filter, reporter })
37 | .then(failures => process.exit(failures))
38 | .catch(e => {
39 | console.error(e.stack);
40 | process.exit(1);
41 | });
42 | ```
43 |
44 | The options are:
45 |
46 | - `rootURL`: the URL at which to mount the tests (so that they resolve any relative URLs correctly).
47 | - `setup`: a setup function to run in the jsdom environment before running the tests.
48 | - `filter`: a function that takes the arguments `testPath` and `testURL` and returns true or false (or a promise for one of those) to indicate whether the test should run. Defaults to no filtering
49 | - `reporter`: an object which can be used to customize the output reports, instead of the default of reporting to the console. (Check out `lib/console-reporter.js` for an example of the object structure.)
50 |
51 | The returned promise fulfills with the number of failing files encountered (`0` for success), or rejects if there was some I/O error retrieving the files.
52 |
--------------------------------------------------------------------------------
/lib/internal/sourcefile.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | // Adapted from wpt tools
4 | // https://github.com/web-platform-tests/wpt/blob/926d722bfc83f3135aab36fddc977de82ed7e63e/tools/manifest/sourcefile.py
5 |
6 | const assert = require("assert");
7 | const fs = require("fs");
8 | const path = require("path");
9 |
10 | /**
11 | * Given a string `s` that ends with `oldSuffix`, replace that occurrence of `oldSuffix`
12 | * with `newSuffix`.
13 | */
14 | function replaceEnd(s, oldSuffix, newSuffix) {
15 | assert.ok(s.endsWith(oldSuffix));
16 | return s.slice(0, s.length - oldSuffix.length) + newSuffix;
17 | }
18 |
19 | const jsMetaRegexp = /\/\/\s*META:\s*(\w*)=(.*)$/u;
20 |
21 | /**
22 | * Yields any metadata (pairs of strings) from the multi-line string `s`,
23 | * as specified according to a supplied regexp.
24 | *
25 | * @param s
26 | * @param regexp Regexp containing two groups containing the metadata name and value.
27 | * @returns {Iterable<[string, string]>}
28 | */
29 | function* readScriptMetadata(s, regexp) {
30 | for (const line of s.split("\n")) {
31 | const m = line.match(regexp);
32 | if (!m) {
33 | break;
34 | }
35 | yield [m[1], m[2]];
36 | }
37 | }
38 |
39 | const anyVariants = {
40 | // Worker tests are not supported yet, so we remove all worker variants
41 | default: {
42 | longhand: ["window"]
43 | },
44 | window: {
45 | suffix: ".any.html"
46 | },
47 | jsshell: {
48 | suffix: ".any.js"
49 | }
50 | };
51 |
52 | /**
53 | * Returns a set of variants (strings) defined by the given keyword.
54 | *
55 | * @returns {Set}
56 | */
57 | function getAnyVariants(item) {
58 | assert.equal(item.startsWith("!"), false);
59 |
60 | const variant = anyVariants[item];
61 | if (!variant) {
62 | return new Set([]);
63 | }
64 | return new Set(variant.longhand || [item]);
65 | }
66 |
67 | /**
68 | * Returns a set of variants (strings) that will be used by default.
69 | *
70 | * @returns {Set}
71 | */
72 | function getDefaultAnyVariants() {
73 | return new Set(anyVariants.default.longhand);
74 | }
75 |
76 | /**
77 | * Returns a set of variants (strings) defined by a comma-separated value.
78 | *
79 | * @returns {Set}
80 | */
81 | function parseVariants(value) {
82 | const globals = getDefaultAnyVariants();
83 | for (let item of value.split(",")) {
84 | item = item.trim();
85 | if (item.startsWith("!")) {
86 | for (const variant of getAnyVariants(item.slice(1))) {
87 | globals.delete(variant);
88 | }
89 | } else {
90 | for (const variant of getAnyVariants(item)) {
91 | globals.add(variant);
92 | }
93 | }
94 | }
95 | return globals;
96 | }
97 |
98 | /**
99 | * Yields tuples of the relevant filename suffix (a string) and whether the
100 | * variant is intended to run in a JS shell, for the variants defined by the
101 | * given comma-separated value.
102 | *
103 | * @param {string} value
104 | * @returns {Array<[string, boolean]>}
105 | */
106 | function globalSuffixes(value) {
107 | const rv = [];
108 |
109 | const globalTypes = parseVariants(value);
110 | for (const globalType of globalTypes) {
111 | const variant = anyVariants[globalType];
112 | const suffix = variant.suffix || `.any.${globalType}.html`;
113 | rv.push([suffix, globalType === "jsshell"]);
114 | }
115 |
116 | return rv;
117 | }
118 |
119 | /**
120 | * Returns a url created from the given url and suffix.
121 | *
122 | * @param {string} url
123 | * @param {string} suffix
124 | * @returns {string}
125 | */
126 | function globalVariantUrl(url, suffix) {
127 | url = url.replace(".any.", ".");
128 | // If the url must be loaded over https, ensure that it will have
129 | // the form .https.any.js
130 | if (url.includes(".https.") && suffix.startsWith(".https.")) {
131 | url = url.replace(".https.", ".");
132 | }
133 | return replaceEnd(url, ".js", suffix);
134 | }
135 |
136 | class SourceFile {
137 | constructor(testsRoot, relPath) {
138 | this.testsRoot = testsRoot;
139 | this.relPath = relPath.replaceAll("\\", "/");
140 | this.contents = undefined;
141 |
142 | this.filename = path.basename(this.relPath);
143 | this.ext = path.extname(this.filename);
144 | this.name = this.filename.slice(0, this.filename.length - this.ext.length);
145 |
146 | this.metaFlags = this.name.split(".").slice(1);
147 | }
148 |
149 | open() {
150 | if (this.contents === undefined) {
151 | this.contents = fs.readFileSync(this.path(), { encoding: "utf8" });
152 | }
153 | return this.contents;
154 | }
155 |
156 | path() {
157 | return path.join(this.testsRoot, this.relPath);
158 | }
159 |
160 | nameIsMultiGlobal() {
161 | return this.metaFlags.includes("any") && this.ext === ".js";
162 | }
163 |
164 | nameIsWorker() {
165 | return this.metaFlags.includes("worker") && this.ext === ".js";
166 | }
167 |
168 | nameIsWindow() {
169 | return this.metaFlags.includes("window") && this.ext === ".js";
170 | }
171 |
172 | contentIsTestharness() {
173 | // TODO Parse the HTML and look for `;
247 | }
248 | return "";
249 | }
250 | }
251 |
252 | class WindowHandler extends HtmlWrapperHandler {
253 | pathReplace() {
254 | return [[".window.html", ".window.js"]];
255 | }
256 |
257 | wrapper(meta, script, path) {
258 | return `
259 |
260 | ${meta}
261 |
262 |
263 | ${script}
264 |
265 |
266 | `;
267 | }
268 | }
269 |
270 | class AnyHtmlHandler extends HtmlWrapperHandler {
271 | pathReplace() {
272 | return [[".any.html", ".any.js"]];
273 | }
274 |
275 | wrapper(meta, script, path) {
276 | return `
277 |
278 | ${meta}
279 |
285 |
286 |
287 | ${script}
288 |
289 |
290 | `;
291 | }
292 | }
293 |
294 | exports.WindowHandler = WindowHandler;
295 | exports.AnyHtmlHandler = AnyHtmlHandler;
296 |
--------------------------------------------------------------------------------