summaryrefslogtreecommitdiffstats
path: root/test/wpt/server
diff options
context:
space:
mode:
Diffstat (limited to 'test/wpt/server')
-rw-r--r--test/wpt/server/routes/network-partition-key.mjs111
-rw-r--r--test/wpt/server/routes/redirect.mjs104
-rw-r--r--test/wpt/server/server.mjs397
-rw-r--r--test/wpt/server/websocket.mjs46
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)
+ })
+})