diff --git a/lib/fetch/body.js b/lib/fetch/body.js index da4998d1f2a..62f81b33d74 100644 --- a/lib/fetch/body.js +++ b/lib/fetch/body.js @@ -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') @@ -281,6 +282,12 @@ 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 () { @@ -288,6 +295,8 @@ function bodyMixinMethods (instance) { throw new TypeError('Illegal invocation') } + throwIfAborted(this[kState]) + const chunks = [] for await (const chunk of consumeBody(this[kState].body)) { @@ -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') @@ -363,6 +374,8 @@ function bodyMixinMethods (instance) { throw new TypeError('Illegal invocation') } + throwIfAborted(this[kState]) + let result = '' const textDecoder = new TextDecoder() @@ -385,6 +398,8 @@ function bodyMixinMethods (instance) { throw new TypeError('Illegal invocation') } + throwIfAborted(this[kState]) + return JSON.parse(await this.text()) }, @@ -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: @@ -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.' }) } } diff --git a/package.json b/package.json index 024de78e22d..a067832c163 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "ignore": [ "lib/llhttp/constants.js", "lib/llhttp/utils.js", - "test/wpt/runner/fetch", + "test/wpt/tests", "test/wpt/runner/resources" ] }, diff --git a/test/node-fetch/main.js b/test/node-fetch/main.js index 7066aeb825f..fb64f08efed 100644 --- a/test/node-fetch/main.js +++ b/test/node-fetch/main.js @@ -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') }) }) }) diff --git a/test/wpt/runner/resources/data.json b/test/wpt/runner/resources/data.json new file mode 100644 index 00000000000..76519fa8cc2 --- /dev/null +++ b/test/wpt/runner/resources/data.json @@ -0,0 +1 @@ +{"key": "value"} diff --git a/test/wpt/runner/resources/empty.txt b/test/wpt/runner/resources/empty.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/wpt/runner/runner/runner.mjs b/test/wpt/runner/runner/runner.mjs index 14344d012f6..862a1e8287e 100644 --- a/test/wpt/runner/runner/runner.mjs +++ b/test/wpt/runner/runner/runner.mjs @@ -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} */ @@ -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) { @@ -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 + } + } } diff --git a/test/wpt/runner/runner/util.mjs b/test/wpt/runner/runner/util.mjs new file mode 100644 index 00000000000..4806307038f --- /dev/null +++ b/test/wpt/runner/runner/util.mjs @@ -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: (?.*?)=(?.*)$/.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 +} diff --git a/test/wpt/runner/runner/worker.mjs b/test/wpt/runner/runner/worker.mjs index 11f8d5cbdd2..b56b404a993 100644 --- a/test/wpt/runner/runner/worker.mjs +++ b/test/wpt/runner/runner/worker.mjs @@ -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, @@ -11,7 +10,7 @@ import { Headers } from '../../../../index.js' -const { initScripts, paths, url } = workerData +const { initScripts, meta, test, url } = workerData const globalPropertyDescriptors = { writable: true, @@ -58,6 +57,8 @@ runInThisContext(` return false } } + globalThis.window = globalThis + globalThis.location = new URL('${url}') `) await import('../resources/testharness.cjs') @@ -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) diff --git a/test/wpt/server/server.mjs b/test/wpt/server/server.mjs index a83733988c2..718990a0056 100644 --- a/test/wpt/server/server.mjs +++ b/test/wpt/server/server.mjs @@ -3,14 +3,85 @@ import { createServer } from 'node:http' import { join } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' +import { createReadStream } from 'node:fs' +import { setTimeout as sleep } from 'node:timers/promises' -// eslint-disable-next-line no-unused-vars -const resources = fileURLToPath(join(import.meta.url, '../../wpt/resources')) +const resources = fileURLToPath(join(import.meta.url, '../../runner/resources')) + +// https://web-platform-tests.org/tools/wptserve/docs/stash.html +class Stash extends Map { + take (key) { + if (this.has(key)) { + const value = this.get(key) + + this.delete(key) + return value.value + } + } + + put (key, value, path) { + this.set(key, { value, path }) + } +} + +const stash = new Stash() const server = createServer(async (req, res) => { const fullUrl = new URL(req.url, `http://localhost:${server.address().port}`) switch (fullUrl.pathname) { + case '/resources/data.json': { + // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/data.json + return createReadStream(join(resources, 'data.json')) + .on('end', () => res.end()) + .pipe(res) + } + case '/resources/infinite-slow-response.py': { + // https://github.com/web-platform-tests/wpt/blob/master/fetch/api/resources/infinite-slow-response.py + const stateKey = fullUrl.searchParams.get('stateKey') ?? '' + const abortKey = fullUrl.searchParams.get('abortKey') ?? '' + + if (stateKey) { + stash.put(stateKey, 'open', fullUrl.pathname) + } + + res.setHeader('Content-Type', 'text/plain') + res.statusCode = 200 + + res.write('.'.repeat(2048)) + + while (true) { + if (!res.write('.')) { + break + } else if (abortKey && stash.take(abortKey, fullUrl.pathname)) { + break + } + + await sleep(10) + } + + if (stateKey) { + stash.put(stateKey, 'closed', fullUrl.pathname) + } + + return res.end() + } + case '/resources/stash-take.py': { + // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/stash-take.py + + const key = fullUrl.searchParams.get('key') + res.setHeader('Access-Control-Allow-Origin', '*') + + const took = stash.take(key, fullUrl.pathname) ?? null + + res.write(JSON.stringify(took)) + return res.end() + } + case '/resources/empty.txt': { + return createReadStream(join(resources, 'empty.txt')) + .on('end', () => res.end()) + .pipe(res) + } default: { res.statusCode = 200 res.end('body') diff --git a/test/wpt/status/fetch.status.json b/test/wpt/status/fetch.status.json new file mode 100644 index 00000000000..3b1d5c2dbc0 --- /dev/null +++ b/test/wpt/status/fetch.status.json @@ -0,0 +1,7 @@ +{ + "fail": [ + "Stream errors once aborted. Underlying connection closed.", + "Underlying connection is closed when aborting after receiving response - no-cors", + "Already aborted signal rejects immediately" + ] +} \ No newline at end of file diff --git a/test/wpt/tests/common/get-host-info.sub.js b/test/wpt/tests/common/get-host-info.sub.js new file mode 100644 index 00000000000..9b8c2b5de63 --- /dev/null +++ b/test/wpt/tests/common/get-host-info.sub.js @@ -0,0 +1,63 @@ +/** + * Host information for cross-origin tests. + * @returns {Object} with properties for different host information. + */ +function get_host_info() { + + var HTTP_PORT = '{{ports[http][0]}}'; + var HTTP_PORT2 = '{{ports[http][1]}}'; + var HTTPS_PORT = '{{ports[https][0]}}'; + var HTTPS_PORT2 = '{{ports[https][1]}}'; + var PROTOCOL = self.location.protocol; + var IS_HTTPS = (PROTOCOL == "https:"); + var PORT = IS_HTTPS ? HTTPS_PORT : HTTP_PORT; + var PORT2 = IS_HTTPS ? HTTPS_PORT2 : HTTP_PORT2; + var HTTP_PORT_ELIDED = HTTP_PORT == "80" ? "" : (":" + HTTP_PORT); + var HTTP_PORT2_ELIDED = HTTP_PORT2 == "80" ? "" : (":" + HTTP_PORT2); + var HTTPS_PORT_ELIDED = HTTPS_PORT == "443" ? "" : (":" + HTTPS_PORT); + var PORT_ELIDED = IS_HTTPS ? HTTPS_PORT_ELIDED : HTTP_PORT_ELIDED; + var ORIGINAL_HOST = '{{host}}'; + var REMOTE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('www1.' + ORIGINAL_HOST); + var OTHER_HOST = '{{domains[www2]}}'; + var NOTSAMESITE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('{{hosts[alt][]}}'); + + return { + HTTP_PORT: HTTP_PORT, + HTTP_PORT2: HTTP_PORT2, + HTTPS_PORT: HTTPS_PORT, + HTTPS_PORT2: HTTPS_PORT2, + PORT: PORT, + PORT2: PORT2, + ORIGINAL_HOST: ORIGINAL_HOST, + REMOTE_HOST: REMOTE_HOST, + + ORIGIN: PROTOCOL + "//" + ORIGINAL_HOST + PORT_ELIDED, + HTTP_ORIGIN: 'http://' + ORIGINAL_HOST + HTTP_PORT_ELIDED, + HTTPS_ORIGIN: 'https://' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTPS_ORIGIN_WITH_CREDS: 'https://foo:bar@' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTP_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + ORIGINAL_HOST + HTTP_PORT2_ELIDED, + REMOTE_ORIGIN: PROTOCOL + "//" + REMOTE_HOST + PORT_ELIDED, + OTHER_ORIGIN: PROTOCOL + "//" + OTHER_HOST + PORT_ELIDED, + HTTP_REMOTE_ORIGIN: 'http://' + REMOTE_HOST + HTTP_PORT_ELIDED, + HTTP_NOTSAMESITE_ORIGIN: 'http://' + NOTSAMESITE_HOST + HTTP_PORT_ELIDED, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + REMOTE_HOST + HTTP_PORT2_ELIDED, + HTTPS_REMOTE_ORIGIN: 'https://' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_REMOTE_ORIGIN_WITH_CREDS: 'https://foo:bar@' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_NOTSAMESITE_ORIGIN: 'https://' + NOTSAMESITE_HOST + HTTPS_PORT_ELIDED, + UNAUTHENTICATED_ORIGIN: 'http://' + OTHER_HOST + HTTP_PORT_ELIDED, + AUTHENTICATED_ORIGIN: 'https://' + OTHER_HOST + HTTPS_PORT_ELIDED + }; +} + +/** + * When a default port is used, location.port returns the empty string. + * This function attempts to provide an exact port, assuming we are running under wptserve. + * @param {*} loc - can be Location///URL, but assumes http/https only. + * @returns {string} The port number. + */ +function get_port(loc) { + if (loc.port) { + return loc.port; + } + return loc.protocol === 'https:' ? '443' : '80'; +} diff --git a/test/wpt/tests/common/utils.js b/test/wpt/tests/common/utils.js new file mode 100644 index 00000000000..62e742bee7f --- /dev/null +++ b/test/wpt/tests/common/utils.js @@ -0,0 +1,98 @@ +/** + * Create an absolute URL from `options` and defaulting unspecified properties to `window.location`. + * @param {Object} options - a `Location`-like object + * @param {string} options.hostname + * @param {string} options.subdomain - prepend subdomain to the hostname + * @param {string} options.port + * @param {string} options.path + * @param {string} options.query + * @param {string} options.hash + * @returns {string} + */ +function make_absolute_url(options) { + var loc = window.location; + var protocol = get(options, "protocol", loc.protocol); + if (protocol[protocol.length - 1] != ":") { + protocol += ":"; + } + + var hostname = get(options, "hostname", loc.hostname); + + var subdomain = get(options, "subdomain"); + if (subdomain) { + hostname = subdomain + "." + hostname; + } + + var port = get(options, "port", loc.port) + var path = get(options, "path", loc.pathname); + var query = get(options, "query", loc.search); + var hash = get(options, "hash", loc.hash) + + var url = protocol + "//" + hostname; + if (port) { + url += ":" + port; + } + + if (path[0] != "/") { + url += "/"; + } + url += path; + if (query) { + if (query[0] != "?") { + url += "?"; + } + url += query; + } + if (hash) { + if (hash[0] != "#") { + url += "#"; + } + url += hash; + } + return url; +} + +/** @private */ +function get(obj, name, default_val) { + if (obj.hasOwnProperty(name)) { + return obj[name]; + } + return default_val; +} + +/** + * Generate a new UUID. + * @returns {string} + */ +function token() { + var uuid = [to_hex(rand_int(32), 8), + to_hex(rand_int(16), 4), + to_hex(0x4000 | rand_int(12), 4), + to_hex(0x8000 | rand_int(14), 4), + to_hex(rand_int(48), 12)].join("-") + return uuid; +} + +/** @private */ +function rand_int(bits) { + if (bits < 1 || bits > 53) { + throw new TypeError(); + } else { + if (bits >= 1 && bits <= 30) { + return 0 | ((1 << bits) * Math.random()); + } else { + var high = (0 | ((1 << (bits - 30)) * Math.random())) * (1 << 30); + var low = 0 | ((1 << 30) * Math.random()); + return high + low; + } + } +} + +/** @private */ +function to_hex(x, length) { + var rv = x.toString(16); + while (rv.length < length) { + rv = "0" + rv; + } + return rv; +} diff --git a/test/wpt/tests/fetch/api/abort/general.any.js b/test/wpt/tests/fetch/api/abort/general.any.js new file mode 100644 index 00000000000..472d50507f6 --- /dev/null +++ b/test/wpt/tests/fetch/api/abort/general.any.js @@ -0,0 +1,542 @@ +// 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 + +const BODY_METHODS = ['arrayBuffer', 'blob', 'formData', 'json', 'text']; + +// This is used to close connections that weren't correctly closed during the tests, +// otherwise you can end up running out of HTTP connections. +let requestAbortKeys = []; + +function abortRequests() { + const keys = requestAbortKeys; + requestAbortKeys = []; + return Promise.all( + keys.map(key => fetch(`../resources/stash-put.py?key=${key}&value=close`)) + ); +} + +const hostInfo = get_host_info(); +const urlHostname = hostInfo.REMOTE_HOST; + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const fetchPromise = fetch('../resources/data.json', { signal }); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Aborting rejects with AbortError"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const url = new URL('../resources/data.json', location); + url.hostname = urlHostname; + + const fetchPromise = fetch(url, { + signal, + mode: 'no-cors' + }); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Aborting rejects with AbortError - no-cors"); + +// Test that errors thrown from the request constructor take priority over abort errors. +// badRequestArgTests is from response-error.js +for (const { args, testName } of badRequestArgTests) { + promise_test(async t => { + try { + // If this doesn't throw, we'll effectively skip the test. + // It'll fail properly in ../request/request-error.html + new Request(...args); + } + catch (err) { + const controller = new AbortController(); + controller.abort(); + + // Add signal to 2nd arg + args[1] = args[1] || {}; + args[1].signal = controller.signal; + await promise_rejects_js(t, TypeError, fetch(...args)); + } + }, `TypeError from request constructor takes priority - ${testName}`); +} + +test(() => { + const request = new Request(''); + assert_true(Boolean(request.signal), "Signal member is present & truthy"); + assert_equals(request.signal.constructor, AbortSignal); +}, "Request objects have a signal property"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + assert_true(Boolean(request.signal), "Signal member is present & truthy"); + assert_equals(request.signal.constructor, AbortSignal); + assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference'); + assert_true(request.signal.aborted, `Request's signal has aborted`); + + const fetchPromise = fetch(request); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + const requestFromRequest = new Request(request); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json'); + const requestFromRequest = new Request(request, { signal }); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object, with signal on second request"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal: new AbortController().signal }); + const requestFromRequest = new Request(request, { signal }); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object, with signal on second request overriding another"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + const fetchPromise = fetch(request, {method: 'POST'}); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal retained after unrelated properties are overridden by fetch"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + const data = await fetch(request, { signal: null }).then(r => r.json()); + assert_equals(data.key, 'value', 'Fetch fully completes'); +}, "Signal removed by setting to null"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const log = []; + + await Promise.all([ + fetch('../resources/data.json', { signal }).then( + () => assert_unreached("Fetch must not resolve"), + () => log.push('fetch-reject') + ), + Promise.resolve().then(() => log.push('next-microtask')) + ]); + + assert_array_equals(log, ['fetch-reject', 'next-microtask']); +}, "Already aborted signal rejects immediately"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { + signal, + method: 'POST', + body: 'foo', + headers: { 'Content-Type': 'text/plain' } + }); + + await fetch(request).catch(() => {}); + + assert_true(request.bodyUsed, "Body has been used"); +}, "Request is still 'used' if signal is aborted before fetching"); + +for (const bodyMethod of BODY_METHODS) { + promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + + const log = []; + const response = await fetch('../resources/data.json', { signal }); + + controller.abort(); + + const bodyPromise = response[bodyMethod](); + + await Promise.all([ + bodyPromise.catch(() => log.push(`${bodyMethod}-reject`)), + Promise.resolve().then(() => log.push('next-microtask')) + ]); + + await promise_rejects_dom(t, "AbortError", bodyPromise); + + assert_array_equals(log, [`${bodyMethod}-reject`, 'next-microtask']); + }, `response.${bodyMethod}() rejects if already aborted`); +} + +promise_test(async (t) => { + const controller = new AbortController(); + const signal = controller.signal; + + const res = await fetch('../resources/data.json', { signal }); + controller.abort(); + + await promise_rejects_dom(t, 'AbortError', res.text()); + await promise_rejects_dom(t, 'AbortError', res.text()); +}, 'Call text() twice on aborted response'); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + controller.abort(); + + await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }).catch(() => {}); + + // I'm hoping this will give the browser enough time to (incorrectly) make the request + // above, if it intends to. + await fetch('../resources/data.json').then(r => r.json()); + + const response = await fetch(`../resources/stash-take.py?key=${stateKey}`); + const data = await response.json(); + + assert_equals(data, null, "Request hasn't been made to the server"); +}, "Already aborted signal does not make request"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const fetches = []; + + for (let i = 0; i < 3; i++) { + const abortKey = token(); + requestAbortKeys.push(abortKey); + + fetches.push( + fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal }) + ); + } + + for (const fetchPromise of fetches) { + await promise_rejects_dom(t, "AbortError", fetchPromise); + } +}, "Already aborted signal can be used for many fetches"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + + await fetch('../resources/data.json', { signal }).then(r => r.json()); + + controller.abort(); + + const fetches = []; + + for (let i = 0; i < 3; i++) { + const abortKey = token(); + requestAbortKeys.push(abortKey); + + fetches.push( + fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal }) + ); + } + + for (const fetchPromise of fetches) { + await promise_rejects_dom(t, "AbortError", fetchPromise); + } +}, "Signal can be used to abort other fetches, even if another fetch succeeded before aborting"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + + const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + controller.abort(); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Underlying connection is closed when aborting after receiving response"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const url = new URL(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, location); + url.hostname = urlHostname; + + await fetch(url, { + signal, + mode: 'no-cors' + }); + + const stashTakeURL = new URL(`../resources/stash-take.py?key=${stateKey}`, location); + stashTakeURL.hostname = urlHostname; + + const beforeAbortResult = await fetch(stashTakeURL).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + controller.abort(); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(stashTakeURL).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Underlying connection is closed when aborting after receiving response - no-cors"); + +for (const bodyMethod of BODY_METHODS) { + promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + + const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + const bodyPromise = response[bodyMethod](); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", bodyPromise); + + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } + }, `Fetch aborted & connection closed when aborted after calling response.${bodyMethod}()`); +} + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + const reader = response.body.getReader(); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", reader.read()); + await promise_rejects_dom(t, "AbortError", reader.closed); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Stream errors once aborted. Underlying connection closed."); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + const reader = response.body.getReader(); + + await reader.read(); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", reader.read()); + await promise_rejects_dom(t, "AbortError", reader.closed); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Stream errors once aborted, after reading. Underlying connection closed."); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + + const response = await fetch(`../resources/empty.txt`, { signal }); + + // Read whole response to ensure close signal has sent. + await response.clone().text(); + + const reader = response.body.getReader(); + + controller.abort(); + + const item = await reader.read(); + + assert_true(item.done, "Stream is done"); +}, "Stream will not error if body is empty. It's closed with an empty queue before it errors."); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + let cancelReason; + + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([42])); + }, + cancel(reason) { + cancelReason = reason; + } + }); + + const fetchPromise = fetch('../resources/empty.txt', { + body, signal, + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + } + }); + + assert_true(!!cancelReason, 'Cancel called sync'); + assert_equals(cancelReason.constructor, DOMException); + assert_equals(cancelReason.name, 'AbortError'); + + await promise_rejects_dom(t, "AbortError", fetchPromise); + + const fetchErr = await fetchPromise.catch(e => e); + + assert_equals(cancelReason, fetchErr, "Fetch rejects with same error instance"); +}, "Readable stream synchronously cancels with AbortError if aborted before reading"); + +test(() => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('.', { signal }); + const requestSignal = request.signal; + + const clonedRequest = request.clone(); + + assert_equals(requestSignal, request.signal, "Original request signal the same after cloning"); + assert_true(request.signal.aborted, "Original request signal aborted"); + assert_not_equals(clonedRequest.signal, request.signal, "Cloned request has different signal"); + assert_true(clonedRequest.signal.aborted, "Cloned request signal aborted"); +}, "Signal state is cloned"); + +test(() => { + const controller = new AbortController(); + const signal = controller.signal; + + const request = new Request('.', { signal }); + const clonedRequest = request.clone(); + + const log = []; + + request.signal.addEventListener('abort', () => log.push('original-aborted')); + clonedRequest.signal.addEventListener('abort', () => log.push('clone-aborted')); + + controller.abort(); + + assert_array_equals(log, ['clone-aborted', 'original-aborted'], "Abort events fired in correct order"); + assert_true(request.signal.aborted, 'Signal aborted'); + assert_true(clonedRequest.signal.aborted, 'Signal aborted'); +}, "Clone aborts with original controller"); diff --git a/test/wpt/runner/fetch/api/body/mime-type.any.js b/test/wpt/tests/fetch/api/body/mime-type.any.js similarity index 100% rename from test/wpt/runner/fetch/api/body/mime-type.any.js rename to test/wpt/tests/fetch/api/body/mime-type.any.js diff --git a/test/wpt/tests/fetch/api/request/request-error.js b/test/wpt/tests/fetch/api/request/request-error.js new file mode 100644 index 00000000000..cf77313f5bc --- /dev/null +++ b/test/wpt/tests/fetch/api/request/request-error.js @@ -0,0 +1,57 @@ +const badRequestArgTests = [ + { + args: ["", { "window": "http://test.url" }], + testName: "RequestInit's window is not null" + }, + { + args: ["http://:not a valid URL"], + testName: "Input URL is not valid" + }, + { + args: ["http://user:pass@test.url"], + testName: "Input URL has credentials" + }, + { + args: ["", { "mode": "navigate" }], + testName: "RequestInit's mode is navigate" + }, + { + args: ["", { "referrer": "http://:not a valid URL" }], + testName: "RequestInit's referrer is invalid" + }, + { + args: ["", { "method": "IN VALID" }], + testName: "RequestInit's method is invalid" + }, + { + args: ["", { "method": "TRACE" }], + testName: "RequestInit's method is forbidden" + }, + { + args: ["", { "mode": "no-cors", "method": "PUT" }], + testName: "RequestInit's mode is no-cors and method is not simple" + }, + { + args: ["", { "mode": "cors", "cache": "only-if-cached" }], + testName: "RequestInit's cache mode is only-if-cached and mode is not same-origin" + }, + { + args: ["test", { "cache": "only-if-cached", "mode": "cors" }], + testName: "Request with cache mode: only-if-cached and fetch mode cors" + }, + { + args: ["test", { "cache": "only-if-cached", "mode": "no-cors" }], + testName: "Request with cache mode: only-if-cached and fetch mode no-cors" + } +]; + +badRequestArgTests.push( + ...["referrerPolicy", "mode", "credentials", "cache", "redirect"].map(optionProp => { + const options = {}; + options[optionProp] = "BAD"; + return { + args: ["", options], + testName: `Bad ${optionProp} init parameter value` + }; + }) +);