diff options
Diffstat (limited to 'test/wpt/server')
-rw-r--r-- | test/wpt/server/routes/network-partition-key.mjs | 111 | ||||
-rw-r--r-- | test/wpt/server/routes/redirect.mjs | 104 | ||||
-rw-r--r-- | test/wpt/server/server.mjs | 397 | ||||
-rw-r--r-- | test/wpt/server/websocket.mjs | 46 |
4 files changed, 658 insertions, 0 deletions
diff --git a/test/wpt/server/routes/network-partition-key.mjs b/test/wpt/server/routes/network-partition-key.mjs new file mode 100644 index 0000000..f1203f7 --- /dev/null +++ b/test/wpt/server/routes/network-partition-key.mjs @@ -0,0 +1,111 @@ +const stash = new Map() + +/** + * @see https://github.com/web-platform-tests/wpt/blob/master/fetch/connection-pool/resources/network-partition-key.py + * @param {Parameters<import('http').RequestListener>[0]} req + * @param {Parameters<import('http').RequestListener>[1]} res + * @param {URL} url + */ +export function route (req, res, { searchParams, port }) { + res.setHeader('Cache-Control', 'no-store') + + const dispatch = searchParams.get('dispatch') + const uuid = searchParams.get('uuid') + const partitionId = searchParams.get('partition_id') + + if (!uuid || !dispatch || !partitionId) { + res.statusCode = 404 + res.end('Invalid query parameters') + return + } + + let testFailed = false + let requestCount = 0 + let connectionCount = 0 + + if (searchParams.get('nocheck_partition') !== 'True') { + const addressKey = `${req.socket.localAddress}|${port}` + const serverState = stash.get(uuid) ?? { + testFailed: false, + requestCount: 0, + connectionCount: 0 + } + + stash.delete(uuid) + requestCount = serverState.requestCount + 1 + serverState.requestCount = requestCount + + if (Object.hasOwn(serverState, addressKey)) { + if (serverState[addressKey] !== partitionId) { + serverState.testFailed = true + } + } else { + connectionCount = serverState.connectionCount + 1 + serverState.connectionCount = connectionCount + } + + serverState[addressKey] = partitionId + testFailed = serverState.testFailed + stash.set(uuid, serverState) + } + + const origin = req.headers.origin + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + } + + if (req.method === 'OPTIONS') { + return handlePreflight(req, res) + } + + if (dispatch === 'fetch_file') { + res.end() + return + } + + if (dispatch === 'check_partition') { + const status = searchParams.get('status') ?? 200 + + if (testFailed) { + res.statusCode = status + res.end('Multiple partition IDs used on a socket') + return + } + + let body = 'ok' + if (searchParams.get('addcounter')) { + body += `. Request was sent ${requestCount} times. ${connectionCount} connections were created.` + res.statusCode = status + res.end(body) + return + } + } + + if (dispatch === 'clean_up') { + stash.delete(uuid) + res.statusCode = 200 + if (testFailed) { + res.end('Test failed, but cleanup completed.') + } else { + res.end('cleanup complete') + } + + return + } + + res.statusCode = 404 + res.end('Unrecognized dispatch parameter: ' + dispatch) +} + +/** + * @param {Parameters<import('http').RequestListener>[0]} req + * @param {Parameters<import('http').RequestListener>[1]} res + */ +function handlePreflight (req, res) { + res.statusCode = 200 + res.setHeader('Access-Control-Allow-Methods', 'GET') + res.setHeader('Access-Control-Allow-Headers', 'header-to-force-cors') + res.setHeader('Access-Control-Max-Age', '86400') + res.end('Preflight request') +} diff --git a/test/wpt/server/routes/redirect.mjs b/test/wpt/server/routes/redirect.mjs new file mode 100644 index 0000000..46770cf --- /dev/null +++ b/test/wpt/server/routes/redirect.mjs @@ -0,0 +1,104 @@ +import { setTimeout } from 'timers/promises' + +const stash = new Map() + +/** + * @see https://github.com/web-platform-tests/wpt/blob/master/fetch/connection-pool/resources/network-partition-key.py + * @param {Parameters<import('http').RequestListener>[0]} req + * @param {Parameters<import('http').RequestListener>[1]} res + * @param {URL} fullUrl + */ +export async function route (req, res, fullUrl) { + const { searchParams } = fullUrl + + let stashedData = { count: 0, preflight: 0 } + let status = 302 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Pragma', 'no-cache') + + if (Object.hasOwn(req.headers, 'origin')) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '') + res.setHeader('Access-Control-Allow-Credentials', 'true') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + let token = null + if (searchParams.has('token')) { + token = searchParams.get('token') + const data = stash.get(token) + stash.delete(token) + if (data) { + stashedData = data + } + } + + if (req.method === 'OPTIONS') { + if (searchParams.has('allow_headers')) { + res.setHeader('Access-Control-Allow-Headers', searchParams.get('allow_headers')) + } + + stashedData.preflight = '1' + + if (!searchParams.has('redirect_preflight')) { + if (token) { + stash.set(searchParams.get('token'), stashedData) + } + + res.statusCode = 200 + res.end('') + return + } + } + + if (searchParams.has('redirect_status')) { + status = parseInt(searchParams.get('redirect_status')) + } + + stashedData.count += 1 + + if (searchParams.has('location')) { + let url = decodeURIComponent(searchParams.get('location')) + + if (!searchParams.has('simple')) { + const scheme = new URL(url, fullUrl).protocol + + if (scheme === 'http:' || scheme === 'https:') { + url += url.includes('?') ? '&' : '?' + + for (const [key, value] of searchParams) { + url += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(value) + } + + url += '&count=' + stashedData.count + } + } + + res.setHeader('location', url) + } + + if (searchParams.has('redirect_referrerpolicy')) { + res.setHeader('Referrer-Policy', searchParams.get('redirect_referrerpolicy')) + } + + if (searchParams.has('delay')) { + await setTimeout(parseFloat(searchParams.get('delay') ?? 0)) + } + + if (token) { + stash.set(searchParams.get('token'), stashedData) + + if (searchParams.has('max_count')) { + const maxCount = parseInt(searchParams.get('max_count')) + + if (stashedData.count > maxCount) { + res.end((stashedData.count - 1).toString()) + return + } + } + } + + res.statusCode = status + res.end('') +} diff --git a/test/wpt/server/server.mjs b/test/wpt/server/server.mjs new file mode 100644 index 0000000..82b9080 --- /dev/null +++ b/test/wpt/server/server.mjs @@ -0,0 +1,397 @@ +import { once } from 'node:events' +import { createServer } from 'node:http' +import { join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { createReadStream, readFileSync, existsSync } from 'node:fs' +import { setTimeout as sleep } from 'node:timers/promises' +import { route as networkPartitionRoute } from './routes/network-partition-key.mjs' +import { route as redirectRoute } from './routes/redirect.mjs' + +const tests = fileURLToPath(join(import.meta.url, '../../tests')) + +// https://web-platform-tests.org/tools/wptserve/docs/stash.html +class Stash extends Map { + take (key) { + if (this.has(key)) { + const value = this.get(key) + + this.delete(key) + return value.value + } + } + + put (key, value, path) { + this.set(key, { value, path }) + } +} + +const stash = new Stash() + +const server = createServer(async (req, res) => { + const fullUrl = new URL(req.url, `http://localhost:${server.address().port}`) + + switch (fullUrl.pathname) { + case '/service-workers/cache-storage/resources/blank.html': { + res.setHeader('content-type', 'text/html') + // fall through + } + case '/service-workers/cache-storage/resources/simple.txt': + case '/fetch/content-encoding/resources/foo.octetstream.gz': + case '/fetch/content-encoding/resources/foo.text.gz': + case '/fetch/api/resources/cors-top.txt': + case '/fetch/api/resources/top.txt': + case '/mimesniff/mime-types/resources/generated-mime-types.json': + case '/mimesniff/mime-types/resources/mime-types.json': + case '/interfaces/dom.idl': + case '/interfaces/url.idl': + case '/interfaces/html.idl': + case '/interfaces/fetch.idl': + case '/interfaces/FileAPI.idl': + case '/interfaces/websockets.idl': + case '/interfaces/referrer-policy.idl': + case '/xhr/resources/utf16-bom.json': + case '/fetch/data-urls/resources/base64.json': + case '/fetch/data-urls/resources/data-urls.json': + case '/fetch/api/resources/empty.txt': + case '/fetch/api/resources/data.json': { + // If this specific resources requires custom headers + const customHeadersPath = join(tests, fullUrl.pathname + '.headers') + if (existsSync(customHeadersPath)) { + const headers = readFileSync(customHeadersPath, 'utf-8') + .trim() + .split(/\r?\n/g) + .map((h) => h.split(': ')) + + for (const [key, value] of headers) { + if (!key || !value) { + console.warn(`Skipping ${key}:${value} header pair`) + continue + } + res.setHeader(key, value) + } + } + + // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/data.json + return createReadStream(join(tests, fullUrl.pathname)) + .on('end', () => res.end()) + .pipe(res) + } + case '/fetch/api/resources/trickle.py': { + // Note: python's time.sleep(...) takes seconds, while setTimeout + // takes ms. + const delay = parseFloat(fullUrl.searchParams.get('ms') ?? 500) + const count = parseInt(fullUrl.searchParams.get('count') ?? 50) + + // eslint-disable-next-line no-unused-vars + for await (const chunk of req); // read request body + + await sleep(delay) + + if (!fullUrl.searchParams.has('notype')) { + res.setHeader('Content-type', 'text/plain') + } + + res.statusCode = 200 + await sleep(delay) + + for (let i = 0; i < count; i++) { + res.write('TEST_TRICKLE\n') + await sleep(delay) + } + + res.end() + break + } + case '/fetch/api/resources/infinite-slow-response.py': { + // https://github.com/web-platform-tests/wpt/blob/master/fetch/api/resources/infinite-slow-response.py + const stateKey = fullUrl.searchParams.get('stateKey') ?? '' + const abortKey = fullUrl.searchParams.get('abortKey') ?? '' + + if (stateKey) { + stash.put(stateKey, 'open', fullUrl.pathname) + } + + res.setHeader('Content-Type', 'text/plain') + res.statusCode = 200 + + res.write('.'.repeat(2048)) + + while (true) { + if (!res.write('.')) { + break + } else if (abortKey && stash.take(abortKey)) { + break + } + + await sleep(100) + } + + if (stateKey) { + stash.put(stateKey, 'closed', fullUrl.pathname) + } + + res.end() + return + } + case '/fetch/api/resources/stash-take.py': { + // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/stash-take.py + + const key = fullUrl.searchParams.get('key') + res.setHeader('Access-Control-Allow-Origin', '*') + + const took = stash.take(key, fullUrl.pathname) ?? null + + res.write(JSON.stringify(took)) + return res.end() + } + case '/fetch/api/resources/echo-content.py': { + res.setHeader('X-Request-Method', req.method) + res.setHeader('X-Request-Content-Length', req.headers['content-length'] ?? 'NO') + res.setHeader('X-Request-Content-Type', req.headers['content-type'] ?? 'NO') + res.setHeader('Content-Type', 'text/plain') + + for await (const chunk of req) { + res.write(chunk) + } + + res.end() + break + } + case '/fetch/api/resources/status.py': { + const code = parseInt(fullUrl.searchParams.get('code') ?? 200) + const text = fullUrl.searchParams.get('text') ?? 'OMG' + const content = fullUrl.searchParams.get('content') ?? '' + const type = fullUrl.searchParams.get('type') ?? '' + res.statusCode = code + res.statusMessage = text + res.setHeader('Content-Type', type) + res.setHeader('X-Request-Method', req.method) + res.end(content) + break + } + case '/fetch/api/resources/inspect-headers.py': { + const query = fullUrl.searchParams + const checkedHeaders = query.get('headers') + ?.split('|') + .map(h => h.toLowerCase()) ?? [] + + if (query.has('headers')) { + for (const header of checkedHeaders) { + if (Object.hasOwn(req.headers, header)) { + res.setHeader(`x-request-${header}`, req.headers[header] ?? '') + } + } + } + + if (query.has('cors')) { + if (Object.hasOwn(req.headers, 'origin')) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, HEAD') + const exposedHeaders = checkedHeaders.map(h => `x-request-${h}`).join(', ') + res.setHeader('Access-Control-Expose-Headers', exposedHeaders) + if (query.has('allow_headers')) { + res.setHeader('Access-Control-Allow-Headers', query.get('allowed_headers')) + } else { + res.setHeader('Access-Control-Allow-Headers', Object.keys(req.headers).join(', ')) + } + } + + res.setHeader('content-type', 'text/plain') + res.end('') + break + } + case '/xhr/resources/parse-headers.py': { + if (fullUrl.searchParams.has('my-custom-header')) { + const val = fullUrl.searchParams.get('my-custom-header').toLowerCase() + // res.setHeader does validation which may prevent some tests from running. + res.socket.write( + `HTTP/1.1 200 OK\r\nmy-custom-header: ${val}\r\n\r\n` + ) + } + res.end('') + break + } + case '/fetch/api/resources/bad-chunk-encoding.py': { + const query = fullUrl.searchParams + + const delay = parseFloat(query.get('ms') ?? 1000) + const count = parseInt(query.get('count') ?? 50) + await sleep(delay) + res.socket.write( + 'HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n' + ) + await sleep(delay) + + for (let i = 0; i < count; i++) { + res.socket.write('a\r\nTEST_CHUNK\r\n') + await sleep(delay) + } + + res.end('garbage') + break + } + case '/xhr/resources/headers-www-authenticate.asis': + case '/xhr/resources/headers-some-are-empty.asis': + case '/xhr/resources/headers-basic': + case '/xhr/resources/headers-double-empty.asis': + case '/xhr/resources/header-content-length-twice.asis': + case '/xhr/resources/header-content-length.asis': { + let asis = readFileSync(join(tests, fullUrl.pathname), 'utf-8') + asis = asis.replace(/\n/g, '\r\n') + asis = `${asis}\r\n` + + res.socket.write(asis) + res.end() + break + } + case '/fetch/connection-pool/resources/network-partition-key.py': { + return networkPartitionRoute(req, res, fullUrl) + } + case '/resources/top.txt': { + return createReadStream(join(tests, 'fetch/api/', fullUrl.pathname)) + .on('end', () => res.end()) + .pipe(res) + } + case '/fetch/api/resources/redirect.py': { + return redirectRoute(req, res, fullUrl) + } + case '/fetch/api/resources/method.py': { + if (fullUrl.searchParams.has('cors')) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, FOO') + res.setHeader('Access-Control-Allow-Headers', 'x-test, x-foo') + res.setHeader('Access-Control-Expose-Headers', 'x-request-method') + } + + res.setHeader('x-request-method', req.method) + res.setHeader('x-request-content-type', req.headers['content-type'] ?? 'NO') + res.setHeader('x-request-content-length', req.headers['content-length'] ?? 'NO') + res.setHeader('x-request-content-encoding', req.headers['content-encoding'] ?? 'NO') + res.setHeader('x-request-content-language', req.headers['content-language'] ?? 'NO') + res.setHeader('x-request-content-location', req.headers['content-location'] ?? 'NO') + + for await (const chunk of req) { + res.write(chunk) + } + + res.end() + return + } + case '/fetch/api/resources/clean-stash.py': { + const token = fullUrl.searchParams.get('token') + const took = stash.take(token) + + if (took) { + res.end('1') + } else { + res.end('0') + } + + break + } + case '/fetch/content-encoding/resources/bad-gzip-body.py': { + res.setHeader('Content-Encoding', 'gzip') + res.end('not actually gzip') + break + } + case '/fetch/api/resources/dump-authorization-header.py': { + res.setHeader('Content-Type', 'text/html') + res.setHeader('Cache-Control', 'no-cache') + + if (req.headers.origin) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + } else { + res.setHeader('Access-Control-Allow-Origin', '*') + } + + res.setHeader('Access-Control-Allow-Headers', 'Authorization') + res.statusCode = 200 + + if (req.headers.authorization) { + res.end(req.headers.authorization) + return + } + + res.end('none') + break + } + case '/xhr/resources/echo-headers.py': { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + + // wpt runner sends this as 1 chunk + let body = '' + + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const key = req.rawHeaders[i] + const value = req.rawHeaders[i + 1] + + body += `${key}: ${value}` + } + + res.end(body) + break + } + case '/fetch/api/resources/authentication.py': { + const auth = Buffer.from(req.headers.authorization.slice('Basic '.length), 'base64') + const [user, password] = auth.toString().split(':') + + if (user === 'user' && password === 'password') { + res.end('Authentication done') + return + } + + const realm = fullUrl.searchParams.get('realm') ?? 'test' + + res.statusCode = 401 + res.setHeader('WWW-Authenticate', `Basic realm="${realm}"`) + res.end('Please login with credentials \'user\' and \'password\'') + return + } + case '/fetch/api/resources/redirect-empty-location.py': { + res.setHeader('location', '') + res.statusCode = 302 + res.end('') + return + } + case '/service-workers/cache-storage/resources/fetch-status.py': { + const status = Number(fullUrl.searchParams.get('status')) + + res.statusCode = status + res.end() + return + } + default: { + res.statusCode = 200 + res.end(fullUrl.toString()) + } + } +}).listen(0) + +await once(server, 'listening') + +const send = (message) => { + if (typeof process.send === 'function') { + process.send(message) + } +} + +const url = `http://localhost:${server.address().port}` +console.log('server opened ' + url) +send({ server: url }) + +process.on('message', (message) => { + if (message === 'shutdown') { + server.close((err) => process.exit(err ? 1 : 0)) + } +}) + +export { server } diff --git a/test/wpt/server/websocket.mjs b/test/wpt/server/websocket.mjs new file mode 100644 index 0000000..cc8ce78 --- /dev/null +++ b/test/wpt/server/websocket.mjs @@ -0,0 +1,46 @@ +import { WebSocketServer } from 'ws' +import { server } from './server.mjs' + +// The file router server handles sending the url, closing, +// and sending messages back to the main process for us. +// The types for WebSocketServer don't include a `request` +// event, so I'm unsure if we can stop relying on server. + +const wss = new WebSocketServer({ + server, + handleProtocols: (protocols) => [...protocols].join(', ') +}) + +wss.on('connection', (ws, request) => { + ws.on('message', (data, isBinary) => { + const str = data.toString('utf-8') + + if (request.url === '/receive-many-with-backpressure') { + setTimeout(() => { + ws.send(str.length.toString(), { binary: false }) + }, 100) + return + } + + if (str === 'Goodbye') { + // Close-server-initiated-close.any.js sends a "Goodbye" message + // when it wants the server to close the connection. + ws.close(1000) + return + } + + ws.send(data, { binary: isBinary }) + }) + + // Some tests, such as `Create-blocked-port.any.js` do NOT + // close the connection automatically. + const timeout = setTimeout(() => { + if (ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) { + ws.close() + } + }, 2500) + + ws.on('close', () => { + clearTimeout(timeout) + }) +}) |