├── .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 | --------------------------------------------------------------------------------