Skip to content

Commit e5ee395

Browse files
authored
feat(WPTRunner): parse META tags (nodejs#1664)
* 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
1 parent 979d398 commit e5ee395

File tree

15 files changed

+1044
-32
lines changed

15 files changed

+1044
-32
lines changed

lib/fetch/body.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { ReadableStreamFrom, toUSVString, isBlobLike } = require('./util')
55
const { FormData } = require('./formdata')
66
const { kState } = require('./symbols')
77
const { webidl } = require('./webidl')
8+
const { DOMException } = require('./constants')
89
const { Blob } = require('buffer')
910
const { kBodyUsed } = require('../core/symbols')
1011
const assert = require('assert')
@@ -281,13 +282,21 @@ async function * consumeBody (body) {
281282
}
282283
}
283284

285+
function throwIfAborted (state) {
286+
if (state.aborted) {
287+
throw new DOMException('The operation was aborted.', 'AbortError')
288+
}
289+
}
290+
284291
function bodyMixinMethods (instance) {
285292
const methods = {
286293
async blob () {
287294
if (!(this instanceof instance)) {
288295
throw new TypeError('Illegal invocation')
289296
}
290297

298+
throwIfAborted(this[kState])
299+
291300
const chunks = []
292301

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

320+
throwIfAborted(this[kState])
321+
311322
const contentLength = this.headers.get('content-length')
312323
const encoded = this.headers.has('content-encoding')
313324

@@ -363,6 +374,8 @@ function bodyMixinMethods (instance) {
363374
throw new TypeError('Illegal invocation')
364375
}
365376

377+
throwIfAborted(this[kState])
378+
366379
let result = ''
367380
const textDecoder = new TextDecoder()
368381

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

401+
throwIfAborted(this[kState])
402+
388403
return JSON.parse(await this.text())
389404
},
390405

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

411+
throwIfAborted(this[kState])
412+
396413
const contentType = this.headers.get('Content-Type')
397414

398415
// If mimeType’s essence is "multipart/form-data", then:
@@ -429,10 +446,16 @@ function bodyMixinMethods (instance) {
429446
}
430447
return formData
431448
} else {
449+
// Wait a tick before checking if the request has been aborted.
450+
// Otherwise, a TypeError can be thrown when an AbortError should.
451+
await Promise.resolve()
452+
453+
throwIfAborted(this[kState])
454+
432455
// Otherwise, throw a TypeError.
433456
webidl.errors.exception({
434457
header: `${instance.name}.formData`,
435-
value: 'Could not parse content as FormData.'
458+
message: 'Could not parse content as FormData.'
436459
})
437460
}
438461
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
"ignore": [
109109
"lib/llhttp/constants.js",
110110
"lib/llhttp/utils.js",
111-
"test/wpt/runner/fetch",
111+
"test/wpt/tests",
112112
"test/wpt/runner/resources"
113113
]
114114
},

test/node-fetch/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ describe('node-fetch', () => {
880880
return expect(res.text())
881881
.to.eventually.be.rejected
882882
.and.be.an.instanceof(Error)
883-
.and.have.property('name', 'TypeError')
883+
.and.have.property('name', 'AbortError')
884884
})
885885
})
886886
})

test/wpt/runner/resources/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"key": "value"}

test/wpt/runner/resources/empty.txt

Whitespace-only changes.

test/wpt/runner/runner/runner.mjs

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { join, resolve } from 'node:path'
1+
import { EventEmitter, once } from 'node:events'
2+
import { readdirSync, readFileSync, statSync } from 'node:fs'
3+
import { isAbsolute, join, resolve } from 'node:path'
24
import { fileURLToPath } from 'node:url'
35
import { Worker } from 'node:worker_threads'
4-
import { readdirSync, statSync } from 'node:fs'
5-
import { EventEmitter } from 'node:events'
6+
import { parseMeta } from './util.mjs'
67

7-
const testPath = fileURLToPath(join(import.meta.url, '../..'))
8+
const basePath = fileURLToPath(join(import.meta.url, '../../..'))
9+
const testPath = join(basePath, 'tests')
10+
const statusPath = join(basePath, 'status')
811

912
export class WPTRunner extends EventEmitter {
1013
/** @type {string} */
@@ -19,11 +22,22 @@ export class WPTRunner extends EventEmitter {
1922
/** @type {string} */
2023
#url
2124

25+
/** @type {import('../../status/fetch.status.json')} */
26+
#status
27+
28+
#stats = {
29+
completed: 0,
30+
failed: 0,
31+
success: 0,
32+
expectedFailures: 0
33+
}
34+
2235
constructor (folder, url) {
2336
super()
2437

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

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

59-
run () {
73+
async run () {
6074
const workerPath = fileURLToPath(join(import.meta.url, '../worker.mjs'))
6175

62-
const worker = new Worker(workerPath, {
63-
workerData: {
64-
initScripts: this.#initScripts,
65-
paths: this.#files,
66-
url: this.#url
67-
}
68-
})
76+
for (const test of this.#files) {
77+
const code = readFileSync(test, 'utf-8')
78+
const worker = new Worker(workerPath, {
79+
workerData: {
80+
// Code to load before the test harness and tests.
81+
initScripts: this.#initScripts,
82+
// The test file.
83+
test: code,
84+
// Parsed META tag information
85+
meta: this.resolveMeta(code, test),
86+
url: this.#url,
87+
path: test
88+
}
89+
})
6990

70-
worker.on('message', (message) => {
71-
if (message.result?.status === 1) {
72-
process.exitCode = 1
73-
console.log({ message })
74-
} else if (message.type === 'completion') {
75-
this.emit('completion')
91+
worker.on('message', (message) => {
92+
if (message.type === 'result') {
93+
this.handleIndividualTestCompletion(message)
94+
} else if (message.type === 'completion') {
95+
this.handleTestCompletion(worker)
96+
}
97+
})
98+
99+
await once(worker, 'exit')
100+
}
101+
102+
this.emit('completion')
103+
const { completed, failed, success, expectedFailures } = this.#stats
104+
console.log(
105+
`Completed: ${completed}, failed: ${failed}, success: ${success}, ` +
106+
`expected failures: ${expectedFailures}, ` +
107+
`unexpected failures: ${failed - expectedFailures}`
108+
)
109+
}
110+
111+
/**
112+
* Called after a test has succeeded or failed.
113+
*/
114+
handleIndividualTestCompletion (message) {
115+
if (message.type === 'result') {
116+
this.#stats.completed += 1
117+
118+
if (message.result.status === 1) {
119+
this.#stats.failed += 1
120+
121+
if (this.#status.fail.includes(message.result.name)) {
122+
this.#stats.expectedFailures += 1
123+
} else {
124+
process.exitCode = 1
125+
console.error(message.result)
126+
}
127+
} else {
128+
this.#stats.success += 1
76129
}
77-
})
130+
}
131+
}
132+
133+
/**
134+
* Called after all the tests in a worker are completed.
135+
* @param {Worker} worker
136+
*/
137+
handleTestCompletion (worker) {
138+
worker.terminate()
78139
}
79140

80141
addInitScript (code) {
81142
this.#initScripts.push(code)
82143
}
144+
145+
/**
146+
* Parses META tags and resolves any script file paths.
147+
* @param {string} code
148+
* @param {string} path The absolute path of the test
149+
*/
150+
resolveMeta (code, path) {
151+
const meta = parseMeta(code)
152+
const scripts = meta.scripts.map((script) => {
153+
if (isAbsolute(script)) {
154+
return readFileSync(join(testPath, script), 'utf-8')
155+
}
156+
157+
return readFileSync(resolve(path, '..', script), 'utf-8')
158+
})
159+
160+
return {
161+
...meta,
162+
scripts
163+
}
164+
}
83165
}

test/wpt/runner/runner/util.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { exit } from 'node:process'
2+
3+
/**
4+
* Parse the `Meta:` tags sometimes included in tests.
5+
* These can include resources to inject, how long it should
6+
* take to timeout, and which globals to expose.
7+
* @example
8+
* // META: timeout=long
9+
* // META: global=window,worker
10+
* // META: script=/common/utils.js
11+
* // META: script=/common/get-host-info.sub.js
12+
* // META: script=../request/request-error.js
13+
* @see https://nodejs.org/api/readline.html#readline_example_read_file_stream_line_by_line
14+
* @param {string} fileContents
15+
*/
16+
export function parseMeta (fileContents) {
17+
const lines = fileContents.split(/\r?\n/g)
18+
19+
const meta = {
20+
/** @type {string|null} */
21+
timeout: null,
22+
/** @type {string[]} */
23+
global: [],
24+
/** @type {string[]} */
25+
scripts: []
26+
}
27+
28+
for (const line of lines) {
29+
if (!line.startsWith('// META: ')) {
30+
break
31+
}
32+
33+
const groups = /^\/\/ META: (?<type>.*?)=(?<match>.*)$/.exec(line)?.groups
34+
35+
if (!groups) {
36+
console.log(`Failed to parse META tag: ${line}`)
37+
exit(1)
38+
}
39+
40+
switch (groups.type) {
41+
case 'timeout': {
42+
meta.timeout = groups.match
43+
break
44+
}
45+
case 'global': {
46+
// window,worker -> ['window', 'worker']
47+
meta.global.push(...groups.match.split(','))
48+
break
49+
}
50+
case 'script': {
51+
// A relative or absolute file path to the resources
52+
// needed for the current test.
53+
meta.scripts.push(groups.match)
54+
break
55+
}
56+
default: {
57+
console.log(`Unknown META tag: ${groups.type}`)
58+
exit(1)
59+
}
60+
}
61+
}
62+
63+
return meta
64+
}

test/wpt/runner/runner/worker.mjs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { readFileSync } from 'node:fs'
2-
import { createContext, runInContext, runInThisContext } from 'node:vm'
1+
import { runInThisContext } from 'node:vm'
32
import { parentPort, workerData } from 'node:worker_threads'
43
import {
54
setGlobalOrigin,
@@ -11,7 +10,7 @@ import {
1110
Headers
1211
} from '../../../../index.js'
1312

14-
const { initScripts, paths, url } = workerData
13+
const { initScripts, meta, test, url } = workerData
1514

1615
const globalPropertyDescriptors = {
1716
writable: true,
@@ -58,6 +57,8 @@ runInThisContext(`
5857
return false
5958
}
6059
}
60+
globalThis.window = globalThis
61+
globalThis.location = new URL('${url}')
6162
`)
6263

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

8889
setGlobalOrigin(url)
8990

91+
// Inject any script the user provided before
92+
// running the tests.
9093
for (const initScript of initScripts) {
9194
runInThisContext(initScript)
9295
}
9396

94-
for (const path of paths) {
95-
const code = readFileSync(path, 'utf-8')
96-
const context = createContext(globalThis)
97-
98-
runInContext(code, context, { filename: path })
97+
// Inject any files from the META tags
98+
for (const script of meta.scripts) {
99+
runInThisContext(script)
99100
}
101+
102+
// Finally, run the test.
103+
runInThisContext(test)

0 commit comments

Comments
 (0)