diff --git a/tests/client.test.ts b/tests/client.test.ts index f1b0cf8d..7a28bc71 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,20 +1,37 @@ import { it, expect } from 'vitest'; -import fetch from 'node-fetch'; +import { createTHandler } from './thandler'; import { RequestHeaders } from '../src/handler'; import { createClient, NetworkError } from '../src/client'; -import { startTServer } from './utils/tserver'; -import { texecute } from './utils/texecute'; +import { ExecutionResult } from 'graphql'; +import { RequestParams } from '../src/common'; +import { Client } from '../src/client'; + +function texecute( + client: Client, + params: RequestParams, +): [request: Promise>, cancel: () => void] { + let cancel!: () => void; + const request = new Promise>((resolve, reject) => { + let result: ExecutionResult; + cancel = client.subscribe(params, { + next: (data) => (result = data), + error: reject, + complete: () => resolve(result), + }); + }); + return [request, cancel]; +} it('should use the provided headers', async () => { let headers: RequestHeaders = {}; - const server = startTServer({ + const { fetch } = createTHandler({ onSubscribe: (req) => { headers = req.headers; }, }); const client = createClient({ - url: server.url, + url: 'http://localhost', fetchFn: fetch, headers: async () => { return { 'x-some': 'header' }; @@ -24,14 +41,45 @@ it('should use the provided headers', async () => { const [request] = texecute(client, { query: '{ hello }' }); await request; - expect(headers['x-some']).toBe('header'); + expect(headers).toMatchInlineSnapshot(` + Headers { + Symbol(headers list): HeadersList { + "cookies": null, + Symbol(headers map): Map { + "x-some" => { + "name": "x-some", + "value": "header", + }, + "content-type" => { + "name": "content-type", + "value": "application/json; charset=utf-8", + }, + "accept" => { + "name": "accept", + "value": "application/graphql-response+json, application/json", + }, + }, + Symbol(headers map sorted): null, + }, + Symbol(guard): "request", + Symbol(realm): { + "settingsObject": { + "baseUrl": undefined, + "origin": undefined, + "policyContainer": { + "referrerPolicy": "strict-origin-when-cross-origin", + }, + }, + }, + } + `); }); it('should execute query, next the result and then complete', async () => { - const server = startTServer(); + const { fetch } = createTHandler(); const client = createClient({ - url: server.url, + url: 'http://localhost', fetchFn: fetch, }); @@ -43,10 +91,10 @@ it('should execute query, next the result and then complete', async () => { }); it('should execute mutation, next the result and then complete', async () => { - const server = startTServer(); + const { fetch } = createTHandler(); const client = createClient({ - url: server.url, + url: 'http://localhost', fetchFn: fetch, }); @@ -58,10 +106,10 @@ it('should execute mutation, next the result and then complete', async () => { }); it('should report invalid request', async () => { - const server = startTServer(); + const { fetch } = createTHandler(); const client = createClient({ - url: server.url, + url: 'http://localhost', fetchFn: fetch, }); diff --git a/tests/handler.test.ts b/tests/handler.test.ts index 3211695e..c18574f8 100644 --- a/tests/handler.test.ts +++ b/tests/handler.test.ts @@ -1,130 +1,148 @@ import { vi, it, expect } from 'vitest'; import { GraphQLError } from 'graphql'; -import fetch from 'node-fetch'; -import { Request } from '../src/handler'; -import { startTServer } from './utils/tserver'; +import { createTHandler } from './thandler'; +import { schema } from './fixtures/simple'; it.each(['schema', 'context', 'onSubscribe', 'onOperation'])( 'should use the response returned from %s', async (option) => { - const server = startTServer({ + const { request } = createTHandler({ [option]: () => { return [null, { status: 418 }]; }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const res = await fetch(url.toString()); - expect(res.status).toBe(418); + const [body, init] = await request('GET', { query: '{ __typename }' }); + + expect(body).toBeNull(); + expect(init.status).toBe(418); }, ); it('should report graphql errors returned from onSubscribe', async () => { - const server = startTServer({ + const { request } = createTHandler({ onSubscribe: () => { return [new GraphQLError('Woah!')]; }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const res = await fetch(url.toString()); - expect(res.json()).resolves.toEqual({ errors: [{ message: 'Woah!' }] }); + await expect(request('GET', { query: '{ __typename }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Woah!\\"}]}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", + }, + "status": 400, + "statusText": "Bad Request", + }, + ] + `); }); it('should respond with result returned from onSubscribe', async () => { - const onOperationFn = vi.fn(() => { - // noop - }); - const server = startTServer({ + const onOperationFn = vi.fn(); + const { request } = createTHandler({ onSubscribe: () => { return { data: { __typename: 'Query' } }; }, onOperation: onOperationFn, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const res = await fetch(url.toString()); - expect(res.status).toBe(200); - expect(res.json()).resolves.toEqual({ data: { __typename: 'Query' } }); + await expect(request('GET', { query: '{ __typename }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"data\\":{\\"__typename\\":\\"Query\\"}}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", + }, + "status": 200, + "statusText": "OK", + }, + ] + `); expect(onOperationFn).not.toBeCalled(); // early result, operation did not happen }); it.each(['schema', 'context', 'onSubscribe', 'onOperation'])( 'should provide the request context to %s', async (option) => { - const optionFn = vi.fn((_req: Request) => { - // noop - }); + const optionFn = vi.fn(); const context = {}; - const server = startTServer({ - changeRequest: (req) => ({ - ...req, - context, - }), + const { handler } = createTHandler({ [option]: optionFn, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - await fetch(url.toString()); + await handler({ + method: 'GET', + url: + 'http://localhost?' + + new URLSearchParams({ query: '{ __typename }' }).toString(), + headers: {}, + body: null, + raw: null, + context, + }).catch(() => { + // schema option breaks, but we don't care + }); - expect(optionFn.mock.calls[0][0]?.context).toBe(context); + expect(optionFn.mock.lastCall?.[0].context).toBe(context); }, ); it('should respond with error if execution result is iterable', async () => { - const server = startTServer({ - // @ts-expect-error live queries for example + const { request } = createTHandler({ execute: () => { return { [Symbol.asyncIterator]() { return this; }, - }; + } as any; }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const result = await fetch(url.toString()); - expect(result.json()).resolves.toEqual({ - errors: [ + await expect(request('GET', { query: '{ __typename }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Subscriptions are not supported\\"}]}", { - message: 'Subscriptions are not supported', + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", + }, + "status": 400, + "statusText": "Bad Request", }, - ], - }); + ] + `); }); it('should correctly serialise execution result errors', async () => { - const server = startTServer(); - const url = new URL(server.url); - url.searchParams.set('query', 'query ($num: Int) { num(num: $num) }'); - url.searchParams.set('variables', JSON.stringify({ num: 'foo' })); - const result = await fetch(url.toString()); - expect(result.json()).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "locations": [ - { - "column": 8, - "line": 1, - }, - ], - "message": "Variable \\"$num\\" got invalid value \\"foo\\"; Int cannot represent non-integer value: \\"foo\\"", + const { request } = createTHandler({ schema }); + + await expect( + request('GET', { + query: 'query ($num: Int) { num(num: $num) }', + variables: { num: 'foo' }, + }), + ).resolves.toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Variable \\\\\\"$num\\\\\\" got invalid value \\\\\\"foo\\\\\\"; Int cannot represent non-integer value: \\\\\\"foo\\\\\\"\\",\\"locations\\":[{\\"line\\":1,\\"column\\":8}]}]}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", }, - ], - } + "status": 200, + "statusText": "OK", + }, + ] `); }); it('should append the provided validation rules array', async () => { - const server = startTServer({ + const { request } = createTHandler({ validationRules: [ (ctx) => { ctx.reportError(new GraphQLError('Woah!')); @@ -132,31 +150,24 @@ it('should append the provided validation rules array', async () => { }, ], }); - const url = new URL(server.url); - url.searchParams.set('query', '{ idontexist }'); - const result = await fetch(url.toString()); - await expect(result.json()).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "message": "Woah!", - }, - { - "locations": [ - { - "column": 3, - "line": 1, - }, - ], - "message": "Cannot query field \\"idontexist\\" on type \\"Query\\".", + + await expect(request('GET', { query: '{ idontexist }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Woah!\\"},{\\"message\\":\\"Cannot query field \\\\\\"idontexist\\\\\\" on type \\\\\\"Query\\\\\\".\\",\\"locations\\":[{\\"line\\":1,\\"column\\":3}]}]}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", }, - ], - } + "status": 400, + "statusText": "Bad Request", + }, + ] `); }); it('should replace the validation rules when providing a function', async () => { - const server = startTServer({ + const { request } = createTHandler({ validationRules: () => [ (ctx) => { ctx.reportError(new GraphQLError('Woah!')); @@ -164,49 +175,65 @@ it('should replace the validation rules when providing a function', async () => }, ], }); - const url = new URL(server.url); - url.searchParams.set('query', '{ idontexist }'); - const result = await fetch(url.toString()); - await expect(result.json()).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "message": "Woah!", + + await expect(request('GET', { query: '{ idontexist }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Woah!\\"}]}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", }, - ], - } + "status": 400, + "statusText": "Bad Request", + }, + ] `); }); it('should print plain errors in detail', async () => { - const server = startTServer({}); - const url = new URL(server.url); - const result = await fetch(url.toString(), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - // missing body - }); - await expect(result.text()).resolves.toMatchInlineSnapshot( - '"{\\"errors\\":[{\\"message\\":\\"Unparsable JSON body\\"}]}"', - ); + const { handler } = createTHandler({ schema }); + + await expect( + handler({ + method: 'POST', + url: 'http://localhost', + headers: { 'content-type': 'application/json' }, + body: null, // missing body + raw: null, + context: null, + }), + ).resolves.toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Missing body\\"}]}", + { + "headers": { + "content-type": "application/json; charset=utf-8", + }, + "status": 400, + "statusText": "Bad Request", + }, + ] + `); }); it('should format errors using the formatter', async () => { const formatErrorFn = vi.fn((_err) => new Error('Formatted')); - const server = startTServer({ + const { request } = createTHandler({ formatError: formatErrorFn, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ idontexist }'); - const res = await fetch(url.toString()); - expect(res.json()).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "message": "Formatted", + await expect(request('GET', { query: '{ idontexist }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Formatted\\"}]}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", }, - ], - } + "status": 400, + "statusText": "Bad Request", + }, + ] `); expect(formatErrorFn).toBeCalledTimes(1); expect(formatErrorFn.mock.lastCall?.[0]).toMatchInlineSnapshot( @@ -227,26 +254,26 @@ it('should respect plain errors toJSON implementation', async () => { } } const formatErrorFn = vi.fn((_err) => new MyError('Custom toJSON')); - const server = startTServer({ + const { request } = createTHandler({ formatError: formatErrorFn, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ idontexist }'); - const res = await fetch(url.toString()); - expect(res.json()).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "message": "Custom toJSON", - "toJSON": "used", + await expect(request('GET', { query: '{ idontexist }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Custom toJSON\\",\\"toJSON\\":\\"used\\"}]}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", }, - ], - } + "status": 400, + "statusText": "Bad Request", + }, + ] `); }); it('should use the custom request params parser', async () => { - const server = startTServer({ + const { handler } = createTHandler({ parseRequestParams() { return { query: '{ hello }', @@ -254,25 +281,32 @@ it('should use the custom request params parser', async () => { }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const res = await fetch(url.toString(), { - // different methods and content-types are not disallowed by the spec - method: 'PUT', - headers: { 'content-type': 'application/lol' }, - }); - - await expect(res.json()).resolves.toMatchInlineSnapshot(` - { - "data": { - "hello": "world", + await expect( + handler({ + // different methods and content-types are not disallowed by the spec + method: 'PUT', + url: 'http://localhost', + headers: { 'content-type': 'application/lol' }, + body: null, + raw: null, + context: null, + }), + ).resolves.toMatchInlineSnapshot(` + [ + "{\\"data\\":{\\"hello\\":\\"world\\"}}", + { + "headers": { + "content-type": "application/json; charset=utf-8", + }, + "status": 200, + "statusText": "OK", }, - } + ] `); }); it('should use the response returned from the custom request params parser', async () => { - const server = startTServer({ + const { request } = createTHandler({ parseRequestParams() { return [ 'Hello', @@ -281,79 +315,103 @@ it('should use the response returned from the custom request params parser', asy }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const res = await fetch(url.toString()); - - expect(res.ok).toBeTruthy(); - expect(res.headers.get('x-hi')).toBe('there'); - await expect(res.text()).resolves.toBe('Hello'); + await expect(request('GET', { query: '{ __typename }' })).resolves + .toMatchInlineSnapshot(` + [ + "Hello", + { + "headers": { + "x-hi": "there", + }, + "status": 200, + "statusText": "OK", + }, + ] + `); }); it('should report thrown Error from custom request params parser', async () => { - const server = startTServer({ + const { request } = createTHandler({ parseRequestParams() { throw new Error('Wrong.'); }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const res = await fetch(url.toString()); - - expect(res.status).toBe(400); - await expect(res.json()).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "message": "Wrong.", + await expect(request('GET', { query: '{ __typename }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Wrong.\\"}]}", + { + "headers": { + "content-type": "application/json; charset=utf-8", }, - ], - } + "status": 400, + "statusText": "Bad Request", + }, + ] `); }); it('should report thrown GraphQLError from custom request params parser', async () => { - const server = startTServer({ + const { request } = createTHandler({ parseRequestParams() { throw new GraphQLError('Wronger.'); }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ __typename }'); - const res = await fetch(url.toString(), { - headers: { accept: 'application/json' }, - }); + await expect( + request( + 'GET', + { query: '{ __typename }' }, + { accept: 'application/graphql-response+json' }, + ), + ).resolves.toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Wronger.\\"}]}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", + }, + "status": 400, + "statusText": "Bad Request", + }, + ] + `); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toMatchInlineSnapshot(` - { - "errors": [ - { - "message": "Wronger.", + await expect( + request('GET', { query: '{ __typename }' }, { accept: 'application/json' }), + ).resolves.toMatchInlineSnapshot(` + [ + "{\\"errors\\":[{\\"message\\":\\"Wronger.\\"}]}", + { + "headers": { + "content-type": "application/json; charset=utf-8", }, - ], - } + "status": 200, + "statusText": "OK", + }, + ] `); }); it('should use the default if nothing is returned from the custom request params parser', async () => { - const server = startTServer({ + const { request } = createTHandler({ parseRequestParams() { return; }, }); - const url = new URL(server.url); - url.searchParams.set('query', '{ hello }'); - const res = await fetch(url.toString()); - - await expect(res.json()).resolves.toMatchInlineSnapshot(` - { - "data": { - "hello": "world", + await expect(request('GET', { query: '{ __typename }' })).resolves + .toMatchInlineSnapshot(` + [ + "{\\"data\\":{\\"__typename\\":\\"Query\\"}}", + { + "headers": { + "content-type": "application/graphql-response+json; charset=utf-8", + }, + "status": 200, + "statusText": "OK", }, - } + ] `); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 54cee0cf..ff47f141 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1,13 +1,11 @@ import { it } from 'vitest'; -import fetch from 'node-fetch'; +import { createTHandler } from './thandler'; import { serverAudits } from '../src/audits/server'; - import { schema } from './fixtures/simple'; -import { startTServer } from './utils/tserver'; -const server = startTServer({ schema }); +const { fetch } = createTHandler({ schema }); -for (const audit of serverAudits({ url: server.url, fetchFn: fetch })) { +for (const audit of serverAudits({ url: 'http://localhost', fetchFn: fetch })) { it(audit.name, async () => { const result = await audit.fn(); if (result.status !== 'ok') { diff --git a/tests/thandler.ts b/tests/thandler.ts new file mode 100644 index 00000000..59fd74cc --- /dev/null +++ b/tests/thandler.ts @@ -0,0 +1,70 @@ +import { + createHandler, + HandlerOptions, + RequestHeaders, + Response, + Handler, +} from '../src/handler'; +import { RequestParams } from '../src/common'; +import { schema } from './fixtures/simple'; + +export interface THandler { + handler: Handler; + request( + method: 'GET', + search: RequestParams, + headers?: RequestHeaders, + ): Promise; + request( + method: 'POST', + body: RequestParams, + headers?: RequestHeaders, + ): Promise; + fetch( + input: globalThis.RequestInfo, + init?: globalThis.RequestInit, + ): Promise; +} + +export function createTHandler(opts: HandlerOptions = {}): THandler { + const handler = createHandler({ schema, ...opts }); + return { + handler, + request(method, params, headers = {}): Promise { + const search = method === 'GET' ? new URLSearchParams() : null; + if (params.operationName) + search?.set('operationName', params.operationName); + search?.set('query', params.query); + if (params.variables) + search?.set('variables', JSON.stringify(params.variables)); + if (params.extensions) + search?.set('extensions', JSON.stringify(params.extensions)); + return handler({ + method, + url: search + ? `http://localhost?${search.toString()}` + : 'http://localhost', + headers: { + accept: 'application/graphql-response+json', + 'content-type': search ? undefined : 'application/json', + ...headers, + }, + body: search ? null : JSON.stringify(params), + raw: null, + context: null, + }); + }, + async fetch(input, init) { + const req = new globalThis.Request(input, init); + const res = await handler({ + method: req.method, + url: req.url, + headers: req.headers, + body: () => req.text(), + raw: req, + context: null, + }); + return new globalThis.Response(...res); + }, + }; +} diff --git a/tests/use.test.ts b/tests/use.test.ts index a0a1f959..003c07b2 100644 --- a/tests/use.test.ts +++ b/tests/use.test.ts @@ -1,22 +1,57 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import net from 'net'; import { fetch } from '@whatwg-node/fetch'; -import http from 'http'; -import express from 'express'; -import fastify from 'fastify'; -import Koa from 'koa'; -import mount from 'koa-mount'; -import { createServerAdapter } from '@whatwg-node/server'; -import uWS from 'uWebSockets.js'; -import { startDisposableServer } from './utils/tserver'; import { serverAudits } from '../src/audits'; import { schema } from './fixtures/simple'; +import http from 'http'; import { createHandler as createHttpHandler } from '../src/use/http'; +import express from 'express'; import { createHandler as createExpressHandler } from '../src/use/express'; +import fastify from 'fastify'; import { createHandler as createFastifyHandler } from '../src/use/fastify'; -import { createHandler as createFetchHandler } from '../src/use/fetch'; +import Koa from 'koa'; +import mount from 'koa-mount'; import { createHandler as createKoaHandler } from '../src/use/koa'; +import uWS from 'uWebSockets.js'; import { createHandler as createUWSHandler } from '../src/use/uWebSockets'; +import { createHandler as createFetchHandler } from '../src/use/fetch'; + +type Dispose = () => Promise; + +const leftovers: Dispose[] = []; +afterAll(async () => { + while (leftovers.length > 0) { + await leftovers.pop()?.(); + } +}); + +function startDisposableServer( + server: http.Server, +): [url: string, port: number, dispose: Dispose] { + const sockets = new Set(); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + const dispose = async () => { + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + }; + leftovers.push(dispose); + + if (!server.listening) { + server.listen(0); + } + + const { port } = server.address() as net.AddressInfo; + const url = `http://localhost:${port}`; + + return [url, port, dispose]; +} describe('http', () => { const [url, , dispose] = startDisposableServer( @@ -164,12 +199,12 @@ describe('fastify', () => { }); describe('fetch', () => { - const [url, , dispose] = startDisposableServer( - http.createServer(createServerAdapter(createFetchHandler({ schema }))), - ); - afterAll(dispose); + const handler = createFetchHandler({ schema }); - for (const audit of serverAudits({ url, fetchFn: fetch })) { + for (const audit of serverAudits({ + url: 'http://localhost', + fetchFn: (input: any, init: any) => handler(new Request(input, init)), + })) { it(audit.name, async () => { const result = await audit.fn(); if (result.status !== 'ok') { diff --git a/tests/utils/texecute.ts b/tests/utils/texecute.ts deleted file mode 100644 index a803298d..00000000 --- a/tests/utils/texecute.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ExecutionResult } from 'graphql'; -import { RequestParams } from '../../src/common'; -import { Client } from '../../src/client'; - -export function texecute( - client: Client, - params: RequestParams, -): [request: Promise>, cancel: () => void] { - let cancel!: () => void; - const request = new Promise>((resolve, reject) => { - let result: ExecutionResult; - cancel = client.subscribe(params, { - next: (data) => (result = data), - error: reject, - complete: () => resolve(result), - }); - }); - return [request, cancel]; -} diff --git a/tests/utils/tserver.ts b/tests/utils/tserver.ts deleted file mode 100644 index 6cf9cb16..00000000 --- a/tests/utils/tserver.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterAll } from 'vitest'; -import http from 'http'; -import net from 'net'; -import { Request, createHandler, HandlerOptions } from '../../src/handler'; -import { schema } from '../fixtures/simple'; - -type Dispose = () => Promise; - -const leftovers: Dispose[] = []; -afterAll(async () => { - while (leftovers.length > 0) { - await leftovers.pop()?.(); - } -}); - -export interface TServer { - url: string; - dispose: Dispose; -} -export function startTServer( - options: HandlerOptions & { - changeRequest?: ( - req: Request, - ) => Request; - } = {}, -): TServer { - const { changeRequest = (req) => req, ...handlerOptions } = options; - const handle = createHandler({ - schema, - ...handlerOptions, - }); - const [url, , dispose] = startDisposableServer( - http.createServer(async (req, res) => { - try { - if (!req.url) { - throw new Error('Missing request URL'); - } - if (!req.method) { - throw new Error('Missing request method'); - } - const [body, init] = await handle( - changeRequest({ - url: req.url, - method: req.method, - headers: req.headers, - body: () => - new Promise((resolve) => { - let body = ''; - req.on('data', (chunk) => (body += chunk)); - req.on('end', () => resolve(body)); - }), - raw: req, - context: null, - }), - ); - res.writeHead(init.status, init.statusText, init.headers).end(body); - } catch (err) { - if (err instanceof Error) { - res.writeHead(500).end(err.message); - } else { - res - .writeHead(500, { ContentType: 'application/json' }) - .end(JSON.stringify(err)); - } - } - }), - ); - return { - url, - dispose, - }; -} - -/** - * Starts a disposable server thet is really stopped when the dispose func resolves. - * - * Additionally adds the server kill function to the post tests `leftovers` - * to be invoked after each test. - */ -export function startDisposableServer( - server: http.Server, -): [url: string, port: number, dispose: Dispose] { - const sockets = new Set(); - server.on('connection', (socket) => { - sockets.add(socket); - socket.once('close', () => sockets.delete(socket)); - }); - - const dispose = async () => { - for (const socket of sockets) { - socket.destroy(); - } - await new Promise((resolve) => server.close(() => resolve())); - }; - leftovers.push(dispose); - - if (!server.listening) { - server.listen(0); - } - - const { port } = server.address() as net.AddressInfo; - const url = `http://localhost:${port}`; - - return [url, port, dispose]; -}