Skip to content

Commit 3b1dd68

Browse files
test: replace supertest (#379)
* test: replace supertest * chore: format
1 parent 834e54f commit 3b1dd68

11 files changed

Lines changed: 875 additions & 948 deletions

File tree

‎package.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,13 @@
9696
"devDependencies": {
9797
"@hono/eslint-config": "^1.0.1",
9898
"@types/node": "^20.10.0",
99-
"@types/supertest": "^2.0.12",
10099
"@types/ws": "^8.18.1",
101100
"@whatwg-node/fetch": "^0.9.14",
102101
"eslint": "^9.10.0",
103102
"hono": "^4.12.8",
104103
"np": "^11.2.1",
105104
"prettier": "^3.2.4",
106105
"publint": "^0.3.18",
107-
"supertest": "^7.2.2",
108106
"tsdown": "^0.20.3",
109107
"typescript": "^5.3.2",
110108
"vitest": "^4.0.18",

‎pnpm-lock.yaml‎

Lines changed: 0 additions & 372 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/request.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export class Request extends GlobalRequest {
4343
}
4444
}
4545

46-
export const newHeadersFromIncoming = (incoming: IncomingMessage | Http2ServerRequest) => {
46+
export const newHeadersFromIncoming = (
47+
incoming: Pick<IncomingMessage | Http2ServerRequest, 'rawHeaders'>
48+
) => {
4749
const headerRecord: [string, string][] = []
4850
const rawHeaders = incoming.rawHeaders
4951
for (let i = 0, len = rawHeaders.length; i < len; i += 2) {
@@ -715,7 +717,9 @@ Object.defineProperty(requestPrototype, 'blob', {
715717
return readBodyWithFastPath(this, 'blob', (buf, request) => {
716718
const type = contentType(request)
717719
const init = type ? { headers: { 'content-type': type } } : undefined
718-
return new Response(buf, init).blob()
720+
// Buffer is typed over ArrayBufferLike, but request bodies are always
721+
// backed by an ArrayBuffer, so cast instead of copying the bytes
722+
return new Response(buf as Uint8Array<ArrayBuffer>, init).blob()
719723
})
720724
},
721725
})
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
�Hello br Compressed
1+
Hello br Compressed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
�Hello br Compressed
1+
Hello br Compressed

‎test/helpers/request.ts‎

Lines changed: 154 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { once } from 'node:events'
22
import { request as requestHTTP } from 'node:http'
33
import type { IncomingMessage, RequestOptions } from 'node:http'
4+
import { connect } from 'node:http2'
5+
import type { IncomingHttpHeaders as IncomingHttp2Headers } from 'node:http2'
6+
import { Server as HttpsServer, request as requestHTTPS } from 'node:https'
47
import type { AddressInfo } from 'node:net'
5-
import { Readable } from 'node:stream'
68
import { newHeadersFromIncoming } from '../../src/request'
79
import { GlobalResponse } from '../../src/response'
810
import type { ServerType } from '../../src/types'
@@ -15,39 +17,174 @@ export type ServerRequestInit = Omit<
1517
path: string
1618
}
1719

18-
export const requestServer = async (
19-
server: ServerType,
20-
init: ServerRequestInit
21-
): Promise<Response> => {
22-
const address = server.address() as AddressInfo | null
20+
export type ServerRequestHooks = {
21+
onHeaders?: (headers: Headers, status: number) => void
22+
onChunk?: (chunk: Buffer, index: number) => void
23+
}
24+
25+
export type Http2RequestInit = {
26+
headers?: Record<string, string>
27+
method?: string
28+
path: string
29+
}
30+
31+
const ensureListening = async (server: ServerType) => {
32+
let address = server.address() as AddressInfo | null
33+
let startedByHelper = false
34+
if (!address) {
35+
server.listen(0, '127.0.0.1')
36+
await once(server, 'listening')
37+
startedByHelper = true
38+
address = server.address() as AddressInfo | null
39+
}
2340
if (!address) {
2441
throw new Error('Server is not listening on a TCP address')
2542
}
2643

44+
return {
45+
address,
46+
// a no-op if the server was already listening before the request, its
47+
// owner is responsible for closing it in that case
48+
close: async () => {
49+
if (startedByHelper) {
50+
await new Promise<void>((resolve) => server.close(() => resolve()))
51+
}
52+
},
53+
}
54+
}
55+
56+
const sendRequest = (server: ServerType, address: AddressInfo, init: ServerRequestInit) => {
2757
const { body, path, ...options } = init
28-
const request = requestHTTP({
58+
const common = {
2959
...options,
3060
agent: false,
3161
hostname: address.address,
3262
path,
3363
port: address.port,
34-
})
64+
}
65+
const request =
66+
server instanceof HttpsServer
67+
? requestHTTPS({ ...common, rejectUnauthorized: false })
68+
: requestHTTP(common)
3569
request.end(body)
70+
return request
71+
}
3672

73+
const receiveResponse = async (request: ReturnType<typeof sendRequest>) => {
3774
const [incoming] = (await once(request, 'response')) as [IncomingMessage]
3875
const status = incoming.statusCode
3976
if (!status) {
4077
throw new Error('Server response did not include a status code')
4178
}
79+
return { incoming, status }
80+
}
4281

43-
const responseBody =
44-
options.method?.toUpperCase() === 'HEAD' || [101, 204, 205, 304].includes(status)
45-
? null
46-
: (Readable.toWeb(incoming) as ReadableStream<Uint8Array>)
82+
export const requestServer = async (
83+
server: ServerType,
84+
init: ServerRequestInit
85+
): Promise<Response> => {
86+
const { address, close } = await ensureListening(server)
4787

48-
return new GlobalResponse(responseBody, {
49-
headers: newHeadersFromIncoming(incoming),
50-
status,
51-
statusText: incoming.statusMessage,
52-
})
88+
// the request may fail mid-flight (e.g. aborted via `init.signal`), so the
89+
// helper-started server is always fully closed before returning or
90+
// surfacing an error
91+
try {
92+
const { incoming, status } = await receiveResponse(sendRequest(server, address, init))
93+
94+
const hasBody = init.method?.toUpperCase() !== 'HEAD' && ![101, 204, 205, 304].includes(status)
95+
96+
let responseBody: BodyInit | null = null
97+
if (hasBody) {
98+
// buffer the body so a helper-started server can be fully closed before
99+
// returning, otherwise a subsequent request could hit a half-closed server
100+
const chunks: Buffer[] = []
101+
for await (const chunk of incoming) {
102+
chunks.push(chunk)
103+
}
104+
responseBody = new Uint8Array(Buffer.concat(chunks))
105+
}
106+
107+
return new GlobalResponse(responseBody, {
108+
headers: newHeadersFromIncoming(incoming),
109+
status,
110+
statusText: incoming.statusMessage,
111+
})
112+
} finally {
113+
await close()
114+
}
115+
}
116+
117+
/**
118+
* Same as `requestServer`, but exposes the raw body chunks for tests that
119+
* assert streaming behavior: `onHeaders` fires before any body data arrives
120+
* and `onChunk` fires per received chunk, so a test can synchronize with the
121+
* server mid-response. Chunk boundaries are preserved by the chunked
122+
* transfer-encoding framing.
123+
*/
124+
export const requestServerChunked = async (
125+
server: ServerType,
126+
init: ServerRequestInit,
127+
hooks: ServerRequestHooks = {}
128+
): Promise<{ chunks: Buffer[]; response: Response }> => {
129+
const { address, close } = await ensureListening(server)
130+
131+
try {
132+
const { incoming, status } = await receiveResponse(sendRequest(server, address, init))
133+
const headers = newHeadersFromIncoming(incoming)
134+
hooks.onHeaders?.(headers, status)
135+
136+
const chunks: Buffer[] = []
137+
incoming.on('data', (chunk: Buffer) => {
138+
hooks.onChunk?.(chunk, chunks.length)
139+
chunks.push(chunk)
140+
})
141+
await once(incoming, 'end')
142+
143+
return {
144+
chunks,
145+
response: new GlobalResponse(new Uint8Array(Buffer.concat(chunks)), {
146+
headers,
147+
status,
148+
statusText: incoming.statusMessage,
149+
}),
150+
}
151+
} finally {
152+
await close()
153+
}
154+
}
155+
156+
export const requestServerHttp2 = async (
157+
server: ServerType,
158+
init: Http2RequestInit
159+
): Promise<Response> => {
160+
const { address, close } = await ensureListening(server)
161+
const client = connect(`http://${address.address}:${address.port}`)
162+
// stream errors reject `once()` below, a session error would otherwise crash
163+
client.once('error', () => {})
164+
165+
try {
166+
const stream = client.request({
167+
':method': init.method ?? 'GET',
168+
':path': init.path,
169+
...init.headers,
170+
})
171+
stream.end()
172+
173+
const [incomingHeaders, , rawHeaders] = (await once(stream, 'response')) as [
174+
IncomingHttp2Headers,
175+
number,
176+
string[],
177+
]
178+
const status = Number(incomingHeaders[':status'])
179+
const headers = newHeadersFromIncoming({ rawHeaders })
180+
181+
const chunks: Buffer[] = []
182+
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
183+
await once(stream, 'end')
184+
185+
return new GlobalResponse(new Uint8Array(Buffer.concat(chunks)), { headers, status })
186+
} finally {
187+
client.close()
188+
await close()
189+
}
53190
}

0 commit comments

Comments
 (0)