From cc9fa766c2d233999d4c5716c7fb9a80ac944205 Mon Sep 17 00:00:00 2001 From: Michael Solomon Date: Sat, 6 Jul 2024 17:33:12 +0300 Subject: [PATCH 1/3] added a failing test --- .../request/http-request-continue.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 test/modules/http/request/http-request-continue.test.ts diff --git a/test/modules/http/request/http-request-continue.test.ts b/test/modules/http/request/http-request-continue.test.ts new file mode 100644 index 00000000..06caeda5 --- /dev/null +++ b/test/modules/http/request/http-request-continue.test.ts @@ -0,0 +1,40 @@ +import { it, expect, beforeAll, afterAll } from 'vitest' +import http from 'http' +import { HttpServer } from '@open-draft/test-server/http' +import { waitForClientRequest } from '../../../helpers' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' + +const interceptor = new ClientRequestInterceptor() + +const httpServer = new HttpServer((app) => { + app.get('/resource', (req, res) => { + res.send('original response') + }) +}) + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('Unmocked request with `Expect: 100-continue` triggers continue event', async () => { + const body = 'this is the full request body' + const request = http.get(httpServer.http.url('/resource'), { + headers: { Expect: '100-continue' }, + }) + request.on('continue', () => { + request.end(body) + }) + + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(200) + expect(await text()).toBe('original response') +}) + +it.todo('Mocked request with `Expect: 100-continue` triggers continue event') \ No newline at end of file From 6e2aaffeffcc52528dbd9cda4fd45d08d6316874 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Sep 2024 14:12:46 +0200 Subject: [PATCH 2/3] chore: move test to compliance --- .../{request => compliance}/http-request-continue.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename test/modules/http/{request => compliance}/http-request-continue.test.ts (96%) diff --git a/test/modules/http/request/http-request-continue.test.ts b/test/modules/http/compliance/http-request-continue.test.ts similarity index 96% rename from test/modules/http/request/http-request-continue.test.ts rename to test/modules/http/compliance/http-request-continue.test.ts index 06caeda5..7103237c 100644 --- a/test/modules/http/request/http-request-continue.test.ts +++ b/test/modules/http/compliance/http-request-continue.test.ts @@ -1,8 +1,9 @@ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'http' import { HttpServer } from '@open-draft/test-server/http' -import { waitForClientRequest } from '../../../helpers' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' const interceptor = new ClientRequestInterceptor() @@ -37,4 +38,4 @@ it('Unmocked request with `Expect: 100-continue` triggers continue event', async expect(await text()).toBe('original response') }) -it.todo('Mocked request with `Expect: 100-continue` triggers continue event') \ No newline at end of file +it.todo('Mocked request with `Expect: 100-continue` triggers continue event') From 2e59653a531afa304c271da4c1931e55edd26ad8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Sep 2024 17:06:15 +0200 Subject: [PATCH 3/3] test: 100-expect test --- .../ClientRequest/MockHttpSocket.ts | 31 ++++--- src/utils/responseUtils.ts | 29 +++++++ .../compliance/http-request-continue.test.ts | 82 ++++++++++++++++--- 3 files changed, 122 insertions(+), 20 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 545faee9..1c9cf565 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -9,13 +9,14 @@ import { Readable } from 'node:stream' import { invariant } from 'outvariant' import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { MockSocket } from '../Socket/MockSocket' -import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs' +import { type NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs' import { isPropertyAccessible } from '../../utils/isPropertyAccessible' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' import { parseRawHeaders } from '../Socket/utils/parseRawHeaders' import { + createResponse, createServerErrorResponse, - RESPONSE_STATUS_CODES_WITHOUT_BODY, + isResponseWithoutBody, } from '../../utils/responseUtils' import { createRequestId } from '../../createRequestId' import { getRawFetchHeaders } from './utils/recordRawHeaders' @@ -128,7 +129,9 @@ export class MockHttpSocket extends MockSocket { const emitEvent = super.emit.bind(this, event as any, ...args) if (this.responseListenersPromise) { - this.responseListenersPromise.finally(emitEvent) + this.responseListenersPromise.finally(() => { + emitEvent() + }) return this.listenerCount(event) > 0 } @@ -517,13 +520,20 @@ export class MockHttpSocket extends MockSocket { 'Failed to write to a request stream: stream does not exist' ) + console.log( + 'REQ BODY!', + chunk.toString('utf8'), + this.request?.headers.has('expect') + ) this.requestStream.push(chunk) } private onRequestEnd(): void { + // console.log('[MHS] REQ END', this.writableFinished) + // Request end can be called for requests without body. if (this.requestStream) { - this.requestStream.push(null) + this.requestStream.push('\r\n') } } @@ -537,14 +547,13 @@ export class MockHttpSocket extends MockSocket { statusText ) => { const headers = parseRawHeaders(rawHeaders) - const canHaveBody = !RESPONSE_STATUS_CODES_WITHOUT_BODY.has(status) // Similarly, create a new stream for each response. - if (canHaveBody) { - this.responseStream = new Readable({ read() {} }) - } + this.responseStream = isResponseWithoutBody(status) + ? undefined + : new Readable({ read() {} }) - const response = new Response( + const response = createResponse( /** * @note The Fetch API response instance exposed to the consumer * is created over the response stream of the HTTP parser. It is NOT @@ -552,7 +561,9 @@ export class MockHttpSocket extends MockSocket { * in response listener while the Socket instance delays the emission * of "end" and other events until those response listeners are finished. */ - canHaveBody ? (Readable.toWeb(this.responseStream!) as any) : null, + this.responseStream + ? (Readable.toWeb(this.responseStream) as ReadableStream) + : null, { status, statusText, diff --git a/src/utils/responseUtils.ts b/src/utils/responseUtils.ts index 496a7228..fdf6f558 100644 --- a/src/utils/responseUtils.ts +++ b/src/utils/responseUtils.ts @@ -44,6 +44,35 @@ export function createServerErrorResponse(body: unknown): Response { ) } +/** + * Creates a Fetch API `Response` instance. + * Unlike the `Response` constructor, this function supports + * non-configurable status codes (e.g. 101). + */ +export function createResponse( + bodyInit?: BodyInit | null, + init?: ResponseInit +): Response { + const status = init?.status || 200 + const isAllowedStatus = status >= 200 + const body = isResponseWithoutBody(status) ? null : bodyInit + + const response = new Response(body, { + ...init, + status: isAllowedStatus ? status : 428, + }) + + if (!isAllowedStatus) { + Object.defineProperty(response, 'status', { + value: status, + enumerable: true, + writable: false, + }) + } + + return response +} + export type ResponseError = Response & { type: 'error' } /** diff --git a/test/modules/http/compliance/http-request-continue.test.ts b/test/modules/http/compliance/http-request-continue.test.ts index 7103237c..88040171 100644 --- a/test/modules/http/compliance/http-request-continue.test.ts +++ b/test/modules/http/compliance/http-request-continue.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'http' import { HttpServer } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' @@ -8,8 +8,13 @@ import { waitForClientRequest } from '../../../helpers' const interceptor = new ClientRequestInterceptor() const httpServer = new HttpServer((app) => { - app.get('/resource', (req, res) => { - res.send('original response') + app.post('/resource', (req, res) => { + req.on('data', (chunk) => + console.log('[server] req data:', chunk.toString()) + ) + console.log('!!![server] added req.on(data)') + + req.pipe(res) }) }) @@ -18,24 +23,81 @@ beforeAll(async () => { await httpServer.listen() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(async () => { interceptor.dispose() await httpServer.close() }) -it('Unmocked request with `Expect: 100-continue` triggers continue event', async () => { - const body = 'this is the full request body' - const request = http.get(httpServer.http.url('/resource'), { - headers: { Expect: '100-continue' }, +it('emits "continue" event for a request with "100-continue" expect header', async () => { + interceptor + .on('request', ({ request }) => { + console.log('[*] request', request.method, request.url) + }) + .on('response', ({ request, response }) => { + console.log('[*] response', response.status, request.method, request.url) + }) + + const request = http.request(httpServer.http.url('/resource'), { + method: 'POST', + headers: { + expect: '100-continue', + }, }) + + const continueListener = vi.fn() + request.on('continue', continueListener) request.on('continue', () => { - request.end(body) + console.log('REQ CONTINUE') + console.log('REQ END') + + console.log('!!!! writing request...') + request.end('hello') + }) + request.on('finish', () => console.log('REQ FINISH')) + request.on('response', () => console.log('REQ RESPONSE')) + + request.on('socket', (socket) => { + socket.write = new Proxy(socket.write, { + apply(target, thisArg, args) { + console.log('SOCKET WRITE', args[0].toString()) + return Reflect.apply(target, thisArg, args) + }, + }) + socket.push = new Proxy(socket.push, { + apply(target, thisArg, args) { + console.log('SOCKET PUSH', args[0].toString()) + return Reflect.apply(target, thisArg, args) + }, + }) + socket.emit = new Proxy(socket.emit, { + apply(target, thisArg, args) { + console.log( + 'SOCKET EMIT', + args[0] === 'data' ? ['data', args[1].toString()] : args + ) + return Reflect.apply(target, thisArg, args) + }, + }) + + socket.on('connect', () => console.log('SOCKET CONNECT')) + // socket.on('data', (chunk) => + // console.log('SOCKET DATA:\n', chunk.toString()) + // ) + socket.on('finish', () => console.log('SOCKET FINISH')) + socket.on('close', () => console.log('SOCKET CLOSE')) + socket.on('error', () => console.log('SOCKET ERROR!!!')) + socket.on('end', () => console.log('SOCKET END')) }) const { res, text } = await waitForClientRequest(request) expect(res.statusCode).toBe(200) - expect(await text()).toBe('original response') + await expect(text()).resolves.toBe('hello') + expect(continueListener).toHaveBeenCalledOnce() }) -it.todo('Mocked request with `Expect: 100-continue` triggers continue event') +it.todo('emits "continue" event for a ')