diff options
Diffstat (limited to 'test/wpt/runner')
-rw-r--r-- | test/wpt/runner/runner.mjs | 356 | ||||
-rw-r--r-- | test/wpt/runner/util.mjs | 172 | ||||
-rw-r--r-- | test/wpt/runner/worker.mjs | 164 |
3 files changed, 692 insertions, 0 deletions
diff --git a/test/wpt/runner/runner.mjs b/test/wpt/runner/runner.mjs new file mode 100644 index 0000000..5bec326 --- /dev/null +++ b/test/wpt/runner/runner.mjs @@ -0,0 +1,356 @@ +import { EventEmitter, once } from 'node:events' +import { isAbsolute, join, resolve } from 'node:path' +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import { colors, handlePipes, normalizeName, parseMeta, resolveStatusPath } from './util.mjs' + +const basePath = fileURLToPath(join(import.meta.url, '../..')) +const testPath = join(basePath, 'tests') +const statusPath = join(basePath, 'status') + +// https://github.com/web-platform-tests/wpt/blob/b24eedd/resources/testharness.js#L3705 +function sanitizeUnpairedSurrogates (str) { + return str.replace( + /([\ud800-\udbff]+)(?![\udc00-\udfff])|(^|[^\ud800-\udbff])([\udc00-\udfff]+)/g, + function (_, low, prefix, high) { + let output = prefix || '' // Prefix may be undefined + const string = low || high // Only one of these alternates can match + for (let i = 0; i < string.length; i++) { + output += codeUnitStr(string[i]) + } + return output + }) +} + +function codeUnitStr (char) { + return 'U+' + char.charCodeAt(0).toString(16) +} + +export class WPTRunner extends EventEmitter { + /** @type {string} */ + #folderName + + /** @type {string} */ + #folderPath + + /** @type {string[]} */ + #files = [] + + /** @type {string[]} */ + #initScripts = [] + + /** @type {string} */ + #url + + /** @type {import('../../status/fetch.status.json')} */ + #status + + /** Tests that have expectedly failed mapped by file name */ + #statusOutput = {} + + #uncaughtExceptions = [] + + /** @type {boolean} */ + #appendReport + + /** @type {string} */ + #reportPath + + #stats = { + completed: 0, + failed: 0, + success: 0, + expectedFailures: 0, + skipped: 0 + } + + constructor (folder, url, { appendReport = false, reportPath } = {}) { + super() + + this.#folderName = folder + this.#folderPath = join(testPath, folder) + this.#files.push( + ...WPTRunner.walk( + this.#folderPath, + (file) => file.endsWith('.any.js') + ) + ) + + if (appendReport) { + if (!reportPath) { + throw new TypeError('reportPath must be provided when appendReport is true') + } + if (!existsSync(reportPath)) { + throw new TypeError('reportPath is invalid') + } + } + + this.#appendReport = appendReport + this.#reportPath = reportPath + + this.#status = JSON.parse(readFileSync(join(statusPath, `${folder}.status.json`))) + this.#url = url + + if (this.#files.length === 0) { + queueMicrotask(() => { + this.emit('completion') + }) + } + + this.once('completion', () => { + for (const { error, test } of this.#uncaughtExceptions) { + console.log(colors(`Uncaught exception in "${test}":`, 'red')) + console.log(colors(`${error.stack}`, 'red')) + console.log('='.repeat(96)) + } + }) + } + + static walk (dir, fn) { + const ini = new Set(readdirSync(dir)) + const files = new Set() + + while (ini.size !== 0) { + for (const d of ini) { + const path = resolve(dir, d) + ini.delete(d) // remove from set + const stats = statSync(path) + + if (stats.isDirectory()) { + for (const f of readdirSync(path)) { + ini.add(resolve(path, f)) + } + } else if (stats.isFile() && fn(d)) { + files.add(path) + } + } + } + + return [...files].sort() + } + + async run () { + const workerPath = fileURLToPath(join(import.meta.url, '../worker.mjs')) + /** @type {Set<Worker>} */ + const activeWorkers = new Set() + let finishedFiles = 1 + let total = this.#files.length + + const files = this.#files.map((test) => { + const code = test.includes('.sub.') + ? handlePipes(readFileSync(test, 'utf-8'), this.#url) + : readFileSync(test, 'utf-8') + const meta = this.resolveMeta(code, test) + + if (meta.variant.length) { + total += meta.variant.length - 1 + } + + return [test, code, meta] + }) + + console.log('='.repeat(96)) + + for (const [test, code, meta] of files) { + console.log(`Started ${test}`) + + const status = resolveStatusPath(test, this.#status) + + if (status.file.skip || status.topLevel.skip) { + this.#stats.skipped += 1 + + console.log(colors(`[${finishedFiles}/${total}] SKIPPED - ${test}`, 'yellow')) + console.log('='.repeat(96)) + + finishedFiles++ + continue + } + + const start = performance.now() + + for (const variant of meta.variant.length ? meta.variant : ['']) { + const url = new URL(this.#url) + if (variant) { + url.search = variant + } + const worker = new Worker(workerPath, { + workerData: { + // Code to load before the test harness and tests. + initScripts: this.#initScripts, + // The test file. + test: code, + // Parsed META tag information + meta, + url: url.href, + path: test + } + }) + + let result, report + if (this.#appendReport) { + report = JSON.parse(readFileSync(this.#reportPath)) + + const fileUrl = new URL(`/${this.#folderName}${test.slice(this.#folderPath.length)}`, 'http://wpt') + fileUrl.pathname = fileUrl.pathname.replace(/\.js$/, '.html') + fileUrl.search = variant + + result = { + test: fileUrl.href.slice(fileUrl.origin.length), + subtests: [], + status: 'OK' + } + report.results.push(result) + } + + activeWorkers.add(worker) + // These values come directly from the web-platform-tests + const timeout = meta.timeout === 'long' ? 60_000 : 10_000 + + worker.on('message', (message) => { + if (message.type === 'result') { + this.handleIndividualTestCompletion(message, status, test, meta, result) + } else if (message.type === 'completion') { + this.handleTestCompletion(worker) + } else if (message.type === 'error') { + this.#uncaughtExceptions.push({ error: message.error, test }) + this.#stats.failed += 1 + this.#stats.success -= 1 + } + }) + + try { + await once(worker, 'exit', { + signal: AbortSignal.timeout(timeout) + }) + + console.log(colors(`[${finishedFiles}/${total}] PASSED - ${test}`, 'green')) + if (variant) console.log('Variant:', variant) + console.log(`Test took ${(performance.now() - start).toFixed(2)}ms`) + console.log('='.repeat(96)) + } catch (e) { + console.log(`${test} timed out after ${timeout}ms`) + } finally { + if (result?.subtests.length > 0) { + writeFileSync(this.#reportPath, JSON.stringify(report)) + } + + finishedFiles++ + activeWorkers.delete(worker) + } + } + } + + this.handleRunnerCompletion() + } + + /** + * Called after a test has succeeded or failed. + */ + handleIndividualTestCompletion (message, status, path, meta, wptResult) { + const { file, topLevel } = status + + if (message.type === 'result') { + this.#stats.completed += 1 + + if (message.result.status === 1) { + this.#stats.failed += 1 + + wptResult?.subtests.push({ + status: 'FAIL', + name: sanitizeUnpairedSurrogates(message.result.name), + message: sanitizeUnpairedSurrogates(message.result.message) + }) + + const name = normalizeName(message.result.name) + + if (file.flaky?.includes(name)) { + this.#stats.expectedFailures += 1 + } else if (file.allowUnexpectedFailures || topLevel.allowUnexpectedFailures || file.fail?.includes(name)) { + if (!file.allowUnexpectedFailures && !topLevel.allowUnexpectedFailures) { + if (Array.isArray(file.fail)) { + this.#statusOutput[path] ??= [] + this.#statusOutput[path].push(name) + } + } + + this.#stats.expectedFailures += 1 + } else { + process.exitCode = 1 + console.error(message.result) + } + } else { + wptResult?.subtests.push({ + status: 'PASS', + name: sanitizeUnpairedSurrogates(message.result.name) + }) + this.#stats.success += 1 + } + } + } + + /** + * Called after all the tests in a worker are completed. + * @param {Worker} worker + */ + handleTestCompletion (worker) { + worker.terminate() + } + + /** + * Called after every test has completed. + */ + handleRunnerCompletion () { + console.log(this.#statusOutput) // tests that failed + + this.emit('completion') + const { completed, failed, success, expectedFailures, skipped } = this.#stats + console.log( + `[${this.#folderName}]: ` + + `Completed: ${completed}, failed: ${failed}, success: ${success}, ` + + `expected failures: ${expectedFailures}, ` + + `unexpected failures: ${failed - expectedFailures}, ` + + `skipped: ${skipped}` + ) + + process.exit(0) + } + + addInitScript (code) { + this.#initScripts.push(code) + } + + /** + * Parses META tags and resolves any script file paths. + * @param {string} code + * @param {string} path The absolute path of the test + */ + resolveMeta (code, path) { + const meta = parseMeta(code) + const scripts = meta.scripts.map((filePath) => { + let content = '' + + if (filePath === '/resources/WebIDLParser.js') { + // See https://github.com/web-platform-tests/wpt/pull/731 + return readFileSync(join(testPath, '/resources/webidl2/lib/webidl2.js'), 'utf-8') + } else if (isAbsolute(filePath)) { + content = readFileSync(join(testPath, filePath), 'utf-8') + } else { + content = readFileSync(resolve(path, '..', filePath), 'utf-8') + } + + // If the file has any built-in pipes. + if (filePath.includes('.sub.')) { + content = handlePipes(content, this.#url) + } + + return content + }) + + return { + ...meta, + resourcePaths: meta.scripts, + scripts + } + } +} diff --git a/test/wpt/runner/util.mjs b/test/wpt/runner/util.mjs new file mode 100644 index 0000000..ec284df --- /dev/null +++ b/test/wpt/runner/util.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert' +import { exit } from 'node:process' +import { inspect } from 'node:util' +import tty from 'node:tty' +import { sep } from 'node:path' + +/** + * Parse the `Meta:` tags sometimes included in tests. + * These can include resources to inject, how long it should + * take to timeout, and which globals to expose. + * @example + * // META: timeout=long + * // META: global=window,worker + * // META: script=/common/utils.js + * // META: script=/common/get-host-info.sub.js + * // META: script=../request/request-error.js + * @see https://nodejs.org/api/readline.html#readline_example_read_file_stream_line_by_line + * @param {string} fileContents + */ +export function parseMeta (fileContents) { + const lines = fileContents.split(/\r?\n/g) + + const meta = { + /** @type {string|null} */ + timeout: null, + /** @type {string[]} */ + global: [], + /** @type {string[]} */ + scripts: [], + /** @type {string[]} */ + variant: [] + } + + for (const line of lines) { + if (!line.startsWith('// META: ')) { + break + } + + const groups = /^\/\/ META: (?<type>.*?)=(?<match>.*)$/.exec(line)?.groups + + if (!groups) { + console.log(`Failed to parse META tag: ${line}`) + exit(1) + } + + switch (groups.type) { + case 'variant': + meta[groups.type].push(groups.match) + break + case 'title': + case 'timeout': { + meta[groups.type] = groups.match + break + } + case 'global': { + // window,worker -> ['window', 'worker'] + meta.global.push(...groups.match.split(',')) + break + } + case 'script': { + // A relative or absolute file path to the resources + // needed for the current test. + meta.scripts.push(groups.match) + break + } + default: { + console.log(`Unknown META tag: ${groups.type}`) + exit(1) + } + } + } + + return meta +} + +/** + * @param {string} sub + */ +function parseSubBlock (sub) { + const subName = sub.includes('[') ? sub.slice(0, sub.indexOf('[')) : sub + const options = sub.matchAll(/\[(.*?)\]/gm) + + return { + sub: subName, + options: [...options].map(match => match[1]) + } +} + +/** + * @see https://web-platform-tests.org/writing-tests/server-pipes.html?highlight=sub#built-in-pipes + * @param {string} code + * @param {string} url + */ +export function handlePipes (code, url) { + const server = new URL(url) + + // "Substitutions are marked in a file using a block delimited by + // {{ and }}. Inside the block the following variables are available:" + return code.replace(/{{(.*?)}}/gm, (_, match) => { + const { sub } = parseSubBlock(match) + + switch (sub) { + // "The host name of the server excluding any subdomain part." + // eslint-disable-next-line no-fallthrough + case 'host': + // "The domain name of a particular subdomain e.g. + // {{domains[www]}} for the www subdomain." + // eslint-disable-next-line no-fallthrough + case 'domains': + // "The domain name of a particular subdomain for a particular host. + // The first key may be empty (designating the “default” host) or + // the value alt; i.e., {{hosts[alt][]}} (designating the alternate + // host)." + // eslint-disable-next-line no-fallthrough + case 'hosts': { + return 'localhost' + } + // "The port number of servers, by protocol e.g. {{ports[http][0]}} + // for the first (and, depending on setup, possibly only) http server" + case 'ports': { + return server.port + } + default: { + throw new TypeError(`Unknown substitute "${sub}".`) + } + } + }) +} + +/** + * Some test names may contain characters that JSON cannot handle. + * @param {string} name + */ +export function normalizeName (name) { + return name.replace(/(\v)/g, (_, match) => { + switch (inspect(match)) { + case '\'\\x0B\'': return '\\x0B' + default: return match + } + }) +} + +export function colors (str, color) { + assert(Object.hasOwn(inspect.colors, color), `Missing color ${color}`) + + if (!tty.WriteStream.prototype.hasColors()) { + return str + } + + const [start, end] = inspect.colors[color] + + return `\u001b[${start}m${str}\u001b[${end}m` +} + +/** @param {string} path */ +export function resolveStatusPath (path, status) { + const paths = path + .slice(process.cwd().length + sep.length) + .split(sep) + .slice(3) // [test, wpt, tests, fetch, b, c.js] -> [fetch, b, c.js] + + // skip the first folder name + for (let i = 1; i < paths.length - 1; i++) { + status = status[paths[i]] + + if (!status) { + break + } + } + + return { topLevel: status ?? {}, file: status?.[paths.at(-1)] ?? {} } +} diff --git a/test/wpt/runner/worker.mjs b/test/wpt/runner/worker.mjs new file mode 100644 index 0000000..90bfcf6 --- /dev/null +++ b/test/wpt/runner/worker.mjs @@ -0,0 +1,164 @@ +import buffer from 'node:buffer' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { setFlagsFromString } from 'node:v8' +import { runInNewContext, runInThisContext } from 'node:vm' +import { parentPort, workerData } from 'node:worker_threads' +import { + fetch, File, FileReader, FormData, Headers, Request, Response, setGlobalOrigin +} from '../../../index.js' +import { CloseEvent } from '../../../lib/websocket/events.js' +import { WebSocket } from '../../../lib/websocket/websocket.js' +import { Cache } from '../../../lib/cache/cache.js' +import { CacheStorage } from '../../../lib/cache/cachestorage.js' +import { kConstruct } from '../../../lib/cache/symbols.js' + +const { initScripts, meta, test, url, path } = workerData + +process.on('uncaughtException', (err) => { + parentPort.postMessage({ + type: 'error', + error: { + message: err.message, + name: err.name, + stack: err.stack + } + }) +}) + +const basePath = join(process.cwd(), 'test/wpt/tests') +const urlPath = path.slice(basePath.length) + +const globalPropertyDescriptors = { + writable: true, + enumerable: false, + configurable: true +} + +Object.defineProperties(globalThis, { + fetch: { + ...globalPropertyDescriptors, + enumerable: true, + value: fetch + }, + File: { + ...globalPropertyDescriptors, + value: buffer.File ?? File + }, + FormData: { + ...globalPropertyDescriptors, + value: FormData + }, + Headers: { + ...globalPropertyDescriptors, + value: Headers + }, + Request: { + ...globalPropertyDescriptors, + value: Request + }, + Response: { + ...globalPropertyDescriptors, + value: Response + }, + FileReader: { + ...globalPropertyDescriptors, + value: FileReader + }, + WebSocket: { + ...globalPropertyDescriptors, + value: WebSocket + }, + CloseEvent: { + ...globalPropertyDescriptors, + value: CloseEvent + }, + Blob: { + ...globalPropertyDescriptors, + // See https://github.com/nodejs/node/pull/45659 + value: buffer.Blob + }, + caches: { + ...globalPropertyDescriptors, + value: new CacheStorage(kConstruct) + }, + Cache: { + ...globalPropertyDescriptors, + value: Cache + }, + CacheStorage: { + ...globalPropertyDescriptors, + value: CacheStorage + } +}) + +// self is required by testharness +// GLOBAL is required by self +runInThisContext(` + globalThis.self = globalThis + globalThis.GLOBAL = { + isWorker () { + return false + }, + isShadowRealm () { + return false + }, + isWindow () { + return false + } + } + globalThis.window = globalThis + globalThis.location = new URL('${urlPath.replace(/\\/g, '/')}', '${url}') + globalThis.Window = Object.getPrototypeOf(globalThis).constructor +`) + +if (meta.title) { + runInThisContext(`globalThis.META_TITLE = "${meta.title}"`) +} + +const harness = readFileSync(join(basePath, '/resources/testharness.js'), 'utf-8') +runInThisContext(harness) + +// add_*_callback comes from testharness +// stolen from node's wpt test runner +// eslint-disable-next-line no-undef +add_result_callback((result) => { + parentPort.postMessage({ + type: 'result', + result: { + status: result.status, + name: result.name, + message: result.message, + stack: result.stack + } + }) +}) + +// eslint-disable-next-line no-undef +add_completion_callback((_, status) => { + parentPort.postMessage({ + type: 'completion', + status + }) +}) + +setGlobalOrigin(globalThis.location) + +// Inject any script the user provided before +// running the tests. +for (const initScript of initScripts) { + runInThisContext(initScript) +} + +// Inject any files from the META tags +for (const script of meta.scripts) { + runInThisContext(script) +} + +// A few tests require gc, which can't be passed to a Worker. +// see https://github.com/nodejs/node/issues/16595#issuecomment-340288680 +setFlagsFromString('--expose-gc') +globalThis.gc = runInNewContext('gc') + +// Finally, run the test. +runInThisContext(test) |