diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js index bdfc0d94a43..694d268f5ac 100644 --- a/lib/handler/cache-handler.js +++ b/lib/handler/cache-handler.js @@ -1,794 +1,802 @@ -'use strict' - -const util = require('../core/util') -const { - parseCacheControlHeader, - hasInvalidCacheControlDirective, - parseVaryHeader, - hasVaryStar, - isInvalidOrWildcardVaryHeader, - isEtagUsable -} = require('../util/cache') -const { parseHttpDate } = require('../util/date.js') - -function noop () {} - -// Status codes that we can use some heuristics on to cache -const HEURISTICALLY_CACHEABLE_STATUS_CODES = [ - 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 -] - -// Status codes which semantic is not handled by the cache -// https://datatracker.ietf.org/doc/html/rfc9111#section-3 -// This list should not grow beyond 206 unless the RFC is updated -// by a newer one including more. Please introduce another list if -// implementing caching of responses with the 'must-understand' directive. -const NOT_UNDERSTOOD_STATUS_CODES = [ - 206 -] - -const MAX_RESPONSE_AGE = 2147483647000 - -// Retention for revalidation-only entries (zero freshness lifetime but a -// validator present); each successful revalidation re-stores the entry. -const REVALIDATION_ONLY_RETENTION = 86400000 // 24 hours - -function trimOWS (value) { - return value.replace(/^[\t ]+|[\t ]+$/g, '') -} - -function arrayIncludes (array, value) { - for (let i = 0; i < array.length; i++) { - if (array[i] === value) { - return true - } - } - - return false -} - -function appendConnectionHeaderTokens (headersToRemove, connectionHeader) { - const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader] - - for (let i = 0; i < values.length; i++) { - const tokens = values[i].split(',') - for (let j = 0; j < tokens.length; j++) { - headersToRemove.push(trimOWS(tokens[j]).toLowerCase()) - } - } -} - -function getSameOriginPath (cacheKey, location) { - if (typeof location !== 'string') { - return undefined - } - - let originUrl - let requestUrl - let locationUrl - try { - originUrl = new URL(cacheKey.origin) - requestUrl = new URL(cacheKey.path, originUrl) - locationUrl = new URL(location, requestUrl) - } catch { - return undefined - } - - if (locationUrl.origin !== originUrl.origin) { - return undefined - } - - return locationUrl.pathname + locationUrl.search -} - -function deleteCachedUri (store, cacheKey, path) { - deleteCachedValue(store, { - ...cacheKey, - path - }) - - for (let i = 0; i < util.safeHTTPMethods.length; i++) { - const method = util.safeHTTPMethods[i] - if (method !== cacheKey.method) { - deleteCachedValue(store, { - ...cacheKey, - method, - path - }) - } - } -} - -function deleteLocationTargets (store, cacheKey, headerValue) { - if (headerValue === undefined) { - return - } - - const values = Array.isArray(headerValue) ? headerValue : [headerValue] - for (let i = 0; i < values.length; i++) { - const path = getSameOriginPath(cacheKey, values[i]) - if (path !== undefined) { - deleteCachedUri(store, cacheKey, path) - } - } -} - -function invalidateUnsafeRequest (store, cacheKey, resHeaders) { - deleteCachedUri(store, cacheKey, cacheKey.path) - deleteLocationTargets(store, cacheKey, resHeaders.location) - deleteLocationTargets(store, cacheKey, resHeaders['content-location']) -} -/** - * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler - * - * @implements {DispatchHandler} - */ -class CacheHandler { - /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} - */ - #cacheKey - - /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']} - */ - #cacheType - - /** - * @type {number | undefined} - */ - #cacheByDefault - - /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} - */ - #store - - /** - * @type {import('../../types/dispatcher.d.ts').default.DispatchHandler} - */ - #handler - - /** - * @type {import('node:stream').Writable | undefined} - */ - #writeStream - - /** - * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts - * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey - * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler - */ - constructor ({ store, type, cacheByDefault }, cacheKey, handler) { - this.#store = store - this.#cacheType = type - this.#cacheByDefault = cacheByDefault - this.#cacheKey = cacheKey - this.#handler = handler - } - - onRequestStart (controller, context) { - this.#writeStream?.destroy() - this.#writeStream = undefined - this.#handler.onRequestStart?.(controller, context) - } - - onRequestUpgrade (controller, statusCode, headers, socket) { - this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket) - } - - /** - * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller - * @param {number} statusCode - * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders - * @param {string} statusMessage - */ - onResponseStart ( - controller, - statusCode, - resHeaders, - statusMessage - ) { - const downstreamOnHeaders = () => - this.#handler.onResponseStart?.( - controller, - statusCode, - resHeaders, - statusMessage - ) - const handler = this - - if ( - !arrayIncludes(util.safeHTTPMethods, this.#cacheKey.method) && - statusCode >= 200 && - statusCode <= 399 - ) { - // Successful response to an unsafe method, delete it from cache - // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response - invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders) - return downstreamOnHeaders() - } - - const cacheControlHeader = resHeaders['cache-control'] - const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) - if ( - !cacheControlHeader && - !resHeaders['expires'] && - !heuristicallyCacheable && - !this.#cacheByDefault - ) { - if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) { - deleteCachedValue(this.#store, this.#cacheKey) - } - - // Don't have anything to tell us this response is cachable and we're not - // caching by default - return downstreamOnHeaders() - } - - const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} - if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) { - if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) { - deleteCachedValue(this.#store, this.#cacheKey) - } - - return downstreamOnHeaders() - } - - const now = Date.now() - const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined - if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) { - // Response considered stale - deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) - return downstreamOnHeaders() - } - - const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined - if (resDate === null) { - deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) - return downstreamOnHeaders() - } - - const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0 - const currentAge = Math.max(apparentAge, resAge ?? 0) - - const hasValidator = - (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) || - typeof resHeaders['last-modified'] === 'string' - - const staleAt = - determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives, hasValidator) ?? - this.#cacheByDefault - // Zero freshness lifetime but a validator: stale from the start, yet still - // storable since each reuse is preceded by a revalidation request. - // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 - const revalidationOnly = staleAt === 0 && hasValidator - if (staleAt === undefined || (currentAge >= staleAt && !revalidationOnly)) { - if (cacheControlHeader || staleAt !== undefined) { - deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) - } - - return downstreamOnHeaders() - } - - const baseTime = now - currentAge - const absoluteStaleAt = staleAt + baseTime - if (now >= absoluteStaleAt && !revalidationOnly) { - // Response is already stale - deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) - return downstreamOnHeaders() - } - - let varyDirectives - if (this.#cacheKey.headers && resHeaders.vary) { - varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers) - if (!varyDirectives) { - // Parse error - return downstreamOnHeaders() - } - } - - const cachedAt = baseTime - const deleteAt = determineDeleteAt(baseTime, now, cacheControlDirectives, absoluteStaleAt) - const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives) - - /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} - */ - const value = { - statusCode, - statusMessage, - headers: strippedHeaders, - vary: varyDirectives, - cacheControlDirectives, - cachedAt, - staleAt: absoluteStaleAt, - deleteAt - } - - // Not modified, re-use the cached value - // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified - if (statusCode === 304) { - const handle304 = (cachedValue) => { - if (!cachedValue) { - // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached. - return downstreamOnHeaders() - } - - // Re-use the cached value: statuscode, statusmessage, headers and body - value.statusCode = cachedValue.statusCode - value.statusMessage = cachedValue.statusMessage - value.etag = cachedValue.etag - value.vary = varyDirectives ?? cachedValue.vary - value.headers = { ...cachedValue.headers, ...strippedHeaders } - - downstreamOnHeaders() - - this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) - - if (!this.#writeStream || !cachedValue?.body) { - return - } - - if (typeof cachedValue.body.values === 'function') { - const bodyIterator = cachedValue.body.values() - - const streamCachedBody = () => { - for (const chunk of bodyIterator) { - const full = this.#writeStream.write(chunk) === false - this.#handler.onResponseData?.(controller, chunk) - // when stream is full stop writing until we get a 'drain' event - if (full) { - break - } - } - } - - this.#writeStream - .on('error', function () { - handler.#writeStream = undefined - handler.#store.delete(handler.#cacheKey) - }) - .on('drain', () => { - streamCachedBody() - }) - .on('close', function () { - if (handler.#writeStream === this) { - handler.#writeStream = undefined - } - }) - - streamCachedBody() - } else if (typeof cachedValue.body.on === 'function') { - // Readable stream body (e.g. from async/remote cache stores) - cachedValue.body - .on('data', (chunk) => { - this.#writeStream.write(chunk) - this.#handler.onResponseData?.(controller, chunk) - }) - .on('end', () => { - this.#writeStream.end() - }) - .on('error', () => { - this.#writeStream = undefined - this.#store.delete(this.#cacheKey) - }) - - this.#writeStream - .on('error', function () { - handler.#writeStream = undefined - handler.#store.delete(handler.#cacheKey) - }) - .on('close', function () { - if (handler.#writeStream === this) { - handler.#writeStream = undefined - } - }) - } - } - - /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} - */ - const result = this.#store.get(this.#cacheKey) - if (result && typeof result.then === 'function') { - result.then(handle304) - } else { - handle304(result) - } - } else { - if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) { - value.etag = resHeaders.etag - } - - this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) - - if (!this.#writeStream) { - return downstreamOnHeaders() - } - - this.#writeStream - .on('drain', () => controller.resume()) - .on('error', function () { - // TODO (fix): Make error somehow observable? - handler.#writeStream = undefined - - // Delete the value in case the cache store is holding onto state from - // the call to createWriteStream - handler.#store.delete(handler.#cacheKey) - }) - .on('close', function () { - if (handler.#writeStream === this) { - handler.#writeStream = undefined - } - - // TODO (fix): Should we resume even if was paused downstream? - controller.resume() - }) - - downstreamOnHeaders() - } - } - - onResponseData (controller, chunk) { - if (this.#writeStream?.write(chunk) === false) { - controller.pause() - } - - this.#handler.onResponseData?.(controller, chunk) - } - - onResponseEnd (controller, trailers) { - this.#writeStream?.end() - this.#handler.onResponseEnd?.(controller, trailers) - } - - onResponseError (controller, err) { - this.#writeStream?.destroy(err) - this.#writeStream = undefined - this.#handler.onResponseError?.(controller, err) - } -} - -/** - * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store - * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey - */ -function deleteCachedValue (store, cacheKey) { - try { - store.delete(cacheKey)?.catch?.(noop) - } catch { - // Fail silently - } -} - -function deleteCachedValueIfNotModified (statusCode, store, cacheKey) { - if (statusCode === 304) { - deleteCachedValue(store, cacheKey) - } -} - -/** - * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType - * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders - * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives - * @returns {boolean} - */ -function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) { - return cacheControlDirectives['no-store'] === true || - (cacheType === 'shared' && cacheControlDirectives.private === true) || - (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false) -} - -/** - * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen - * - * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType - * @param {number} statusCode - * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders - * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives - * @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders] - */ -function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) { - // Status code must be final and understood. - if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) { - return false - } - // Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching - // directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3 - if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] && - !cacheControlDirectives.public && - cacheControlDirectives['max-age'] === undefined && - // RFC 9111: a private response directive, if the cache is not shared - !(cacheControlDirectives.private && cacheType === 'private') && - !(cacheControlDirectives['s-maxage'] !== undefined && cacheType === 'shared') - ) { - return false - } - - if (cacheControlDirectives['no-store']) { - return false - } - - if (cacheType === 'shared' && cacheControlDirectives.private === true) { - return false - } - - // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 - if (resHeaders.vary && hasVaryStar(resHeaders.vary)) { - return false - } - - // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen - if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) { - if ( - !cacheControlDirectives.public && - !cacheControlDirectives['s-maxage'] && - !cacheControlDirectives['must-revalidate'] - ) { - return false - } - - if (typeof reqHeaders.authorization !== 'string') { - return false - } - - if ( - Array.isArray(cacheControlDirectives['no-cache']) && - arrayIncludes(cacheControlDirectives['no-cache'], 'authorization') - ) { - return false - } - - if ( - Array.isArray(cacheControlDirectives['private']) && - arrayIncludes(cacheControlDirectives['private'], 'authorization') - ) { - return false - } - } - - return true -} - -/** - * @param {string | string[]} dateHeader - * @returns {Date | null | undefined} - */ -function getDate (dateHeader) { - let dateValue = dateHeader - if (Array.isArray(dateValue)) { - if (dateValue.length !== 1) { - return null - } - - dateValue = dateValue[0] - } - - if (typeof dateValue !== 'string') { - return null - } - - return parseHttpDate(dateValue) -} - -/** - * @param {string | string[]} ageHeader - * @returns {number | undefined} - */ -function getAge (ageHeader) { - let ageValue = ageHeader - if (Array.isArray(ageValue)) { - if (ageValue.length !== 1) { - return MAX_RESPONSE_AGE - } - - ageValue = ageValue[0] - } - - if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) { - return MAX_RESPONSE_AGE - } - - const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, '')) - if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) { - return MAX_RESPONSE_AGE - } - - return Number(age) * 1000 -} - -/** - * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType - * @param {number} now - * @param {number | undefined} age - * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders - * @param {Date | undefined} responseDate - * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives - * @param {boolean} hasValidator whether the response has a validator (etag or - * last-modified) that revalidation requests can be made with - * - * @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached - */ -function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives, hasValidator) { - if (cacheType === 'shared') { - // Prioritize s-maxage since we're a shared cache - // s-maxage > max-age > Expire - // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 - if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) { - return 0 - } - - const sMaxAge = cacheControlDirectives['s-maxage'] - if (sMaxAge !== undefined) { - if (sMaxAge > 0) { - return sMaxAge * 1000 - } - - // Immediately stale, but storable if we can revalidate it before reuse. - return 0 - } - } - - if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) { - return 0 - } - - const maxAge = cacheControlDirectives['max-age'] - if (maxAge !== undefined) { - if (maxAge > 0) { - return maxAge * 1000 - } - - // Immediately stale, but storable if we can revalidate it before reuse. - return 0 - } - - if (Object.hasOwn(resHeaders, 'expires')) { - // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 - if (typeof resHeaders.expires !== 'string') { - return 0 - } - - const expiresDate = parseHttpDate(resHeaders.expires) - if (!expiresDate) { - return 0 - } - - if (now >= expiresDate.getTime()) { - return 0 - } - - if (responseDate) { - if (responseDate >= expiresDate) { - return 0 - } - - const freshnessLifetime = expiresDate.getTime() - responseDate.getTime() - if (age !== undefined && age >= freshnessLifetime) { - return 0 - } - - return freshnessLifetime - } - - return expiresDate.getTime() - now - } - - if (typeof resHeaders['last-modified'] === 'string') { - // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh - const lastModified = parseHttpDate(resHeaders['last-modified']) - if (lastModified) { - if (lastModified.getTime() >= now) { - return undefined - } - - const responseAge = now - lastModified.getTime() - - return responseAge * 0.1 - } - } - - if (cacheControlDirectives.immutable) { - // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 - return 31536000000 - } - - if (cacheControlDirectives['no-cache'] === true && hasValidator) { - // No freshness source, but a validator lets us revalidate before reuse. - // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 - return 0 - } - - return undefined -} - -/** - * @param {number} baseTime - * @param {number} cachedAt - * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives - * @param {number} staleAt - */ -function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) { - let staleWhileRevalidate = -Infinity - let staleIfError = -Infinity - let immutable = -Infinity - - if (cacheControlDirectives['stale-while-revalidate']) { - staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000) - } - - if (cacheControlDirectives['stale-if-error']) { - staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000) - } - - if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { - immutable = cachedAt + 31536000000 - } - - // When no stale directives or immutable flag, add a revalidation buffer - // equal to the freshness lifetime so the entry survives past staleAt long - // enough to be revalidated instead of silently disappearing. - // - // Response Date headers only have second precision, so baseTime can trail the - // actual cache insertion time by up to ~1s. Pad the buffer by that bounded - // skew so short-lived entries do not disappear exactly when they should be - // revalidated. - if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) { - const freshnessLifetime = staleAt - baseTime - if (freshnessLifetime <= 0) { - // Revalidation-only entry: no freshness lifetime to size the buffer on, - // so retain it for a bounded window instead. - return cachedAt + REVALIDATION_ONLY_RETENTION - } - const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000) - return staleAt + freshnessLifetime + datePrecisionPadding - } - - return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable) -} - -/** - * Strips headers required to be removed in cached responses - * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders - * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives - * @returns {Record} - */ -function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { - const headersToRemove = [ - 'connection', - 'proxy-authenticate', - 'proxy-authentication-info', - 'proxy-authorization', - 'proxy-connection', - 'te', - 'transfer-encoding', - 'upgrade', - // We'll add age back when serving it - 'age' - ] - - if (resHeaders['connection']) { - appendConnectionHeaderTokens(headersToRemove, resHeaders['connection']) - } - - if (Array.isArray(cacheControlDirectives['no-cache'])) { - headersToRemove.push(...cacheControlDirectives['no-cache']) - } - - if (Array.isArray(cacheControlDirectives['private'])) { - headersToRemove.push(...cacheControlDirectives['private']) - } - - let strippedHeaders - for (const headerName of headersToRemove) { - if (Object.hasOwn(resHeaders, headerName)) { - strippedHeaders ??= { ...resHeaders } - delete strippedHeaders[headerName] - } - } - - return strippedHeaders ?? resHeaders -} - -module.exports = CacheHandler +'use strict' + +const util = require('../core/util') +const { + parseCacheControlHeader, + hasInvalidCacheControlDirective, + parseVaryHeader, + hasVaryStar, + isInvalidOrWildcardVaryHeader, + isEtagUsable +} = require('../util/cache') +const { parseHttpDate } = require('../util/date.js') + +function noop () {} + +// Status codes that we can use some heuristics on to cache +const HEURISTICALLY_CACHEABLE_STATUS_CODES = [ + 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 +] + +// Status codes which semantic is not handled by the cache +// https://datatracker.ietf.org/doc/html/rfc9111#section-3 +// This list should not grow beyond 206 unless the RFC is updated +// by a newer one including more. Please introduce another list if +// implementing caching of responses with the 'must-understand' directive. +const NOT_UNDERSTOOD_STATUS_CODES = [ + 206 +] + +const MAX_RESPONSE_AGE = 2147483647000 + +// Retention for revalidation-only entries (zero freshness lifetime but a +// validator present); each successful revalidation re-stores the entry. +const REVALIDATION_ONLY_RETENTION = 86400000 // 24 hours + +function trimOWS (value) { + return value.replace(/^[\t ]+|[\t ]+$/g, '') +} + +function arrayIncludes (array, value) { + for (let i = 0; i < array.length; i++) { + if (array[i] === value) { + return true + } + } + + return false +} + +function appendConnectionHeaderTokens (headersToRemove, connectionHeader) { + const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader] + + for (let i = 0; i < values.length; i++) { + const tokens = values[i].split(',') + for (let j = 0; j < tokens.length; j++) { + headersToRemove.push(trimOWS(tokens[j]).toLowerCase()) + } + } +} + +function getSameOriginPath (cacheKey, location) { + if (typeof location !== 'string') { + return undefined + } + + let originUrl + let requestUrl + let locationUrl + try { + originUrl = new URL(cacheKey.origin) + requestUrl = new URL(cacheKey.path, originUrl) + locationUrl = new URL(location, requestUrl) + } catch { + return undefined + } + + if (locationUrl.origin !== originUrl.origin) { + return undefined + } + + return locationUrl.pathname + locationUrl.search +} + +function deleteCachedUri (store, cacheKey, path) { + deleteCachedValue(store, { + ...cacheKey, + path + }) + + for (let i = 0; i < util.safeHTTPMethods.length; i++) { + const method = util.safeHTTPMethods[i] + if (method !== cacheKey.method) { + deleteCachedValue(store, { + ...cacheKey, + method, + path + }) + } + } +} + +function deleteLocationTargets (store, cacheKey, headerValue) { + if (headerValue === undefined) { + return + } + + const values = Array.isArray(headerValue) ? headerValue : [headerValue] + for (let i = 0; i < values.length; i++) { + const path = getSameOriginPath(cacheKey, values[i]) + if (path !== undefined) { + deleteCachedUri(store, cacheKey, path) + } + } +} + +function invalidateUnsafeRequest (store, cacheKey, resHeaders) { + deleteCachedUri(store, cacheKey, cacheKey.path) + deleteLocationTargets(store, cacheKey, resHeaders.location) + deleteLocationTargets(store, cacheKey, resHeaders['content-location']) +} +/** + * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler + * + * @implements {DispatchHandler} + */ +class CacheHandler { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + #cacheKey + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']} + */ + #cacheType + + /** + * @type {number | undefined} + */ + #cacheByDefault + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} + */ + #store + + /** + * @type {import('../../types/dispatcher.d.ts').default.DispatchHandler} + */ + #handler + + /** + * @type {import('node:stream').Writable | undefined} + */ + #writeStream + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler + */ + constructor ({ store, type, cacheByDefault }, cacheKey, handler) { + this.#store = store + this.#cacheType = type + this.#cacheByDefault = cacheByDefault + this.#cacheKey = cacheKey + this.#handler = handler + } + + onRequestStart (controller, context) { + this.#writeStream?.destroy() + this.#writeStream = undefined + this.#handler.onRequestStart?.(controller, context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller + * @param {number} statusCode + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {string} statusMessage + */ + onResponseStart ( + controller, + statusCode, + resHeaders, + statusMessage + ) { + const downstreamOnHeaders = () => + this.#handler.onResponseStart?.( + controller, + statusCode, + resHeaders, + statusMessage + ) + const handler = this + + if ( + !arrayIncludes(util.safeHTTPMethods, this.#cacheKey.method) && + statusCode >= 200 && + statusCode <= 399 + ) { + // Successful response to an unsafe method, delete it from cache + // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response + invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders) + return downstreamOnHeaders() + } + + const cacheControlHeader = resHeaders['cache-control'] + const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) + if ( + !cacheControlHeader && + !resHeaders['expires'] && + !heuristicallyCacheable && + !this.#cacheByDefault + ) { + if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) { + deleteCachedValue(this.#store, this.#cacheKey) + } + + // Don't have anything to tell us this response is cachable and we're not + // caching by default + return downstreamOnHeaders() + } + + const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} + if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) { + if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) { + deleteCachedValue(this.#store, this.#cacheKey) + } + + return downstreamOnHeaders() + } + + const now = Date.now() + const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined + if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) { + // Response considered stale + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + return downstreamOnHeaders() + } + + const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined + if (resDate === null) { + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + return downstreamOnHeaders() + } + + const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0 + const currentAge = Math.max(apparentAge, resAge ?? 0) + + const hasValidator = + (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) || + typeof resHeaders['last-modified'] === 'string' + + const staleAt = + determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives, hasValidator) ?? + this.#cacheByDefault + // Zero freshness lifetime but a validator: stale from the start, yet still + // storable since each reuse is preceded by a revalidation request. + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 + const revalidationOnly = staleAt === 0 && hasValidator + if (staleAt === undefined || (currentAge >= staleAt && !revalidationOnly)) { + if (cacheControlHeader || staleAt !== undefined) { + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + } + + return downstreamOnHeaders() + } + + const baseTime = now - currentAge + const absoluteStaleAt = staleAt + baseTime + if (now >= absoluteStaleAt && !revalidationOnly) { + // Response is already stale + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + return downstreamOnHeaders() + } + + let varyDirectives + if (this.#cacheKey.headers && resHeaders.vary) { + varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers) + if (!varyDirectives) { + // Parse error + return downstreamOnHeaders() + } + } + + const cachedAt = baseTime + const deleteAt = determineDeleteAt(baseTime, now, cacheControlDirectives, absoluteStaleAt) + const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives) + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode, + statusMessage, + headers: strippedHeaders, + vary: varyDirectives, + cacheControlDirectives, + cachedAt, + staleAt: absoluteStaleAt, + deleteAt + } + + // Not modified, re-use the cached value + // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified + if (statusCode === 304) { + const handle304 = (cachedValue) => { + if (!cachedValue) { + // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached. + return downstreamOnHeaders() + } + + // Re-use the cached value: statuscode, statusmessage, headers and body + value.statusCode = cachedValue.statusCode + value.statusMessage = cachedValue.statusMessage + value.etag = cachedValue.etag + value.vary = varyDirectives ?? cachedValue.vary + value.headers = { ...cachedValue.headers, ...strippedHeaders } + + downstreamOnHeaders() + + this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) + + if (!this.#writeStream || !cachedValue?.body) { + return + } + + if (typeof cachedValue.body.values === 'function') { + const bodyIterator = cachedValue.body.values() + + const streamCachedBody = () => { + for (const chunk of bodyIterator) { + const full = this.#writeStream.write(chunk) === false + this.#handler.onResponseData?.(controller, chunk) + // when stream is full stop writing until we get a 'drain' event + if (full) { + break + } + } + } + + this.#writeStream + .on('error', function () { + handler.#writeStream = undefined + handler.#store.delete(handler.#cacheKey) + }) + .on('drain', () => { + streamCachedBody() + }) + .on('close', function () { + if (handler.#writeStream === this) { + handler.#writeStream = undefined + } + }) + + streamCachedBody() + } else if (typeof cachedValue.body.on === 'function') { + // Readable stream body (e.g. from async/remote cache stores) + cachedValue.body + .on('data', (chunk) => { + this.#writeStream.write(chunk) + this.#handler.onResponseData?.(controller, chunk) + }) + .on('end', () => { + this.#writeStream.end() + }) + .on('error', () => { + this.#writeStream = undefined + this.#store.delete(this.#cacheKey) + }) + + this.#writeStream + .on('error', function () { + handler.#writeStream = undefined + handler.#store.delete(handler.#cacheKey) + }) + .on('close', function () { + if (handler.#writeStream === this) { + handler.#writeStream = undefined + } + }) + } + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const result = this.#store.get(this.#cacheKey) + if (result && typeof result.then === 'function') { + result.then(handle304) + } else { + handle304(result) + } + } else { + if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) { + value.etag = resHeaders.etag + } + + this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) + + if (!this.#writeStream) { + return downstreamOnHeaders() + } + + this.#writeStream + .on('drain', () => controller.resume()) + .on('error', function () { + // TODO (fix): Make error somehow observable? + handler.#writeStream = undefined + + // Delete the value in case the cache store is holding onto state from + // the call to createWriteStream + handler.#store.delete(handler.#cacheKey) + }) + .on('close', function () { + if (handler.#writeStream === this) { + handler.#writeStream = undefined + } + + // TODO (fix): Should we resume even if was paused downstream? + controller.resume() + }) + + downstreamOnHeaders() + } + } + + onResponseData (controller, chunk) { + if (this.#writeStream?.write(chunk) === false) { + controller.pause() + } + + this.#handler.onResponseData?.(controller, chunk) + } + + onResponseEnd (controller, trailers) { + this.#writeStream?.end() + this.#handler.onResponseEnd?.(controller, trailers) + } + + onBodySent (...args) { + this.#handler.onBodySent?.(...args) + } + + onRequestSent (...args) { + this.#handler.onRequestSent?.(...args) + } + + onResponseError (controller, err) { + this.#writeStream?.destroy(err) + this.#writeStream = undefined + this.#handler.onResponseError?.(controller, err) + } +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey + */ +function deleteCachedValue (store, cacheKey) { + try { + store.delete(cacheKey)?.catch?.(noop) + } catch { + // Fail silently + } +} + +function deleteCachedValueIfNotModified (statusCode, store, cacheKey) { + if (statusCode === 304) { + deleteCachedValue(store, cacheKey) + } +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @returns {boolean} + */ +function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) { + return cacheControlDirectives['no-store'] === true || + (cacheType === 'shared' && cacheControlDirectives.private === true) || + (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false) +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + * + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {number} statusCode + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders] + */ +function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) { + // Status code must be final and understood. + if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) { + return false + } + // Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching + // directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3 + if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] && + !cacheControlDirectives.public && + cacheControlDirectives['max-age'] === undefined && + // RFC 9111: a private response directive, if the cache is not shared + !(cacheControlDirectives.private && cacheType === 'private') && + !(cacheControlDirectives['s-maxage'] !== undefined && cacheType === 'shared') + ) { + return false + } + + if (cacheControlDirectives['no-store']) { + return false + } + + if (cacheType === 'shared' && cacheControlDirectives.private === true) { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 + if (resHeaders.vary && hasVaryStar(resHeaders.vary)) { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) { + if ( + !cacheControlDirectives.public && + !cacheControlDirectives['s-maxage'] && + !cacheControlDirectives['must-revalidate'] + ) { + return false + } + + if (typeof reqHeaders.authorization !== 'string') { + return false + } + + if ( + Array.isArray(cacheControlDirectives['no-cache']) && + arrayIncludes(cacheControlDirectives['no-cache'], 'authorization') + ) { + return false + } + + if ( + Array.isArray(cacheControlDirectives['private']) && + arrayIncludes(cacheControlDirectives['private'], 'authorization') + ) { + return false + } + } + + return true +} + +/** + * @param {string | string[]} dateHeader + * @returns {Date | null | undefined} + */ +function getDate (dateHeader) { + let dateValue = dateHeader + if (Array.isArray(dateValue)) { + if (dateValue.length !== 1) { + return null + } + + dateValue = dateValue[0] + } + + if (typeof dateValue !== 'string') { + return null + } + + return parseHttpDate(dateValue) +} + +/** + * @param {string | string[]} ageHeader + * @returns {number | undefined} + */ +function getAge (ageHeader) { + let ageValue = ageHeader + if (Array.isArray(ageValue)) { + if (ageValue.length !== 1) { + return MAX_RESPONSE_AGE + } + + ageValue = ageValue[0] + } + + if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) { + return MAX_RESPONSE_AGE + } + + const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, '')) + if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) { + return MAX_RESPONSE_AGE + } + + return Number(age) * 1000 +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {number} now + * @param {number | undefined} age + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {Date | undefined} responseDate + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @param {boolean} hasValidator whether the response has a validator (etag or + * last-modified) that revalidation requests can be made with + * + * @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached + */ +function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives, hasValidator) { + if (cacheType === 'shared') { + // Prioritize s-maxage since we're a shared cache + // s-maxage > max-age > Expire + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 + if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) { + return 0 + } + + const sMaxAge = cacheControlDirectives['s-maxage'] + if (sMaxAge !== undefined) { + if (sMaxAge > 0) { + return sMaxAge * 1000 + } + + // Immediately stale, but storable if we can revalidate it before reuse. + return 0 + } + } + + if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) { + return 0 + } + + const maxAge = cacheControlDirectives['max-age'] + if (maxAge !== undefined) { + if (maxAge > 0) { + return maxAge * 1000 + } + + // Immediately stale, but storable if we can revalidate it before reuse. + return 0 + } + + if (Object.hasOwn(resHeaders, 'expires')) { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 + if (typeof resHeaders.expires !== 'string') { + return 0 + } + + const expiresDate = parseHttpDate(resHeaders.expires) + if (!expiresDate) { + return 0 + } + + if (now >= expiresDate.getTime()) { + return 0 + } + + if (responseDate) { + if (responseDate >= expiresDate) { + return 0 + } + + const freshnessLifetime = expiresDate.getTime() - responseDate.getTime() + if (age !== undefined && age >= freshnessLifetime) { + return 0 + } + + return freshnessLifetime + } + + return expiresDate.getTime() - now + } + + if (typeof resHeaders['last-modified'] === 'string') { + // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh + const lastModified = parseHttpDate(resHeaders['last-modified']) + if (lastModified) { + if (lastModified.getTime() >= now) { + return undefined + } + + const responseAge = now - lastModified.getTime() + + return responseAge * 0.1 + } + } + + if (cacheControlDirectives.immutable) { + // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 + return 31536000000 + } + + if (cacheControlDirectives['no-cache'] === true && hasValidator) { + // No freshness source, but a validator lets us revalidate before reuse. + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 + return 0 + } + + return undefined +} + +/** + * @param {number} baseTime + * @param {number} cachedAt + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @param {number} staleAt + */ +function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) { + let staleWhileRevalidate = -Infinity + let staleIfError = -Infinity + let immutable = -Infinity + + if (cacheControlDirectives['stale-while-revalidate']) { + staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000) + } + + if (cacheControlDirectives['stale-if-error']) { + staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000) + } + + if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { + immutable = cachedAt + 31536000000 + } + + // When no stale directives or immutable flag, add a revalidation buffer + // equal to the freshness lifetime so the entry survives past staleAt long + // enough to be revalidated instead of silently disappearing. + // + // Response Date headers only have second precision, so baseTime can trail the + // actual cache insertion time by up to ~1s. Pad the buffer by that bounded + // skew so short-lived entries do not disappear exactly when they should be + // revalidated. + if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) { + const freshnessLifetime = staleAt - baseTime + if (freshnessLifetime <= 0) { + // Revalidation-only entry: no freshness lifetime to size the buffer on, + // so retain it for a bounded window instead. + return cachedAt + REVALIDATION_ONLY_RETENTION + } + const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000) + return staleAt + freshnessLifetime + datePrecisionPadding + } + + return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable) +} + +/** + * Strips headers required to be removed in cached responses + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @returns {Record} + */ +function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { + const headersToRemove = [ + 'connection', + 'proxy-authenticate', + 'proxy-authentication-info', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'transfer-encoding', + 'upgrade', + // We'll add age back when serving it + 'age' + ] + + if (resHeaders['connection']) { + appendConnectionHeaderTokens(headersToRemove, resHeaders['connection']) + } + + if (Array.isArray(cacheControlDirectives['no-cache'])) { + headersToRemove.push(...cacheControlDirectives['no-cache']) + } + + if (Array.isArray(cacheControlDirectives['private'])) { + headersToRemove.push(...cacheControlDirectives['private']) + } + + let strippedHeaders + for (const headerName of headersToRemove) { + if (Object.hasOwn(resHeaders, headerName)) { + strippedHeaders ??= { ...resHeaders } + delete strippedHeaders[headerName] + } + } + + return strippedHeaders ?? resHeaders +} + +module.exports = CacheHandler diff --git a/lib/handler/decorator-handler.js b/lib/handler/decorator-handler.js index 1b53c711324..c3b0bf6c0d5 100644 --- a/lib/handler/decorator-handler.js +++ b/lib/handler/decorator-handler.js @@ -1,66 +1,75 @@ -'use strict' - -const assert = require('node:assert') - -/** - * @deprecated - */ -module.exports = class DecoratorHandler { - #handler - #onCompleteCalled = false - #onErrorCalled = false - #onResponseStartCalled = false - - constructor (handler) { - if (typeof handler !== 'object' || handler === null) { - throw new TypeError('handler must be an object') - } - this.#handler = handler - } - - onRequestStart (...args) { - this.#handler.onRequestStart?.(...args) - } - - onRequestUpgrade (...args) { - assert(!this.#onCompleteCalled) - assert(!this.#onErrorCalled) - - return this.#handler.onRequestUpgrade?.(...args) - } - - onResponseStart (...args) { - assert(!this.#onCompleteCalled) - assert(!this.#onErrorCalled) - assert(!this.#onResponseStartCalled) - - this.#onResponseStartCalled = true - - return this.#handler.onResponseStart?.(...args) - } - - onResponseData (...args) { - assert(!this.#onCompleteCalled) - assert(!this.#onErrorCalled) - - return this.#handler.onResponseData?.(...args) - } - - onResponseEnd (...args) { - assert(!this.#onCompleteCalled) - assert(!this.#onErrorCalled) - - this.#onCompleteCalled = true - return this.#handler.onResponseEnd?.(...args) - } - - onResponseError (...args) { - this.#onErrorCalled = true - return this.#handler.onResponseError?.(...args) - } - - /** - * @deprecated - */ - onBodySent () {} -} +'use strict' + +const assert = require('node:assert') + +/** + * @deprecated + */ +module.exports = class DecoratorHandler { + #handler + #onCompleteCalled = false + #onErrorCalled = false + #onResponseStartCalled = false + + constructor (handler) { + if (typeof handler !== 'object' || handler === null) { + throw new TypeError('handler must be an object') + } + this.#handler = handler + } + + onRequestStart (...args) { + this.#handler.onRequestStart?.(...args) + } + + onRequestUpgrade (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + + return this.#handler.onRequestUpgrade?.(...args) + } + + onResponseStart (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + assert(!this.#onResponseStartCalled) + + this.#onResponseStartCalled = true + + return this.#handler.onResponseStart?.(...args) + } + + onResponseData (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + + return this.#handler.onResponseData?.(...args) + } + + onResponseEnd (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + + this.#onCompleteCalled = true + return this.#handler.onResponseEnd?.(...args) + } + + onResponseError (...args) { + this.#onErrorCalled = true + return this.#handler.onResponseError?.(...args) + } + + /** + * @deprecated + */ + onBodySent (...args) { + return this.#handler.onBodySent?.(...args) + } + + /** + * @deprecated + */ + onRequestSent (...args) { + return this.#handler.onRequestSent?.(...args) + } +} diff --git a/test/decorator-handler.js b/test/decorator-handler.js index 06c2e83b011..3e6320a257b 100644 --- a/test/decorator-handler.js +++ b/test/decorator-handler.js @@ -1,406 +1,444 @@ -'use strict' - -const { tspl } = require('@matteo.collina/tspl') -const { describe, test } = require('node:test') -const DecoratorHandler = require('../lib/handler/decorator-handler') - -describe('DecoratorHandler', () => { - test('should throw if provided handler is not an object', t => { - t = tspl(t, { plan: 4 }) - t.throws( - () => new DecoratorHandler(null), - new TypeError('handler must be an object') - ) - t.throws( - () => new DecoratorHandler('string'), - new TypeError('handler must be an object') - ) - - t.throws( - () => new DecoratorHandler(null), - new TypeError('handler must be an object') - ) - t.throws( - () => new DecoratorHandler('string'), - new TypeError('handler must be an object') - ) - }) - - describe('wrap', () => { - const Handler = class { - #handler = null - constructor (handler) { - this.#handler = handler - } - - onRequestStart (controller, context) { - return this.#handler?.onRequestStart?.(controller, context) - } - - onResponseStart (controller, statusCode, headers, statusMessage) { - return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage) - } - - onRequestUpgrade (controller, statusCode, headers, socket) { - return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket) - } - - onResponseData (controller, data) { - return this.#handler?.onResponseData?.(controller, data) - } - - onResponseEnd (controller, trailers) { - return this.#handler?.onResponseEnd?.(controller, trailers) - } - - onResponseError (controller, err) { - return this.#handler?.onResponseError?.(controller, err) - } - } - const Controller = class { - #controller = null - constructor (controller) { - this.#controller = controller - } - - abort (reason) { - return this.#controller?.abort?.(reason) - } - - resume () { - return this.#controller?.resume?.() - } - - pause () { - return this.#controller?.pause?.() - } - } - - describe('#onRequestStart', () => { - test('should delegate onRequestStart-method', t => { - t = tspl(t, { plan: 3 }) - const handler = new Handler( - { - onRequestStart: (controller, ctx) => { - t.equal(typeof controller, 'object') - t.equal(typeof controller.abort, 'function') - t.equal(typeof ctx, 'object') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onRequestStart(new Controller(), {}) - }) - - test('should not throw if onRequestStart-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onRequestStart()) - }) - }) - - describe('#onResponseStart', () => { - test('should delegate onResponseStart-method', t => { - t = tspl(t, { plan: 4 }) - const handler = new Handler( - { - onResponseStart: (controller, statusCode, headers, statusMessage) => { - t.equal(statusCode, 200) - t.equal(headers['content-type'], 'application/json') - t.equal(typeof controller.resume, 'function') - t.equal(statusMessage, 'OK') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseStart(new Controller(), 200, { - 'content-type': 'application/json' - }, 'OK') - }) - - test('should not throw if onResponseStart-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, { - 'content-type': 'application/json' - })) - }) - }) - - describe('#onRequestUpgrade', () => { - test('should delegate onRequestUpgrade-method', t => { - t = tspl(t, { plan: 3 }) - const handler = new Handler( - { - onRequestUpgrade: (_controller, statusCode, headers, socket) => { - t.equal(statusCode, 301) - t.equal(headers['content-type'], 'application/json') - t.equal(typeof socket, 'object') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onRequestUpgrade(new Controller(), 301, { - 'content-type': 'application/json' - }, {}) - }) - - test('should not throw if onRequestUpgrade-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, { - 'content-type': 'application/json' - })) - }) - }) - - describe('#onResponseData', () => { - test('should delegate onResponseData-method', t => { - t = tspl(t, { plan: 1 }) - const handler = new Handler( - { - onResponseData: (_controller, chunk) => { - t.equal('chunk', chunk) - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseData(new Controller(), 'chunk') - }) - - test('should not throw if onResponseData-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk')) - }) - }) - - describe('#onResponseEnd', () => { - test('should delegate onResponseEnd-method', t => { - t = tspl(t, { plan: 1 }) - const handler = new Handler( - { - onResponseEnd: (_controller, trailers) => { - t.equal(trailers['x-trailer'], 'trailer') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }) - }) - - test('should not throw if onResponseEnd-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })) - }) - }) - - describe('#onResponseError', () => { - test('should delegate onResponseError-method', t => { - t = tspl(t, { plan: 1 }) - const handler = new Handler( - { - onResponseError: (_controller, err) => { - t.equal(err.message, 'Oops!') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseError(new Controller(), new Error('Oops!')) - }) - - test('should not throw if onResponseError-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onResponseError(new Controller(), new Error('Oops!'))) - }) - }) - }) - - describe('no-wrap', () => { - const Handler = class { - #handler = null - constructor (handler) { - this.#handler = handler - } - - onRequestStart (controller, context) { - return this.#handler?.onRequestStart?.(controller, context) - } - - onRequestUpgrade (controller, statusCode, headers, socket) { - return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket) - } - - onResponseStart (controller, statusCode, headers, statusMessage) { - return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage) - } - - onResponseData (controller, data) { - return this.#handler?.onResponseData?.(controller, data) - } - - onResponseEnd (controller, trailers) { - return this.#handler?.onResponseEnd?.(controller, trailers) - } - - onResponseError (controller, err) { - return this.#handler?.onResponseError?.(controller, err) - } - } - const Controller = class { - #controller = null - constructor (controller) { - this.#controller = controller - } - - abort (reason) { - return this.#controller?.abort?.(reason) - } - - resume () { - return this.#controller?.resume?.() - } - - pause () { - return this.#controller?.pause?.() - } - } - - describe('#onRequestStart', () => { - test('should delegate onRequestStart-method', t => { - t = tspl(t, { plan: 2 }) - const handler = new Handler( - { - onRequestStart: (controller, ctx) => { - t.equal(controller.constructor, Controller) - t.equal(typeof ctx, 'object') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onRequestStart(new Controller(), {}) - }) - - test('should not throw if onRequestStart-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onRequestStart()) - }) - }) - - describe('#onRequestUpgrade', () => { - test('should delegate onRequestUpgrade-method', t => { - t = tspl(t, { plan: 4 }) - const handler = new Handler( - { - onRequestUpgrade: (controller, statusCode, headers, socket) => { - t.equal(controller.constructor, Controller) - t.equal(statusCode, 301) - t.equal(headers['content-type'], 'application/json') - t.equal(typeof socket, 'object') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onRequestUpgrade(new Controller(), 301, { - 'content-type': 'application/json' - }, {}) - }) - - test('should not throw if onRequestUpgrade-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, { - 'content-type': 'application/json' - }, {})) - }) - }) - - describe('#onResponseStart', () => { - test('should delegate onResponseStart-method', t => { - t = tspl(t, { plan: 4 }) - const handler = new Handler( - { - onResponseStart: (controller, statusCode, headers, message) => { - t.equal(controller.constructor, Controller) - t.equal(statusCode, 200) - t.equal(headers['content-type'], 'application/json') - t.equal(message, 'OK') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseStart(new Controller(), 200, { - 'content-type': 'application/json' - }, 'OK') - }) - - test('should not throw if onResponseStart-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, { - 'content-type': 'application/json' - }, 'OK')) - }) - }) - - describe('#onResponseData', () => { - test('should delegate onResponseData-method', t => { - t = tspl(t, { plan: 2 }) - const handler = new Handler( - { - onResponseData: (controller, chunk) => { - t.equal(controller.constructor, Controller) - t.equal('chunk', chunk) - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseData(new Controller(), 'chunk') - }) - - test('should not throw if onResponseData-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk')) - }) - }) - - describe('#onResponseEnd', () => { - test('should delegate onResponseEnd-method', t => { - t = tspl(t, { plan: 2 }) - const handler = new Handler( - { - onResponseEnd: (controller, trailers) => { - t.equal(controller.constructor, Controller) - t.equal(trailers['x-trailer'], 'trailer') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }) - }) - - test('should not throw if onResponseEnd-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({}) - t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })) - }) - }) - - describe('#onResponseError', () => { - test('should delegate onResponseError-method', t => { - t = tspl(t, { plan: 2 }) - const handler = new Handler( - { - onResponseError: (controller, err) => { - t.equal(controller.constructor, Controller) - t.equal(err.message, 'Oops!') - } - }) - const decorator = new DecoratorHandler(handler) - decorator.onResponseError(new Controller(), new Error('Oops!')) - }) - - test('should throw if onResponseError-method is not defined in the handler', t => { - t = tspl(t, { plan: 1 }) - const decorator = new DecoratorHandler({ - // To hin and not wrap the instance - onRequestStart: () => {} - }) - t.doesNotThrow(() => decorator.onResponseError(new Controller())) - }) - }) - }) -}) +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { describe, test } = require('node:test') +const DecoratorHandler = require('../lib/handler/decorator-handler') + +describe('DecoratorHandler', () => { + test('should throw if provided handler is not an object', t => { + t = tspl(t, { plan: 4 }) + t.throws( + () => new DecoratorHandler(null), + new TypeError('handler must be an object') + ) + t.throws( + () => new DecoratorHandler('string'), + new TypeError('handler must be an object') + ) + + t.throws( + () => new DecoratorHandler(null), + new TypeError('handler must be an object') + ) + t.throws( + () => new DecoratorHandler('string'), + new TypeError('handler must be an object') + ) + }) + + describe('wrap', () => { + const Handler = class { + #handler = null + constructor (handler) { + this.#handler = handler + } + + onRequestStart (controller, context) { + return this.#handler?.onRequestStart?.(controller, context) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + onResponseData (controller, data) { + return this.#handler?.onResponseData?.(controller, data) + } + + onResponseEnd (controller, trailers) { + return this.#handler?.onResponseEnd?.(controller, trailers) + } + + onResponseError (controller, err) { + return this.#handler?.onResponseError?.(controller, err) + } + } + const Controller = class { + #controller = null + constructor (controller) { + this.#controller = controller + } + + abort (reason) { + return this.#controller?.abort?.(reason) + } + + resume () { + return this.#controller?.resume?.() + } + + pause () { + return this.#controller?.pause?.() + } + } + + describe('#onRequestStart', () => { + test('should delegate onRequestStart-method', t => { + t = tspl(t, { plan: 3 }) + const handler = new Handler( + { + onRequestStart: (controller, ctx) => { + t.equal(typeof controller, 'object') + t.equal(typeof controller.abort, 'function') + t.equal(typeof ctx, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestStart(new Controller(), {}) + }) + + test('should not throw if onRequestStart-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestStart()) + }) + }) + + describe('#onResponseStart', () => { + test('should delegate onResponseStart-method', t => { + t = tspl(t, { plan: 4 }) + const handler = new Handler( + { + onResponseStart: (controller, statusCode, headers, statusMessage) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.equal(typeof controller.resume, 'function') + t.equal(statusMessage, 'OK') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + }, 'OK') + }) + + test('should not throw if onResponseStart-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + })) + }) + }) + + describe('#onRequestUpgrade', () => { + test('should delegate onRequestUpgrade-method', t => { + t = tspl(t, { plan: 3 }) + const handler = new Handler( + { + onRequestUpgrade: (_controller, statusCode, headers, socket) => { + t.equal(statusCode, 301) + t.equal(headers['content-type'], 'application/json') + t.equal(typeof socket, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + }, {}) + }) + + test('should not throw if onRequestUpgrade-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + })) + }) + }) + + describe('#onResponseData', () => { + test('should delegate onResponseData-method', t => { + t = tspl(t, { plan: 1 }) + const handler = new Handler( + { + onResponseData: (_controller, chunk) => { + t.equal('chunk', chunk) + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseData(new Controller(), 'chunk') + }) + + test('should not throw if onResponseData-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk')) + }) + }) + + describe('#onResponseEnd', () => { + test('should delegate onResponseEnd-method', t => { + t = tspl(t, { plan: 1 }) + const handler = new Handler( + { + onResponseEnd: (_controller, trailers) => { + t.equal(trailers['x-trailer'], 'trailer') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }) + }) + + test('should not throw if onResponseEnd-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })) + }) + }) + + describe('#onResponseError', () => { + test('should delegate onResponseError-method', t => { + t = tspl(t, { plan: 1 }) + const handler = new Handler( + { + onResponseError: (_controller, err) => { + t.equal(err.message, 'Oops!') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseError(new Controller(), new Error('Oops!')) + }) + + test('should not throw if onResponseError-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseError(new Controller(), new Error('Oops!'))) + }) + }) + }) + + describe('no-wrap', () => { + const Handler = class { + #handler = null + constructor (handler) { + this.#handler = handler + } + + onRequestStart (controller, context) { + return this.#handler?.onRequestStart?.(controller, context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage) + } + + onResponseData (controller, data) { + return this.#handler?.onResponseData?.(controller, data) + } + + onResponseEnd (controller, trailers) { + return this.#handler?.onResponseEnd?.(controller, trailers) + } + + onResponseError (controller, err) { + return this.#handler?.onResponseError?.(controller, err) + } + } + const Controller = class { + #controller = null + constructor (controller) { + this.#controller = controller + } + + abort (reason) { + return this.#controller?.abort?.(reason) + } + + resume () { + return this.#controller?.resume?.() + } + + pause () { + return this.#controller?.pause?.() + } + } + + describe('#onRequestStart', () => { + test('should delegate onRequestStart-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onRequestStart: (controller, ctx) => { + t.equal(controller.constructor, Controller) + t.equal(typeof ctx, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestStart(new Controller(), {}) + }) + + test('should not throw if onRequestStart-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestStart()) + }) + }) + + describe('#onRequestUpgrade', () => { + test('should delegate onRequestUpgrade-method', t => { + t = tspl(t, { plan: 4 }) + const handler = new Handler( + { + onRequestUpgrade: (controller, statusCode, headers, socket) => { + t.equal(controller.constructor, Controller) + t.equal(statusCode, 301) + t.equal(headers['content-type'], 'application/json') + t.equal(typeof socket, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + }, {}) + }) + + test('should not throw if onRequestUpgrade-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + }, {})) + }) + }) + + describe('#onResponseStart', () => { + test('should delegate onResponseStart-method', t => { + t = tspl(t, { plan: 4 }) + const handler = new Handler( + { + onResponseStart: (controller, statusCode, headers, message) => { + t.equal(controller.constructor, Controller) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.equal(message, 'OK') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + }, 'OK') + }) + + test('should not throw if onResponseStart-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + }, 'OK')) + }) + }) + + describe('#onResponseData', () => { + test('should delegate onResponseData-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onResponseData: (controller, chunk) => { + t.equal(controller.constructor, Controller) + t.equal('chunk', chunk) + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseData(new Controller(), 'chunk') + }) + + test('should not throw if onResponseData-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk')) + }) + }) + + describe('#onResponseEnd', () => { + test('should delegate onResponseEnd-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onResponseEnd: (controller, trailers) => { + t.equal(controller.constructor, Controller) + t.equal(trailers['x-trailer'], 'trailer') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }) + }) + + test('should not throw if onResponseEnd-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })) + }) + }) + + describe('#onResponseError', () => { + test('should delegate onResponseError-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onResponseError: (controller, err) => { + t.equal(controller.constructor, Controller) + t.equal(err.message, 'Oops!') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseError(new Controller(), new Error('Oops!')) + }) + + test('should throw if onResponseError-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({ + // To hin and not wrap the instance + onRequestStart: () => {} + }) + t.doesNotThrow(() => decorator.onResponseError(new Controller())) + }) + }) + + describe('#onBodySent', () => { + test('should delegate onBodySent to wrapped handler', t => { + t = tspl(t, { plan: 1 }) + const inner = { + onBodySent: (chunk) => { + t.equal(chunk, 'hello') + } + } + const decorator = new DecoratorHandler(inner) + decorator.onBodySent('hello') + }) + + test('should not throw if wrapped handler has no onBodySent', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({ onRequestStart: () => {} }) + t.doesNotThrow(() => decorator.onBodySent('hello')) + }) + }) + + describe('#onRequestSent', () => { + test('should delegate onRequestSent to wrapped handler', t => { + t = tspl(t, { plan: 1 }) + const inner = { + onRequestSent: () => { + t.ok(true) + } + } + const decorator = new DecoratorHandler(inner) + decorator.onRequestSent() + }) + + test('should not throw if wrapped handler has no onRequestSent', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({ onRequestStart: () => {} }) + t.doesNotThrow(() => decorator.onRequestSent()) + }) + }) + }) +}) diff --git a/test/web-platform-tests/expectation.json b/test/web-platform-tests/expectation.json index 001ed3ffe9e..34e2cb82957 100644 --- a/test/web-platform-tests/expectation.json +++ b/test/web-platform-tests/expectation.json @@ -639,7 +639,7 @@ "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" }, { - "name": "Fetch http://web-platform.test:60029/fetch/api/resources/top.txt with no-cors mode", + "name": "Fetch http://web-platform.test:53157/fetch/api/resources/top.txt with no-cors mode", "success": false, "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" } @@ -1939,8 +1939,62 @@ ] }, "request-upload.h2.any.html": { - "success": false, - "cases": [] + "success": true, + "cases": [ + { + "name": "Synchronous feature detect", + "success": true + }, + { + "name": "Fetch with POST with empty ReadableStream", + "success": false, + "message": "assert_equals: expected \"\" but got \"{\\\"error\\\": {\\\"code\\\": 500, \\\"message\\\": \\\"Internal server error loading http://web-platform.test:8000/fetch/api/resources/echo-content.h2.py:\\\\n Traceback (most recent call last):\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/server.py\\\\\\\", line 373, in finish_handling\\\\n handler(request, response)\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 334, in __call__\\\\n self._load_file(request, response, func)\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 320, in _load_file\\\\n return func(request, response, environ, path)\\\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 332, in func\\\\n raise HTTPException(500, \\\\\\\"No main function in script %s\\\\\\\" % path)\\\\n HTTPException: (500, 'No main function in script /home/matteo/repositories/undici/test/web-platform-tests/wpt/fetch/api/resources/echo-content.h2.py')\\\\n\\\"}}\"" + }, + { + "name": "Fetch with POST with ReadableStream", + "success": false, + "message": "assert_equals: expected \"Test\" but got \"{\\\"error\\\": {\\\"code\\\": 500, \\\"message\\\": \\\"Internal server error loading http://web-platform.test:8000/fetch/api/resources/echo-content.h2.py:\\\\n Traceback (most recent call last):\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/server.py\\\\\\\", line 373, in finish_handling\\\\n handler(request, response)\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 334, in __call__\\\\n self._load_file(request, response, func)\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 320, in _load_file\\\\n return func(request, response, environ, path)\\\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 332, in func\\\\n raise HTTPException(500, \\\\\\\"No main function in script %s\\\\\\\" % path)\\\\n HTTPException: (500, 'No main function in script /home/matteo/repositories/undici/test/web-platform-tests/wpt/fetch/api/resources/echo-content.h2.py')\\\\n\\\"}}\"" + }, + { + "name": "Fetch with POST with ReadableStream on 421 response should return the response and not retry.", + "success": true + }, + { + "name": "Feature detect for POST with ReadableStream", + "success": true + }, + { + "name": "Feature detect for POST with ReadableStream, using request object", + "success": true + }, + { + "name": "Synchronous feature detect fails if feature unsupported", + "success": false, + "message": "assert_equals: expected \"Test\" but got \"{\\\"error\\\": {\\\"code\\\": 500, \\\"message\\\": \\\"Internal server error loading http://web-platform.test:8000/fetch/api/resources/echo-content.h2.py:\\\\n Traceback (most recent call last):\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/server.py\\\\\\\", line 373, in finish_handling\\\\n handler(request, response)\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 334, in __call__\\\\n self._load_file(request, response, func)\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 320, in _load_file\\\\n return func(request, response, environ, path)\\\\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\\\n\\\\n File \\\\\\\"/home/matteo/repositories/undici/test/web-platform-tests/wpt/tools/wptserve/wptserve/handlers.py\\\\\\\", line 332, in func\\\\n raise HTTPException(500, \\\\\\\"No main function in script %s\\\\\\\" % path)\\\\n HTTPException: (500, 'No main function in script /home/matteo/repositories/undici/test/web-platform-tests/wpt/fetch/api/resources/echo-content.h2.py')\\\\n\\\"}}\"" + }, + { + "name": "Streaming upload with body containing a String", + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" + }, + { + "name": "Streaming upload with body containing null", + "success": true + }, + { + "name": "Streaming upload with body containing a number", + "success": true + }, + { + "name": "Streaming upload should fail on a 401 response", + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" + }, + { + "name": "ReadbleStream should be closed on signal.abort", + "success": true + } + ] } }, "request": { @@ -2610,6 +2664,11 @@ "success": false, "message": "promise_test: Unhandled rejection with value: object \"TypeError: Cannot read properties of undefined (reading 'contentWindow')\"" }, + { + "name": "Import declaration with `type: \"text\"` fetches with a \"text\" Request.destination", + "success": false, + "message": "promise_test: Unhandled rejection with value: object \"TypeError: Cannot read properties of undefined (reading 'contentWindow')\"" + }, { "name": "HTMLLinkElement with rel=preload and as=fetch fetches with an empty string Request.destination", "success": false, @@ -4866,7 +4925,7 @@ { "name": "Consume response's body: from FormData to blob", "success": false, - "message": "assert_equals: Blob body type should be computed from the response Content-Type expected \"multipart/form-data; boundary=----formdata-undici-062475584128\" but got \"multipart/form-data;boundary=----formdata-undici-062475584128\"" + "message": "assert_equals: Blob body type should be computed from the response Content-Type expected \"multipart/form-data; boundary=----formdata-undici-073652552945\" but got \"multipart/form-data;boundary=----formdata-undici-073652552945\"" }, { "name": "Consume response's body: from FormData to text", @@ -7545,7 +7604,12 @@ "success": true, "cases": [ { - "name": "cors-preflight-cache", + "name": "CORS preflight cache reuses explicit header entries", + "success": false, + "message": "assert_equals: Preflight request has been made expected \"1\" but got \"0\"" + }, + { + "name": "CORS preflight cache does not reuse wildcard header entries for Authorization", "success": false, "message": "assert_equals: Preflight request has been made expected \"1\" but got \"0\"" } @@ -8845,665 +8909,8 @@ } }, "idlharness.any.html": { - "success": true, - "cases": [ - { - "name": "idl_test validation", - "success": true - }, - { - "name": "Partial interface mixin WindowOrWorkerGlobalScope: original interface mixin defined", - "success": true - }, - { - "name": "Partial interface mixin WindowOrWorkerGlobalScope: member names are unique", - "success": true - }, - { - "name": "Partial interface Window: original interface defined", - "success": true - }, - { - "name": "Partial interface Window: member names are unique", - "success": true - }, - { - "name": "Partial interface Window[2]: member names are unique", - "success": true - }, - { - "name": "Request includes Body: member names are unique", - "success": true - }, - { - "name": "Response includes Body: member names are unique", - "success": true - }, - { - "name": "Window includes GlobalEventHandlers: member names are unique", - "success": true - }, - { - "name": "Window includes WindowEventHandlers: member names are unique", - "success": true - }, - { - "name": "Window includes WindowOrWorkerGlobalScope: member names are unique", - "success": true - }, - { - "name": "WorkerGlobalScope includes WindowOrWorkerGlobalScope: member names are unique", - "success": true - }, - { - "name": "Window includes AnimationFrameProvider: member names are unique", - "success": true - }, - { - "name": "Window includes WindowSessionStorage: member names are unique", - "success": true - }, - { - "name": "Window includes WindowLocalStorage: member names are unique", - "success": true - }, - { - "name": "Headers interface: existence and properties of interface object", - "success": true - }, - { - "name": "Headers interface object length", - "success": true - }, - { - "name": "Headers interface object name", - "success": true - }, - { - "name": "Headers interface: existence and properties of interface prototype object", - "success": true - }, - { - "name": "Headers interface: existence and properties of interface prototype object's \"constructor\" property", - "success": true - }, - { - "name": "Headers interface: existence and properties of interface prototype object's @@unscopables property", - "success": true - }, - { - "name": "Headers interface: operation append(ByteString, ByteString)", - "success": true - }, - { - "name": "Headers interface: operation delete(ByteString)", - "success": true - }, - { - "name": "Headers interface: operation get(ByteString)", - "success": true - }, - { - "name": "Headers interface: operation getSetCookie()", - "success": true - }, - { - "name": "Headers interface: operation has(ByteString)", - "success": true - }, - { - "name": "Headers interface: operation set(ByteString, ByteString)", - "success": true - }, - { - "name": "Headers interface: iterable", - "success": true - }, - { - "name": "Headers must be primary interface of new Headers()", - "success": true - }, - { - "name": "Stringification of new Headers()", - "success": true - }, - { - "name": "Headers interface: new Headers() must inherit property \"append(ByteString, ByteString)\" with the proper type", - "success": true - }, - { - "name": "Headers interface: calling append(ByteString, ByteString) on new Headers() with too few arguments must throw TypeError", - "success": true - }, - { - "name": "Headers interface: new Headers() must inherit property \"delete(ByteString)\" with the proper type", - "success": true - }, - { - "name": "Headers interface: calling delete(ByteString) on new Headers() with too few arguments must throw TypeError", - "success": true - }, - { - "name": "Headers interface: new Headers() must inherit property \"get(ByteString)\" with the proper type", - "success": true - }, - { - "name": "Headers interface: calling get(ByteString) on new Headers() with too few arguments must throw TypeError", - "success": true - }, - { - "name": "Headers interface: new Headers() must inherit property \"getSetCookie()\" with the proper type", - "success": true - }, - { - "name": "Headers interface: new Headers() must inherit property \"has(ByteString)\" with the proper type", - "success": true - }, - { - "name": "Headers interface: calling has(ByteString) on new Headers() with too few arguments must throw TypeError", - "success": true - }, - { - "name": "Headers interface: new Headers() must inherit property \"set(ByteString, ByteString)\" with the proper type", - "success": true - }, - { - "name": "Headers interface: calling set(ByteString, ByteString) on new Headers() with too few arguments must throw TypeError", - "success": true - }, - { - "name": "Request interface: existence and properties of interface object", - "success": true - }, - { - "name": "Request interface object length", - "success": true - }, - { - "name": "Request interface object name", - "success": true - }, - { - "name": "Request interface: existence and properties of interface prototype object", - "success": true - }, - { - "name": "Request interface: existence and properties of interface prototype object's \"constructor\" property", - "success": true - }, - { - "name": "Request interface: existence and properties of interface prototype object's @@unscopables property", - "success": true - }, - { - "name": "Request interface: operation clone()", - "success": true - }, - { - "name": "Request must be primary interface of new Request('about:blank')", - "success": true - }, - { - "name": "Stringification of new Request('about:blank')", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"method\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"url\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"headers\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"destination\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"referrer\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"referrerPolicy\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"mode\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"credentials\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"cache\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"redirect\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"integrity\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"keepalive\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"isReloadNavigation\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"isHistoryNavigation\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"signal\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"duplex\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"clone()\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"body\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"bodyUsed\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"arrayBuffer()\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"blob()\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"bytes()\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"formData()\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"json()\" with the proper type", - "success": true - }, - { - "name": "Request interface: new Request('about:blank') must inherit property \"text()\" with the proper type", - "success": true - }, - { - "name": "Response interface: existence and properties of interface object", - "success": true - }, - { - "name": "Response interface object length", - "success": true - }, - { - "name": "Response interface object name", - "success": true - }, - { - "name": "Response interface: existence and properties of interface prototype object", - "success": true - }, - { - "name": "Response interface: existence and properties of interface prototype object's \"constructor\" property", - "success": true - }, - { - "name": "Response interface: existence and properties of interface prototype object's @@unscopables property", - "success": true - }, - { - "name": "Response interface: operation error()", - "success": true - }, - { - "name": "Response interface: operation redirect(USVString, optional unsigned short)", - "success": true - }, - { - "name": "Response interface: operation json(any, optional ResponseInit)", - "success": true - }, - { - "name": "Response interface: operation clone()", - "success": true - }, - { - "name": "Response must be primary interface of new Response()", - "success": true - }, - { - "name": "Stringification of new Response()", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"error()\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"redirect(USVString, optional unsigned short)\" with the proper type", - "success": true - }, - { - "name": "Response interface: calling redirect(USVString, optional unsigned short) on new Response() with too few arguments must throw TypeError", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"json(any, optional ResponseInit)\" with the proper type", - "success": true - }, - { - "name": "Response interface: calling json(any, optional ResponseInit) on new Response() with too few arguments must throw TypeError", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"type\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"url\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"redirected\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"status\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"ok\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"statusText\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"headers\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"clone()\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"body\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"bodyUsed\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"arrayBuffer()\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"blob()\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"bytes()\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"formData()\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"json()\" with the proper type", - "success": true - }, - { - "name": "Response interface: new Response() must inherit property \"text()\" with the proper type", - "success": true - }, - { - "name": "FetchLaterResult interface: existence and properties of interface object", - "success": false, - "message": "assert_own_property: self does not have own property \"FetchLaterResult\" expected property \"FetchLaterResult\" missing" - }, - { - "name": "FetchLaterResult interface object length", - "success": false, - "message": "assert_own_property: self does not have own property \"FetchLaterResult\" expected property \"FetchLaterResult\" missing" - }, - { - "name": "FetchLaterResult interface object name", - "success": false, - "message": "assert_own_property: self does not have own property \"FetchLaterResult\" expected property \"FetchLaterResult\" missing" - }, - { - "name": "FetchLaterResult interface: existence and properties of interface prototype object", - "success": false, - "message": "assert_own_property: self does not have own property \"FetchLaterResult\" expected property \"FetchLaterResult\" missing" - }, - { - "name": "FetchLaterResult interface: existence and properties of interface prototype object's \"constructor\" property", - "success": false, - "message": "assert_own_property: self does not have own property \"FetchLaterResult\" expected property \"FetchLaterResult\" missing" - }, - { - "name": "FetchLaterResult interface: existence and properties of interface prototype object's @@unscopables property", - "success": false, - "message": "assert_own_property: self does not have own property \"FetchLaterResult\" expected property \"FetchLaterResult\" missing" - }, - { - "name": "FetchLaterResult interface: attribute activated", - "success": false, - "message": "assert_own_property: self does not have own property \"FetchLaterResult\" expected property \"FetchLaterResult\" missing" - }, - { - "name": "Window interface: operation fetchLater(RequestInfo, optional DeferredRequestInit)", - "success": false, - "message": "assert_own_property: global object missing non-static operation expected property \"fetchLater\" missing" - }, - { - "name": "Window interface: window must inherit property \"fetchLater(RequestInfo, optional DeferredRequestInit)\" with the proper type", - "success": false, - "message": "assert_own_property: expected property \"fetchLater\" missing" - }, - { - "name": "Window interface: calling fetchLater(RequestInfo, optional DeferredRequestInit) on window with too few arguments must throw TypeError", - "success": false, - "message": "assert_own_property: expected property \"fetchLater\" missing" - }, - { - "name": "Window interface: window must inherit property \"fetch(RequestInfo, optional RequestInit)\" with the proper type", - "success": true - }, - { - "name": "Request interface: attribute method", - "success": true - }, - { - "name": "Request interface: attribute url", - "success": true - }, - { - "name": "Request interface: attribute headers", - "success": true - }, - { - "name": "Request interface: attribute destination", - "success": true - }, - { - "name": "Request interface: attribute referrer", - "success": true - }, - { - "name": "Request interface: attribute referrerPolicy", - "success": true - }, - { - "name": "Request interface: attribute mode", - "success": true - }, - { - "name": "Request interface: attribute credentials", - "success": true - }, - { - "name": "Request interface: attribute cache", - "success": true - }, - { - "name": "Request interface: attribute redirect", - "success": true - }, - { - "name": "Request interface: attribute integrity", - "success": true - }, - { - "name": "Request interface: attribute keepalive", - "success": true - }, - { - "name": "Request interface: attribute isReloadNavigation", - "success": true - }, - { - "name": "Request interface: attribute isHistoryNavigation", - "success": true - }, - { - "name": "Request interface: attribute signal", - "success": true - }, - { - "name": "Request interface: attribute duplex", - "success": true - }, - { - "name": "Request interface: attribute body", - "success": true - }, - { - "name": "Request interface: attribute bodyUsed", - "success": true - }, - { - "name": "Response interface: attribute type", - "success": true - }, - { - "name": "Response interface: attribute url", - "success": true - }, - { - "name": "Response interface: attribute redirected", - "success": true - }, - { - "name": "Response interface: attribute status", - "success": true - }, - { - "name": "Response interface: attribute ok", - "success": true - }, - { - "name": "Response interface: attribute statusText", - "success": true - }, - { - "name": "Response interface: attribute headers", - "success": true - }, - { - "name": "Response interface: attribute body", - "success": true - }, - { - "name": "Response interface: attribute bodyUsed", - "success": true - }, - { - "name": "idl_test setup", - "success": true - }, - { - "name": "Request interface: operation arrayBuffer()", - "success": true - }, - { - "name": "Request interface: operation blob()", - "success": true - }, - { - "name": "Request interface: operation bytes()", - "success": true - }, - { - "name": "Request interface: operation formData()", - "success": true - }, - { - "name": "Request interface: operation json()", - "success": true - }, - { - "name": "Request interface: operation text()", - "success": true - }, - { - "name": "Response interface: operation arrayBuffer()", - "success": true - }, - { - "name": "Response interface: operation blob()", - "success": true - }, - { - "name": "Response interface: operation bytes()", - "success": true - }, - { - "name": "Response interface: operation formData()", - "success": true - }, - { - "name": "Response interface: operation json()", - "success": true - }, - { - "name": "Response interface: operation text()", - "success": true - }, - { - "name": "Window interface: operation fetch(RequestInfo, optional RequestInit)", - "success": false, - "message": "assert_unreached: Should have rejected: calling operation with this = {} didn't throw TypeError Reached unreachable code" - }, - { - "name": "Window interface: calling fetch(RequestInfo, optional RequestInit) on window with too few arguments must throw TypeError", - "success": false, - "message": "assert_unreached: Should have rejected: Called with 0 arguments Reached unreachable code" - } - ] + "success": false, + "cases": [] }, "policies": { "csp-blocked-worker.html": { @@ -11304,17 +10711,17 @@ "success": true, "cases": [ { - "name": "Decompresion using gzip-encoded dictionary works as expected", + "name": "Decompression using gzip-encoded dictionary works as expected", "success": false, "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { - "name": "Decompresion using Brotli-encoded dictionary works as expected", + "name": "Decompression using Brotli-encoded dictionary works as expected", "success": false, "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { - "name": "Decompresion using Zstandard-encoded dictionary works as expected", + "name": "Decompression using Zstandard-encoded dictionary works as expected", "success": false, "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, @@ -11334,17 +10741,27 @@ "success": true, "cases": [ { - "name": "Decompresion using Brotli with the dictionary works as expected", + "name": "Decompression using Brotli with the dictionary works as expected", + "success": false, + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" + }, + { + "name": "Decompression using Zstandard with the dictionary works as expected", "success": false, "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { - "name": "Decompresion using Zstandard with the dictionary works as expected", + "name": "Decompression of a cross origin resource works as expected", "success": false, "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { - "name": "Decompresion of a cross origin resource works as expected", + "name": "Decompression using Brotli fails when dictionary hash mismatches", + "success": false, + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" + }, + { + "name": "Decompression using Zstandard fails when dictionary hash mismatches", "success": false, "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" } @@ -11357,6 +10774,11 @@ "name": "Fetch cross-origin no-cors request does not include Available-Dictionary header", "success": false, "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" + }, + { + "name": "Opaque responses resulting from cross-origin redirects in no-cors mode do not register dictionary", + "success": false, + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" } ] }, @@ -11391,34 +10813,8 @@ ] }, "dictionary-registration.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Simple dictionary registration and unregistration", - "success": false, - "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" - }, - { - "name": "Dictionary registration with dictionary ID", - "success": false, - "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" - }, - { - "name": "New dictionary registration overrides the existing one", - "success": false, - "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" - }, - { - "name": "Dictionary registration does not invalidate cache entry", - "success": false, - "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" - }, - { - "name": "Expired dictionary is not used", - "success": false, - "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" - } - ] + "success": false, + "cases": [] } }, "connection-pool": { @@ -13776,401 +13172,84 @@ }, "fetch-later": { "activate-after.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() sends out based on activateAfter.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() sends out based on activateAfter, even if document is in BFCache.", - "success": false, - "message": "window.open is not a function" - } - ] + "success": false, + "cases": [] }, "basic.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() cannot be called without request.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater()\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() with same-origin (https) URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() with http://localhost URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() with https://localhost URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() with http://127.0.0.1 URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() with https://127.0.0.1 URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() with http://[::1] URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() with https://[::1] URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() with https://example.com URL does not throw.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() throws SecurityError on non-trustworthy http URL.", - "success": false, - "message": "assert_throws_dom: should throw SecurityError for insecure http url http://example.com function \"() => fetchLater(httpUrl)\" threw object \"ReferenceError: fetchLater is not defined\" that is not a DOMException SecurityError: property \"code\" is equal to undefined, expected 18" - }, - { - "name": "fetchLater() throws TypeError on file:// scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('file://tmp')\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws TypeError on ftp:// scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('ftp://example.com')\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws TypeError on ssh:// scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('ssh://example.com')\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws TypeError on wss:// scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('wss://example.com')\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws TypeError on about: scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('about:blank')\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws TypeError on javascript: scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater(`javascript:alert('');`)\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws TypeError on data: scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('data:text/plain,Hello')\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws TypeError on blob: scheme.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('blob:https://example.com/some-uuid')\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() throws RangeError on negative activateAfter.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('https://www.google.com', {activateAfter: -1})\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function RangeError() { [native code] }\" (\"RangeError\")" - }, - { - "name": "fetchLater()'s return tells the deferred request is not yet sent.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() throws TypeError when mutating its returned state.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() throws AbortError when its initial abort signal is aborted.", - "success": false, - "message": "assert_throws_dom: function \"() => fetchLater('/', {signal: controller.signal})\" threw object \"ReferenceError: fetchLater is not defined\" that is not a DOMException AbortError: property \"code\" is equal to undefined, expected 20" - }, - { - "name": "fetchLater() does not throw error when it is aborted before sending.", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "headers": { "header-referrer-no-referrer-when-downgrade.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header https://web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "header-referrer-no-referrer.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header ", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "header-referrer-origin-when-cross-origin.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header https://web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "Test referer header https://www1.web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "header-referrer-origin.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header https://www1.web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "header-referrer-same-origin.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header ", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "Test referer header https://www1.web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "header-referrer-strict-origin-when-cross-origin.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header https://www1.web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "header-referrer-strict-origin.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header https://web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "header-referrer-unsafe-url.tentative.https.html": { - "success": true, - "cases": [ - { - "name": "Test referer header https://web-platform.test:8443", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] } }, "iframe.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "A blank iframe can trigger fetchLater.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "new-window.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "A blank window[target=''][features=''] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A same-origin window[target=''][features=''] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A cross-origin window[target=''][features=''] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A blank window[target=''][features='popup'] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A same-origin window[target=''][features='popup'] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A cross-origin window[target=''][features='popup'] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A blank window[target='_blank'][features=''] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A same-origin window[target='_blank'][features=''] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A cross-origin window[target='_blank'][features=''] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A blank window[target='_blank'][features='popup'] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A same-origin window[target='_blank'][features='popup'] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "A cross-origin window[target='_blank'][features='popup'] can trigger fetchLater.", - "success": false, - "message": "window.open is not a function" - } - ] + "success": false, + "cases": [] }, "non-secure.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() is not supported in non-secure context.", - "success": true - } - ] - }, - "permissions-policy": { - "deferred-fetch-allowed-by-permissions-policy-attribute-redirect.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "Permissions policy allow=\"deferred-fetch\" allows fetchLater() from a redirected same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "Permissions policy allow=\"deferred-fetch\" disallows fetchLater() from a redirected cross-origin iframe.", - "success": false, - "message": "document is not defined" - } - ] + "success": true, + "cases": [ + { + "name": "fetchLater() is not supported in non-secure context.", + "success": true + } + ] + }, + "permissions-policy": { + "deferred-fetch-allowed-by-permissions-policy-attribute-redirect.tentative.https.window.html": { + "success": false, + "cases": [] }, "deferred-fetch-allowed-by-permissions-policy-attribute.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "Permissions policy \"deferred-fetch\" can be enabled in the same-origin iframe using allow=\"deferred-fetch\" attribute.", - "success": false, - "message": "document is not defined" - }, - { - "name": "Permissions policy \"deferred-fetch\" can be enabled in the cross-origin iframe using allow=\"deferred-fetch\" attribute.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "deferred-fetch-allowed-by-permissions-policy.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "Permissions policy header: \"deferred-fetch=*\" allows fetchLater() in the same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "Permissions policy header: \"deferred-fetch=*\" allows fetchLater() in the cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "Permissions policy header: \"deferred-fetch=*\" allow=\"deferred-fetch\" allows fetchLater() in the cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "Permissions policy header: \"deferred-fetch=*\" allows fetchLater() in the top-level document.", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "deferred-fetch-default-permissions-policy.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "Default \"deferred-fetch\" permissions policy [\"self\"] allows fetchLater() in the same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "Default \"deferred-fetch-minimal\" permissions policy [\"*\"] allows fetchLater() in the cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "Default \"deferred-fetch\" permissions policy [\"self\"] allows fetchLater() in the top-level document.", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "deferred-fetch-supported-by-permissions-policy.tentative.window.html": { - "success": true, - "cases": [ - { - "name": "document.featurePolicy.features should advertise deferred-fetch.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] } }, "policies": { @@ -14189,477 +13268,80 @@ }, "quota": { "accumulated-oversized-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "The 2nd fetchLater(same-origin) call in the top-level document is not allowed to exceed per-origin quota for its POST body of String.", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "cross-origin-iframe": { "accumulated-oversized-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "The 2nd fetchLater(same-origin) call in a default cross-origin child iframe has its owned per-origin quota for a request POST body of String.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: fetchLater is not defined\"" - } - ] + "success": false, + "cases": [] }, "empty-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() does not accept empty POST request body of String in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of ArrayBuffer in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts a non-empty POST request body of FormData in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of URLSearchParams in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of Blob in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of File in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "max-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() accepts max payload in a parent-frame-origin POST request body of String in a default cross-origin iframe.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - }, - { - "name": "fetchLater() rejects max+1 payload in a parent-frame-origin POST request body of String in a default cross-origin iframe.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - }, - { - "name": "fetchLater() accepts max payload in a self-frame-origin POST request body of String in a default cross-origin iframe.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - }, - { - "name": "fetchLater() rejects max+1 payload in a self-frame-origin POST request body of String in a default cross-origin iframe.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - } - ] + "success": false, + "cases": [] }, "multiple-iframes.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() request quota are delegated to cross-origin iframes and not shared, even if they are same origin.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - } - ] + "success": false, + "cases": [] }, "oversized-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() does not accept payload[size=8193] exceeding per-origin quota in a POST request body of String in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=8193] exceeding per-origin quota in a POST request body of ArrayBuffer in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=8193] exceeding per-origin quota in a POST request body of FormData in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=8193] exceeding per-origin quota in a POST request body of URLSearchParams in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=8193] exceeding per-origin quota in a POST request body of Blob in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=8193] exceeding per-origin quota in a POST request body of File in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "small-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of String in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of ArrayBuffer in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of FormData in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of URLSearchParams in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of Blob in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of File in a default cross-origin iframe.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] } }, "empty-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() does not accept an empty POST request body of String.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('/', requestInit)\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() does not accept an empty POST request body of ArrayBuffer.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('/', requestInit)\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() does not accept an empty POST request body of URLSearchParams.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('/', requestInit)\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() does not accept an empty POST request body of Blob.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('/', requestInit)\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() does not accept an empty POST request body of File.", - "success": false, - "message": "assert_throws_js: function \"() => fetchLater('/', requestInit)\" threw object \"ReferenceError: fetchLater is not defined\" (\"ReferenceError\") expected instance of function \"function TypeError() { [native code] }\" (\"TypeError\")" - }, - { - "name": "fetchLater() accepts a non-empty POST request body of FormData.", - "success": true - }, - { - "name": "fetchLater() accept a GET request.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() accept a DELETE request.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() accept a PUT request.", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "max-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() rejects max+1 payload in a POST request body of String.", - "success": false, - "message": "assert_throws_quotaexceedederror: function \"() => {\n fetchLater(requestUrl, {\n activateAfter: 0,\n method: 'POST',\n body: generatePayload(\n getRemainingQuota(QUOTA_PER_ORIGIN, requestUrl, headers) + 1,\n dataType),\n });\n }\" threw object \"ReferenceError: fetchLater is not defined\" that is not a correct QuotaExceededError: property \"code\" is equal to undefined, expected 22" - }, - { - "name": "fetchLater() accepts max payload in a POST request body of String.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: fetchLater is not defined\"" - } - ] + "success": false, + "cases": [] }, "multiple-origins.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() has per-request-origin quota for its POST body of String.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() has per-request-origin quota for its POST body of ArrayBuffer.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() has per-request-origin quota for its POST body of FormData.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() has per-request-origin quota for its POST body of URLSearchParams.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() has per-request-origin quota for its POST body of Blob.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() has per-request-origin quota for its POST body of File.", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] }, "oversized-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of String.", - "success": false, - "message": "assert_throws_quotaexceedederror: function \"() => {\n fetchLater('/', {\n activateAfter: 0,\n method: 'POST',\n body: makeBeaconData(\n generatePayload(OVERSIZED_REQUEST_BODY_SIZE), dataType),\n });\n }\" threw object \"ReferenceError: fetchLater is not defined\" that is not a correct QuotaExceededError: property \"code\" is equal to undefined, expected 22" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of ArrayBuffer.", - "success": false, - "message": "assert_throws_quotaexceedederror: function \"() => {\n fetchLater('/', {\n activateAfter: 0,\n method: 'POST',\n body: makeBeaconData(\n generatePayload(OVERSIZED_REQUEST_BODY_SIZE), dataType),\n });\n }\" threw object \"ReferenceError: fetchLater is not defined\" that is not a correct QuotaExceededError: property \"code\" is equal to undefined, expected 22" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of FormData.", - "success": false, - "message": "assert_throws_quotaexceedederror: function \"() => {\n fetchLater('/', {\n activateAfter: 0,\n method: 'POST',\n body: makeBeaconData(\n generatePayload(OVERSIZED_REQUEST_BODY_SIZE), dataType),\n });\n }\" threw object \"ReferenceError: fetchLater is not defined\" that is not a correct QuotaExceededError: property \"code\" is equal to undefined, expected 22" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of URLSearchParams.", - "success": false, - "message": "assert_throws_quotaexceedederror: function \"() => {\n fetchLater('/', {\n activateAfter: 0,\n method: 'POST',\n body: makeBeaconData(\n generatePayload(OVERSIZED_REQUEST_BODY_SIZE), dataType),\n });\n }\" threw object \"ReferenceError: fetchLater is not defined\" that is not a correct QuotaExceededError: property \"code\" is equal to undefined, expected 22" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of Blob.", - "success": false, - "message": "assert_throws_quotaexceedederror: function \"() => {\n fetchLater('/', {\n activateAfter: 0,\n method: 'POST',\n body: makeBeaconData(\n generatePayload(OVERSIZED_REQUEST_BODY_SIZE), dataType),\n });\n }\" threw object \"ReferenceError: fetchLater is not defined\" that is not a correct QuotaExceededError: property \"code\" is equal to undefined, expected 22" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of File.", - "success": false, - "message": "assert_throws_quotaexceedederror: function \"() => {\n fetchLater('/', {\n activateAfter: 0,\n method: 'POST',\n body: makeBeaconData(\n generatePayload(OVERSIZED_REQUEST_BODY_SIZE), dataType),\n });\n }\" threw object \"ReferenceError: fetchLater is not defined\" that is not a correct QuotaExceededError: property \"code\" is equal to undefined, expected 22" - } - ] + "success": false, + "cases": [] }, "same-origin-iframe": { "accumulated-oversized-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "The 2nd fetchLater(same-origin) call in a same-origin child iframe is not allowed to exceed per-origin quota for its POST body of String.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: fetchLater is not defined\"" - } - ] + "success": false, + "cases": [] }, "empty-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() does not accept empty POST request body of String in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of ArrayBuffer in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts a non-empty POST request body of FormData in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of URLSearchParams in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of Blob in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept empty POST request body of File in same-origin iframe.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "max-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() accepts max payload in a POST request body of String in same-origin iframe.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - }, - { - "name": "fetchLater() rejects max+1 payload in a POST request body of String in same-origin iframe.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - } - ] + "success": false, + "cases": [] }, "multiple-iframes.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() request quota are shared by same-origin iframes and root.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"ReferenceError: document is not defined\"" - } - ] + "success": false, + "cases": [] }, "oversized-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of String in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of ArrayBuffer in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of FormData in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of URLSearchParams in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of Blob in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() does not accept payload[size=65537] exceeding per-origin quota in a POST request body of File in same-origin iframe.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "small-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of String in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of ArrayBuffer in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of FormData in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of URLSearchParams in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of Blob in same-origin iframe.", - "success": false, - "message": "document is not defined" - }, - { - "name": "fetchLater() accepts payload[size=20] in a POST request body of File in same-origin iframe.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] } }, "small-payload.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() accepts small payload in a POST request body of String.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() accepts small payload in a POST request body of ArrayBuffer.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() accepts small payload in a POST request body of FormData.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() accepts small payload in a POST request body of URLSearchParams.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() accepts small payload in a POST request body of Blob.", - "success": false, - "message": "fetchLater is not defined" - }, - { - "name": "fetchLater() accepts small payload in a POST request body of File.", - "success": false, - "message": "fetchLater is not defined" - } - ] + "success": false, + "cases": [] } }, "send-on-deactivate-with-background-sync.tentative.https.window.html": { @@ -14667,65 +13349,21 @@ "cases": [] }, "send-on-deactivate.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "fetchLater() sends on page entering BFCache if BackgroundSync is off.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "Call fetchLater() when BFCached with activateAfter=0 sends immediately.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "fetchLater() sends on navigating away a page w/o BFCache.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "fetchLater() does not send aborted request on navigating away a page w/o BFCache.", - "success": false, - "message": "window.open is not a function" - }, - { - "name": "fetchLater() with activateAfter=1m sends on page entering BFCache if BackgroundSync is off.", - "success": false, - "message": "window.open is not a function" - } - ] + "success": false, + "cases": [] }, "send-on-discard": { "not-send-after-abort.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "A discarded document does not send an already aborted fetchLater request.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "send-multiple-with-activate-after.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "A discarded document sends all its fetchLater requests, no matter how much their activateAfter timeout remain.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] }, "send-multiple.tentative.https.window.html": { - "success": true, - "cases": [ - { - "name": "A discarded document sends all its fetchLater requests.", - "success": false, - "message": "document is not defined" - } - ] + "success": false, + "cases": [] } } }, @@ -15252,6 +13890,46 @@ { "name": "When key-order is set , URLs should be compared in an order-insensitive way. Not matched cases", "success": true + }, + { + "name": "When params names a specific parameter, URLs differing only in that parameter should be cached as the same entry.", + "success": false, + "message": "assert_less_than: Response 2 does not come from cache expected a number less than 2 but got 2" + }, + { + "name": "When params names a specific parameter, URLs differing in other parameters should be cached as different entries.", + "success": true + }, + { + "name": "When params names multiple parameters, URLs differing only in those parameters should be cached as the same entry.", + "success": false, + "message": "assert_less_than: Response 2 does not come from cache expected a number less than 2 but got 2" + }, + { + "name": "When params=?1 is set explicitly (equivalent to bare params), URLs differing only in their parameters (other than `dispatch` and `uuid`) should be cached as the same entry.", + "success": false, + "message": "assert_less_than: Response 2 does not come from cache expected a number less than 2 but got 2" + }, + { + "name": "When params and except are set, URLs differing in a kept parameter should be cached as different entries.", + "success": true + }, + { + "name": "When params names a parameter and key-order is set, URLs differing only in that parameter and parameter order should be cached as the same entry.", + "success": false, + "message": "assert_less_than: Response 2 does not come from cache expected a number less than 2 but got 2" + }, + { + "name": "When params names a parameter and key-order is set, URLs differing in other parameters should be cached as different entries.", + "success": true + }, + { + "name": "When params is an inner list combined with except, it should fall back to exact match and URLs with different parameters should be cached as different entries.", + "success": true + }, + { + "name": "When except is set without params, it should fall back to exact match and URLs with different parameters should be cached as different entries.", + "success": true } ] }, @@ -15324,9 +14002,13 @@ "success": true, "cases": [ { - "name": "Response with Cache-Control: max-age=2592000, public and Pragma: no-cache should be cached", + "name": "Response with Cache-Control: max-age=2592000, public and Pragma: no-cache should not be cached", + "success": true + }, + { + "name": "Response with Cache-Control: max-age=2592000, immutable and Pragma: no-cache should be cached", "success": false, - "message": "assert_equals: Responses should be identical, indicating caching expected \"Timestamp: 1774110745.180101\" but got \"Timestamp: 1774110745.1738834\"" + "message": "assert_equals: Responses should be identical, indicating cache use expected \"Token: 77d4714b-3ac4-4def-88c0-d493defdef00\" but got \"Token: 36ecdb03-47dd-44d7-a8e4-1aa0118012e7\"" } ] }, @@ -23648,8 +22330,7 @@ }, { "name": "A blob range request with no end.", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true }, { "name": "A blob range request with no start.", @@ -23677,8 +22358,7 @@ }, { "name": "Blob range with whitespace around equals sign", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true }, { "name": "Blob range with no value", @@ -24123,11 +22803,11 @@ "message": "document is not defined" }, { - "name": "Fetch: /images/green-1x1.png", + "name": "Fetch: /images/gr\\teen-1x1.png?img=%3C", "success": true }, { - "name": "Fetch: /images/gre\\nen-1x1.png", + "name": "Fetch: /images/green-1x1.png?img=%3C", "success": true }, { @@ -24135,41 +22815,45 @@ "success": true }, { - "name": "Fetch: /images/gre\\ren-1x1.png", + "name": "Fetch: /images/green-1x1.png", "success": true }, { - "name": "Fetch: /images/green-1x1.png?img=<", + "name": "Fetch: /images/gre\\nen-1x1.png", "success": true }, { - "name": "Fetch: /images/green-1x1.png?img=<", + "name": "Fetch: /images/green-1x1.png?img= ", "success": true }, { - "name": "Fetch: /images/green-1x1.png?img=%3C", + "name": "Fetch: /images/green-1x1.png?img=<", "success": true }, { - "name": "Fetch: /images/gr\\neen-1x1.png?img=%3C", - "success": true + "name": "Fetch: /images/gre\\nen-1x1.png?img=<", + "success": false, + "message": "assert_unreached: Fetch should fail. Reached unreachable code" }, { "name": "Fetch: /images/gr\\reen-1x1.png?img=%3C", "success": true }, { - "name": "Fetch: /images/gr\\teen-1x1.png?img=%3C", + "name": "Fetch: /images/gr\\neen-1x1.png?img= ", "success": true }, { - "name": "Fetch: /images/green-1x1.png?img= ", + "name": "Fetch: /images/green-1x1.png?img=<", "success": true }, { - "name": "Fetch: /images/green-1x1.png?<\\r=block", - "success": false, - "message": "assert_unreached: Fetch should fail. Reached unreachable code" + "name": "Fetch: /images/gre\\ren-1x1.png", + "success": true + }, + { + "name": "Fetch: /images/gr\\neen-1x1.png?img=%3C", + "success": true }, { "name": "Fetch: /images/gr\\teen-1x1.png?img= ", @@ -24181,32 +22865,28 @@ "message": "assert_unreached: Fetch should fail. Reached unreachable code" }, { - "name": "Fetch: /images/gre\\ten-1x1.png?img=<", + "name": "Fetch: /images/green-1x1.png?<\\r=block", "success": false, "message": "assert_unreached: Fetch should fail. Reached unreachable code" }, { - "name": "Fetch: /images/green-1x1.png?<\\n=block", + "name": "Fetch: /images/gre\\ten-1x1.png?img=<", "success": false, "message": "assert_unreached: Fetch should fail. Reached unreachable code" }, { - "name": "Fetch: /images/gr\\reen-1x1.png?img= ", - "success": true - }, - { - "name": "Fetch: /images/gre\\nen-1x1.png?img=<", + "name": "Fetch: /images/green-1x1.png?<\\n=block", "success": false, "message": "assert_unreached: Fetch should fail. Reached unreachable code" }, - { - "name": "Fetch: /images/gr\\neen-1x1.png?img= ", - "success": true - }, { "name": "Fetch: /images/green-1x1.png?<\\t=block", "success": false, "message": "assert_unreached: Fetch should fail. Reached unreachable code" + }, + { + "name": "Fetch: /images/gr\\reen-1x1.png?img= ", + "success": true } ] }, @@ -24392,7 +23072,7 @@ { "name": "Second fetch returns same response", "success": false, - "message": "assert_equals: expected \"oxxwxmaepucudtbybaij\" but got \"eejepvpvjzlnzrmyaimq\"" + "message": "assert_equals: expected \"elgfdlxuxmhxxwbyvsut\" but got \"usdbnkxkcbipygmeaeey\"" } ] }, @@ -36552,11 +35232,11 @@ "success": true, "cases": [ { - "name": "Application data is 125 byte which means any 'Extended payload length' field isn't used at all.", + "name": "Application data is 126 byte which starts to use the 16 bit 'Extended payload length' field.", "success": true }, { - "name": "Application data is 126 byte which starts to use the 16 bit 'Extended payload length' field.", + "name": "Application data is 125 byte which means any 'Extended payload length' field isn't used at all.", "success": true }, { @@ -38864,9 +37544,9 @@ "success": true, "cases": [ { - "name": "constructing an insecure WebSocket in a secure context should throw", + "name": "opening an insecure WebSocket in a secure context should fail", "success": false, - "message": "assert_throws_dom: constructor should throw function \"() => CreateInsecureWebSocket()\" did not throw" + "message": "assert_unreached: open should not fire Reached unreachable code" } ] }, @@ -39164,7 +37844,7 @@ { "name": "backpressure should be applied to received messages", "success": false, - "message": "assert_greater_than_equal: data send should have taken at least 2 seconds expected a number greater than or equal to 1.8 but got 0.0826730728149414" + "message": "assert_greater_than_equal: data send should have taken at least 2 seconds expected a number greater than or equal to 1.8 but got 0.07775568962097168" } ] }, @@ -39188,8 +37868,119 @@ ] }, "close.any.html?default": { - "success": false, - "cases": [] + "success": true, + "cases": [ + { + "name": "close code should be sent to server and reflected back", + "success": true + }, + { + "name": "no close argument should send empty Close frame", + "success": true + }, + { + "name": "unspecified close code should send empty Close frame", + "success": true + }, + { + "name": "unspecified close code with empty reason should send empty Close frame", + "success": true + }, + { + "name": "unspecified close code with non-empty reason should set code to 1000", + "success": true + }, + { + "name": "close(true) should throw a TypeError", + "success": true + }, + { + "name": "close() with an overlong reason should throw", + "success": true + }, + { + "name": "close during handshake should work", + "success": true + }, + { + "name": "close() with invalid code 999 should throw", + "success": true + }, + { + "name": "close() with invalid code 1001 should throw", + "success": true + }, + { + "name": "close() with invalid code 2999 should throw", + "success": true + }, + { + "name": "close() with invalid code 5000 should throw", + "success": true + }, + { + "name": "closing the writable should result in a clean close", + "success": true + }, + { + "name": "writer close() promise should not resolve until handshake completes", + "success": false, + "message": "assert_greater_than_equal: one second should have elapsed expected a number greater than or equal to 900 but got 0.2510350000000017" + }, + { + "name": "incomplete closing handshake should be considered unclean close", + "success": false, + "message": "assert_equals: close code should be Abnormal Closure expected 1006 but got 1005" + }, + { + "name": "aborting the writable should result in a clean close", + "success": true + }, + { + "name": "aborting the writable with attributes not wrapped in a WebSocketError should be ignored", + "success": true + }, + { + "name": "aborting the writable with a code should send that code", + "success": true + }, + { + "name": "aborting the writable with a code and reason should use them", + "success": true + }, + { + "name": "aborting the writable with a reason but no code should default the close code", + "success": true + }, + { + "name": "aborting the writable with a DOMException not set code or reason", + "success": true + }, + { + "name": "canceling the readable should result in a clean close", + "success": true + }, + { + "name": "canceling the readable with attributes not wrapped in a WebSocketError should be ignored", + "success": true + }, + { + "name": "canceling the readable with a code should send that code", + "success": true + }, + { + "name": "canceling the readable with a code and reason should use them", + "success": true + }, + { + "name": "canceling the readable with a reason but no code should default the close code", + "success": true + }, + { + "name": "canceling the readable with a DOMException not set code or reason", + "success": true + } + ] }, "close.any.html?wpt_flags=h2": { "success": true, @@ -39331,8 +38122,119 @@ ] }, "close.any.html?wss": { - "success": false, - "cases": [] + "success": true, + "cases": [ + { + "name": "close code should be sent to server and reflected back", + "success": true + }, + { + "name": "no close argument should send empty Close frame", + "success": true + }, + { + "name": "unspecified close code should send empty Close frame", + "success": true + }, + { + "name": "unspecified close code with empty reason should send empty Close frame", + "success": true + }, + { + "name": "unspecified close code with non-empty reason should set code to 1000", + "success": true + }, + { + "name": "close(true) should throw a TypeError", + "success": true + }, + { + "name": "close() with an overlong reason should throw", + "success": true + }, + { + "name": "close during handshake should work", + "success": true + }, + { + "name": "close() with invalid code 999 should throw", + "success": true + }, + { + "name": "close() with invalid code 1001 should throw", + "success": true + }, + { + "name": "close() with invalid code 2999 should throw", + "success": true + }, + { + "name": "close() with invalid code 5000 should throw", + "success": true + }, + { + "name": "closing the writable should result in a clean close", + "success": true + }, + { + "name": "writer close() promise should not resolve until handshake completes", + "success": false, + "message": "assert_greater_than_equal: one second should have elapsed expected a number greater than or equal to 900 but got 0.27419499999996333" + }, + { + "name": "incomplete closing handshake should be considered unclean close", + "success": false, + "message": "assert_equals: close code should be Abnormal Closure expected 1006 but got 1005" + }, + { + "name": "aborting the writable should result in a clean close", + "success": true + }, + { + "name": "aborting the writable with attributes not wrapped in a WebSocketError should be ignored", + "success": true + }, + { + "name": "aborting the writable with a code should send that code", + "success": true + }, + { + "name": "aborting the writable with a code and reason should use them", + "success": true + }, + { + "name": "aborting the writable with a reason but no code should default the close code", + "success": true + }, + { + "name": "aborting the writable with a DOMException not set code or reason", + "success": true + }, + { + "name": "canceling the readable should result in a clean close", + "success": true + }, + { + "name": "canceling the readable with attributes not wrapped in a WebSocketError should be ignored", + "success": true + }, + { + "name": "canceling the readable with a code should send that code", + "success": true + }, + { + "name": "canceling the readable with a code and reason should use them", + "success": true + }, + { + "name": "canceling the readable with a reason but no code should default the close code", + "success": true + }, + { + "name": "canceling the readable with a DOMException not set code or reason", + "success": true + } + ] }, "constructor.any.html?wpt_flags=h2": { "success": true, @@ -43495,7 +42397,7 @@ "success": true, "cases": [ { - "name": "EventSource: lastEventId resets", + "name": "EventSource: lastEventId persists", "success": true }, { @@ -43503,7 +42405,7 @@ "success": true }, { - "name": "EventSource: lastEventId persists", + "name": "EventSource: lastEventId resets", "success": true } ]