Skip to content

Commit

Permalink
feat(WPTRunner): parse META tags (nodejs#1664)
Browse files Browse the repository at this point in the history
* feat(WPTRunner): parse `META` tags

* feat: add more routes

* feat: add /resources/data.json route

* fix(fetch): throw AbortError DOMException on consume if aborted

* fix(fetch): throw AbortError on `.formData()` after abort

* feat: add expected failures & end log

* fix: import DOMException for node 16

* feat: run each test in its own worker & simplify worker
  • Loading branch information
KhafraDev authored and crysmags committed Feb 27, 2024
1 parent 436d84c commit 472c05a
Show file tree
Hide file tree
Showing 15 changed files with 1,044 additions and 32 deletions.
25 changes: 24 additions & 1 deletion lib/fetch/body.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { ReadableStreamFrom, toUSVString, isBlobLike } = require('./util')
const { FormData } = require('./formdata')
const { kState } = require('./symbols')
const { webidl } = require('./webidl')
const { DOMException } = require('./constants')
const { Blob } = require('buffer')
const { kBodyUsed } = require('../core/symbols')
const assert = require('assert')
Expand Down Expand Up @@ -281,13 +282,21 @@ async function * consumeBody (body) {
}
}

function throwIfAborted (state) {
if (state.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError')
}
}

function bodyMixinMethods (instance) {
const methods = {
async blob () {
if (!(this instanceof instance)) {
throw new TypeError('Illegal invocation')
}

throwIfAborted(this[kState])

const chunks = []

for await (const chunk of consumeBody(this[kState].body)) {
Expand All @@ -308,6 +317,8 @@ function bodyMixinMethods (instance) {
throw new TypeError('Illegal invocation')
}

throwIfAborted(this[kState])

const contentLength = this.headers.get('content-length')
const encoded = this.headers.has('content-encoding')

Expand Down Expand Up @@ -363,6 +374,8 @@ function bodyMixinMethods (instance) {
throw new TypeError('Illegal invocation')
}

throwIfAborted(this[kState])

let result = ''
const textDecoder = new TextDecoder()

Expand All @@ -385,6 +398,8 @@ function bodyMixinMethods (instance) {
throw new TypeError('Illegal invocation')
}

throwIfAborted(this[kState])

return JSON.parse(await this.text())
},

Expand All @@ -393,6 +408,8 @@ function bodyMixinMethods (instance) {
throw new TypeError('Illegal invocation')
}

throwIfAborted(this[kState])

const contentType = this.headers.get('Content-Type')

// If mimeType’s essence is "multipart/form-data", then:
Expand Down Expand Up @@ -429,10 +446,16 @@ function bodyMixinMethods (instance) {
}
return formData
} else {
// Wait a tick before checking if the request has been aborted.
// Otherwise, a TypeError can be thrown when an AbortError should.
await Promise.resolve()

throwIfAborted(this[kState])

// Otherwise, throw a TypeError.
webidl.errors.exception({
header: `${instance.name}.formData`,
value: 'Could not parse content as FormData.'
message: 'Could not parse content as FormData.'
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
"ignore": [
"lib/llhttp/constants.js",
"lib/llhttp/utils.js",
"test/wpt/runner/fetch",
"test/wpt/tests",
"test/wpt/runner/resources"
]
},
Expand Down
2 changes: 1 addition & 1 deletion test/node-fetch/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ describe('node-fetch', () => {
return expect(res.text())
.to.eventually.be.rejected
.and.be.an.instanceof(Error)
.and.have.property('name', 'TypeError')
.and.have.property('name', 'AbortError')
})
})
})
Expand Down
1 change: 1 addition & 0 deletions test/wpt/runner/resources/data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"key": "value"}
Empty file.
120 changes: 101 additions & 19 deletions test/wpt/runner/runner/runner.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { join, resolve } from 'node:path'
import { EventEmitter, once } from 'node:events'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import { readdirSync, statSync } from 'node:fs'
import { EventEmitter } from 'node:events'
import { parseMeta } from './util.mjs'

const testPath = fileURLToPath(join(import.meta.url, '../..'))
const basePath = fileURLToPath(join(import.meta.url, '../../..'))
const testPath = join(basePath, 'tests')
const statusPath = join(basePath, 'status')

export class WPTRunner extends EventEmitter {
/** @type {string} */
Expand All @@ -19,11 +22,22 @@ export class WPTRunner extends EventEmitter {
/** @type {string} */
#url

/** @type {import('../../status/fetch.status.json')} */
#status

#stats = {
completed: 0,
failed: 0,
success: 0,
expectedFailures: 0
}

constructor (folder, url) {
super()

this.#folderPath = join(testPath, folder)
this.#files.push(...WPTRunner.walk(this.#folderPath, () => true))
this.#status = JSON.parse(readFileSync(join(statusPath, `${folder}.status.json`)))
this.#url = url

if (this.#files.length === 0) {
Expand Down Expand Up @@ -56,28 +70,96 @@ export class WPTRunner extends EventEmitter {
return [...files]
}

run () {
async run () {
const workerPath = fileURLToPath(join(import.meta.url, '../worker.mjs'))

const worker = new Worker(workerPath, {
workerData: {
initScripts: this.#initScripts,
paths: this.#files,
url: this.#url
}
})
for (const test of this.#files) {
const code = readFileSync(test, 'utf-8')
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: this.resolveMeta(code, test),
url: this.#url,
path: test
}
})

worker.on('message', (message) => {
if (message.result?.status === 1) {
process.exitCode = 1
console.log({ message })
} else if (message.type === 'completion') {
this.emit('completion')
worker.on('message', (message) => {
if (message.type === 'result') {
this.handleIndividualTestCompletion(message)
} else if (message.type === 'completion') {
this.handleTestCompletion(worker)
}
})

await once(worker, 'exit')
}

this.emit('completion')
const { completed, failed, success, expectedFailures } = this.#stats
console.log(
`Completed: ${completed}, failed: ${failed}, success: ${success}, ` +
`expected failures: ${expectedFailures}, ` +
`unexpected failures: ${failed - expectedFailures}`
)
}

/**
* Called after a test has succeeded or failed.
*/
handleIndividualTestCompletion (message) {
if (message.type === 'result') {
this.#stats.completed += 1

if (message.result.status === 1) {
this.#stats.failed += 1

if (this.#status.fail.includes(message.result.name)) {
this.#stats.expectedFailures += 1
} else {
process.exitCode = 1
console.error(message.result)
}
} else {
this.#stats.success += 1
}
})
}
}

/**
* Called after all the tests in a worker are completed.
* @param {Worker} worker
*/
handleTestCompletion (worker) {
worker.terminate()
}

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((script) => {
if (isAbsolute(script)) {
return readFileSync(join(testPath, script), 'utf-8')
}

return readFileSync(resolve(path, '..', script), 'utf-8')
})

return {
...meta,
scripts
}
}
}
64 changes: 64 additions & 0 deletions test/wpt/runner/runner/util.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { exit } from 'node:process'

/**
* 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: []
}

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 'timeout': {
meta.timeout = 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
}
20 changes: 12 additions & 8 deletions test/wpt/runner/runner/worker.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { readFileSync } from 'node:fs'
import { createContext, runInContext, runInThisContext } from 'node:vm'
import { runInThisContext } from 'node:vm'
import { parentPort, workerData } from 'node:worker_threads'
import {
setGlobalOrigin,
Expand All @@ -11,7 +10,7 @@ import {
Headers
} from '../../../../index.js'

const { initScripts, paths, url } = workerData
const { initScripts, meta, test, url } = workerData

const globalPropertyDescriptors = {
writable: true,
Expand Down Expand Up @@ -58,6 +57,8 @@ runInThisContext(`
return false
}
}
globalThis.window = globalThis
globalThis.location = new URL('${url}')
`)

await import('../resources/testharness.cjs')
Expand Down Expand Up @@ -87,13 +88,16 @@ add_completion_callback((_, status) => {

setGlobalOrigin(url)

// Inject any script the user provided before
// running the tests.
for (const initScript of initScripts) {
runInThisContext(initScript)
}

for (const path of paths) {
const code = readFileSync(path, 'utf-8')
const context = createContext(globalThis)

runInContext(code, context, { filename: path })
// Inject any files from the META tags
for (const script of meta.scripts) {
runInThisContext(script)
}

// Finally, run the test.
runInThisContext(test)
Loading

0 comments on commit 472c05a

Please sign in to comment.