@znight/testing 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -8
- package/dist/index.d.mts +101 -1
- package/dist/index.mjs +243 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/extensions.ts +16 -0
- package/src/index.ts +12 -0
- package/src/test-app.ts +45 -1
- package/src/websocket.ts +323 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Znight testing utilities: TestBed with auto-mocking and TestApp for end-to-end tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ugho STEPHAN",
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@znight/core": "0.
|
|
49
|
+
"@znight/core": "0.6.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/extensions.ts
CHANGED
|
@@ -140,3 +140,19 @@ export function readGraphQLRoots(metadata: ZnightMetadata): Token[] {
|
|
|
140
140
|
}
|
|
141
141
|
return roots
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Tokens a WebSocket application needs at boot (`extensions.websocket`, RFC 0003 §7.1): every
|
|
146
|
+
* gateway, its handshake and message guards and the guards of its handlers.
|
|
147
|
+
*/
|
|
148
|
+
export function readWebSocketRoots(metadata: ZnightMetadata): Token[] {
|
|
149
|
+
const roots: Token[] = []
|
|
150
|
+
for (const gateway of arrayOf(extension(metadata, 'websocket')?.['gateways'])) {
|
|
151
|
+
if (!isRecord(gateway) || typeof gateway['token'] !== 'string') continue
|
|
152
|
+
roots.push(gateway['token'], ...tokensOf(gateway['handshakeGuards']), ...tokensOf(gateway['messageGuards']))
|
|
153
|
+
for (const handler of arrayOf(gateway['handlers'])) {
|
|
154
|
+
if (isRecord(handler)) roots.push(...tokensOf(handler['guards']))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return roots
|
|
158
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -54,5 +54,17 @@ export {
|
|
|
54
54
|
type SseMessage,
|
|
55
55
|
TestApp,
|
|
56
56
|
type TestAppOptions,
|
|
57
|
+
WEBSOCKET_PLUGIN_NAME,
|
|
57
58
|
} from './test-app.ts'
|
|
58
59
|
export { type AopMode, TestBed, type TestBedOptions, type TestBedResult } from './testbed.ts'
|
|
60
|
+
export {
|
|
61
|
+
type ConnectWebSocketOptions,
|
|
62
|
+
connectWebSocket,
|
|
63
|
+
type TestWsClient,
|
|
64
|
+
type TestWsCloseInfo,
|
|
65
|
+
type TestWsConnectOptions,
|
|
66
|
+
type TestWsWaitOptions,
|
|
67
|
+
type WebSocketConnector,
|
|
68
|
+
WsHandshakeError,
|
|
69
|
+
WsRequestError,
|
|
70
|
+
} from './websocket.ts'
|
package/src/test-app.ts
CHANGED
|
@@ -12,13 +12,23 @@ import type {
|
|
|
12
12
|
TypedToken,
|
|
13
13
|
ZnightMetadata,
|
|
14
14
|
} from '@znight/core'
|
|
15
|
+
import type { TestWsClient, TestWsConnectOptions, WebSocketConnector } from './websocket.ts'
|
|
15
16
|
|
|
16
17
|
import { Application, createConsoleLogger, ErrorCode, ZnightError } from '@znight/core'
|
|
17
18
|
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
readGraphQLRoots,
|
|
21
|
+
readHttpRoots,
|
|
22
|
+
readMessagingRoots,
|
|
23
|
+
readScheduleRoots,
|
|
24
|
+
readWebSocketRoots,
|
|
25
|
+
} from './extensions.ts'
|
|
19
26
|
import { MetadataIndex } from './metadata-index.ts'
|
|
20
27
|
import { entriesOf } from './utils.ts'
|
|
21
28
|
|
|
29
|
+
/** Name of the WebSocket plugin `TestApp.connect()` delegates to (`WebSocketPlugin` from `@znight/websocket`). */
|
|
30
|
+
export const WEBSOCKET_PLUGIN_NAME = 'websocket'
|
|
31
|
+
|
|
22
32
|
/** Name of the HTTP plugin `TestApp.inject()` delegates to (`HttpPlugin.configure()` from `@znight/http`). */
|
|
23
33
|
export const HTTP_PLUGIN_NAME = 'http'
|
|
24
34
|
|
|
@@ -320,6 +330,39 @@ export class TestApp implements AsyncDisposable {
|
|
|
320
330
|
}
|
|
321
331
|
}
|
|
322
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Opens an in-memory WebSocket connection through the WebSocket plugin (`WebSocketPlugin` of
|
|
335
|
+
* `@znight/websocket`): the real handshake through the HTTP pipeline (guards, origin and
|
|
336
|
+
* subprotocol checks), then an in-memory transport. `path` is the URL path, HTTP prefix included.
|
|
337
|
+
* Rejects with a `WsHandshakeError` when the handshake is refused.
|
|
338
|
+
*
|
|
339
|
+
* ```ts
|
|
340
|
+
* await using alice = await app.connect('/chat', { protocols: ['chat.v1'] })
|
|
341
|
+
* expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
|
|
342
|
+
* ```
|
|
343
|
+
*
|
|
344
|
+
* @throws ZnightError ZNIGHT001 when the application has no WebSocket plugin, ZNIGHT021 once closed.
|
|
345
|
+
*/
|
|
346
|
+
connect(path: string, options?: TestWsConnectOptions): Promise<TestWsClient> {
|
|
347
|
+
try {
|
|
348
|
+
if (this.#closed) {
|
|
349
|
+
throw new ZnightError(ErrorCode.LifecycleFailed, 'TestApp.connect(): the application is closed')
|
|
350
|
+
}
|
|
351
|
+
if (typeof path !== 'string') throw new TypeError('[znight] TestApp.connect() expects a path')
|
|
352
|
+
const plugin = this.app.getPlugin<Plugin & Partial<WebSocketConnector>>(WEBSOCKET_PLUGIN_NAME)
|
|
353
|
+
if (plugin === undefined || typeof plugin.connect !== 'function') {
|
|
354
|
+
throw new ZnightError(ErrorCode.MissingProvider, 'TestApp.connect() requires the WebSocket plugin', {
|
|
355
|
+
hints: [
|
|
356
|
+
'Add WebSocketPlugin.configure() from @znight/websocket (and HttpPlugin) to TestApp.create({ plugins })',
|
|
357
|
+
],
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
return plugin.connect(path, options)
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return Promise.reject(error)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
323
366
|
/**
|
|
324
367
|
* Dispatches a request in memory and resolves at the response headers; the body is read as it is
|
|
325
368
|
* produced (`response.body`). Cancelling the body (or aborting `signal`) disconnects the client.
|
|
@@ -451,6 +494,7 @@ function initOptions(
|
|
|
451
494
|
...readMessagingRoots(metadata),
|
|
452
495
|
...readScheduleRoots(metadata),
|
|
453
496
|
...readGraphQLRoots(metadata),
|
|
497
|
+
...readWebSocketRoots(metadata),
|
|
454
498
|
].filter(token => index.isProvider(token))
|
|
455
499
|
// plugin roots complete the entry points; they never restrict the boot by themselves
|
|
456
500
|
if (roots.length === 0) return {}
|
package/src/websocket.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket test clients (RFC 0003 §5.10). `TestApp.connect()` delegates to the plugin named
|
|
3
|
+
* `websocket` (`WebSocketPlugin` of `@znight/websocket`, an in-memory connection);
|
|
4
|
+
* `connectWebSocket(url)` wraps the global `WebSocket` (Node.js and Bun) with the same interface.
|
|
5
|
+
* `@znight/testing` depends on no feature package: the errors match structurally.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** How a connection closed. */
|
|
9
|
+
export interface TestWsCloseInfo {
|
|
10
|
+
readonly code: number
|
|
11
|
+
readonly reason: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Options of the waiting methods of {@link TestWsClient}. */
|
|
15
|
+
export interface TestWsWaitOptions {
|
|
16
|
+
/** Default: 1 000 ms (`request()`: 5 000 ms). */
|
|
17
|
+
readonly timeoutMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A WebSocket client speaking the `events` protocol of `@znight/websocket`, or raw frames. */
|
|
21
|
+
export interface TestWsClient extends AsyncDisposable {
|
|
22
|
+
/** The negotiated subprotocol, `''` when none. */
|
|
23
|
+
readonly protocol: string
|
|
24
|
+
/** Every frame received, in order: JSON objects decoded, other text frames as strings, binary frames as bytes. */
|
|
25
|
+
readonly frames: readonly unknown[]
|
|
26
|
+
/** Sends an event without id. */
|
|
27
|
+
send(event: string, data?: unknown): void
|
|
28
|
+
/** Sends a request; resolves with the `data` of its reply, rejects with a {@link WsRequestError}. */
|
|
29
|
+
request<T = unknown>(event: string, data?: unknown, options?: TestWsWaitOptions): Promise<T>
|
|
30
|
+
/** The `data` of the oldest server event (matching `event`) not taken yet: frames are buffered from the start. */
|
|
31
|
+
next<T = unknown>(event?: string, options?: TestWsWaitOptions): Promise<T>
|
|
32
|
+
sendRaw(frame: string | Uint8Array): void
|
|
33
|
+
nextRaw(options?: TestWsWaitOptions): Promise<string | Uint8Array>
|
|
34
|
+
close(code?: number, reason?: string): Promise<TestWsCloseInfo>
|
|
35
|
+
readonly closed: Promise<TestWsCloseInfo>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options of `TestApp.connect()`. */
|
|
39
|
+
export interface TestWsConnectOptions {
|
|
40
|
+
readonly query?: Readonly<Record<string, string | readonly string[]>>
|
|
41
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
42
|
+
readonly protocols?: readonly string[]
|
|
43
|
+
/** The `origin` header (a browser's). Default: none. */
|
|
44
|
+
readonly origin?: string
|
|
45
|
+
readonly cookies?: Readonly<Record<string, string>>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Options of {@link connectWebSocket}. */
|
|
49
|
+
export interface ConnectWebSocketOptions {
|
|
50
|
+
/** Handshake headers (Node.js and Bun clients accept them; browsers cannot set any). */
|
|
51
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
52
|
+
readonly protocols?: readonly string[]
|
|
53
|
+
/** Default: 2 000 ms. */
|
|
54
|
+
readonly timeoutMs?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What `TestApp.connect()` expects from the plugin named `'websocket'`. */
|
|
58
|
+
export interface WebSocketConnector {
|
|
59
|
+
connect(path: string, options?: TestWsConnectOptions): Promise<TestWsClient>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A refused handshake (`status`, `headers`, `body`, `json()`). `instanceof` also matches the
|
|
64
|
+
* `WsHandshakeError` of `@znight/websocket` (what `TestApp.connect()` rejects with).
|
|
65
|
+
*/
|
|
66
|
+
export class WsHandshakeError extends Error {
|
|
67
|
+
readonly status: number
|
|
68
|
+
readonly headers: Readonly<Record<string, string | string[]>>
|
|
69
|
+
readonly body: string
|
|
70
|
+
|
|
71
|
+
constructor(status: number, headers: Readonly<Record<string, string | string[]>> = {}, body = '') {
|
|
72
|
+
super(`The WebSocket handshake was refused with ${status}: ${body}`)
|
|
73
|
+
this.name = 'WsHandshakeError'
|
|
74
|
+
this.status = status
|
|
75
|
+
this.headers = headers
|
|
76
|
+
this.body = body
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static override [Symbol.hasInstance](value: unknown): boolean {
|
|
80
|
+
return (
|
|
81
|
+
value instanceof Error &&
|
|
82
|
+
value.name === 'WsHandshakeError' &&
|
|
83
|
+
typeof (value as { status?: unknown }).status === 'number'
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The body parsed as JSON. */
|
|
88
|
+
json<T = unknown>(): T {
|
|
89
|
+
return JSON.parse(this.body) as T
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The failure reply of a request (`code`, `message`, `details`); matches `@znight/websocket`'s too. */
|
|
94
|
+
export class WsRequestError extends Error {
|
|
95
|
+
readonly code: string
|
|
96
|
+
readonly details: unknown
|
|
97
|
+
|
|
98
|
+
constructor(code: string, message: string, details?: unknown) {
|
|
99
|
+
super(message)
|
|
100
|
+
this.name = 'WsRequestError'
|
|
101
|
+
this.code = code
|
|
102
|
+
this.details = details
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static override [Symbol.hasInstance](value: unknown): boolean {
|
|
106
|
+
return (
|
|
107
|
+
value instanceof Error &&
|
|
108
|
+
value.name === 'WsRequestError' &&
|
|
109
|
+
typeof (value as { code?: unknown }).code === 'string'
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Entry {
|
|
115
|
+
readonly frame: string | Uint8Array
|
|
116
|
+
readonly envelope: Readonly<Record<string, unknown>> | undefined
|
|
117
|
+
consumed: boolean
|
|
118
|
+
rawConsumed: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Waiter {
|
|
122
|
+
readonly match: (entry: Entry) => boolean
|
|
123
|
+
readonly take: (entry: Entry) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function decode(frame: string | Uint8Array): Readonly<Record<string, unknown>> | undefined {
|
|
127
|
+
if (typeof frame !== 'string') return undefined
|
|
128
|
+
try {
|
|
129
|
+
const value: unknown = JSON.parse(frame)
|
|
130
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
131
|
+
? (value as Record<string, unknown>)
|
|
132
|
+
: undefined
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A {@link TestWsClient} over a global `WebSocket`. */
|
|
139
|
+
class NetworkClient implements TestWsClient {
|
|
140
|
+
readonly #ws: WebSocket
|
|
141
|
+
readonly #entries: Entry[] = []
|
|
142
|
+
readonly #waiters = new Set<Waiter>()
|
|
143
|
+
readonly #closed = Promise.withResolvers<TestWsCloseInfo>()
|
|
144
|
+
#ended = false
|
|
145
|
+
#nextId = 1
|
|
146
|
+
|
|
147
|
+
constructor(ws: WebSocket) {
|
|
148
|
+
this.#ws = ws
|
|
149
|
+
ws.addEventListener('message', event => {
|
|
150
|
+
const data: unknown = event.data
|
|
151
|
+
this.#receive(typeof data === 'string' ? data : new Uint8Array(data as ArrayBuffer))
|
|
152
|
+
})
|
|
153
|
+
ws.addEventListener('close', event => {
|
|
154
|
+
this.#ended = true
|
|
155
|
+
this.#closed.resolve({ code: event.code, reason: event.reason })
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
get protocol(): string {
|
|
160
|
+
return this.#ws.protocol
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get frames(): readonly unknown[] {
|
|
164
|
+
return this.#entries.map(entry => entry.envelope ?? entry.frame)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get closed(): Promise<TestWsCloseInfo> {
|
|
168
|
+
return this.#closed.promise
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
send(event: string, data?: unknown): void {
|
|
172
|
+
this.#ws.send(JSON.stringify(data === undefined ? { event } : { event, data }))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
request<T = unknown>(event: string, data?: unknown, options: TestWsWaitOptions = {}): Promise<T> {
|
|
176
|
+
const id = this.#nextId++
|
|
177
|
+
const reply = this.#wait<T>(
|
|
178
|
+
entry => !entry.consumed && entry.envelope?.['id'] === id && entry.envelope['event'] === undefined,
|
|
179
|
+
(entry, resolve, reject) => {
|
|
180
|
+
entry.consumed = true
|
|
181
|
+
const envelope = entry.envelope as Record<string, unknown>
|
|
182
|
+
const error = envelope['error'] as Record<string, unknown> | undefined
|
|
183
|
+
if (error === undefined) {
|
|
184
|
+
resolve(envelope['data'] as T)
|
|
185
|
+
} else {
|
|
186
|
+
reject(
|
|
187
|
+
new WsRequestError(
|
|
188
|
+
typeof error['code'] === 'string' ? error['code'] : 'INTERNAL',
|
|
189
|
+
typeof error['message'] === 'string' ? error['message'] : `The request "${event}" failed`,
|
|
190
|
+
error['details'],
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
options.timeoutMs ?? 5_000,
|
|
196
|
+
`the reply to "${event}" (id ${id})`,
|
|
197
|
+
)
|
|
198
|
+
this.#ws.send(JSON.stringify(data === undefined ? { event, id } : { event, data, id }))
|
|
199
|
+
return reply
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
next<T = unknown>(event?: string, options: TestWsWaitOptions = {}): Promise<T> {
|
|
203
|
+
return this.#wait<T>(
|
|
204
|
+
entry =>
|
|
205
|
+
!entry.consumed &&
|
|
206
|
+
typeof entry.envelope?.['event'] === 'string' &&
|
|
207
|
+
(event === undefined || entry.envelope['event'] === event),
|
|
208
|
+
(entry, resolve) => {
|
|
209
|
+
entry.consumed = true
|
|
210
|
+
resolve((entry.envelope as Record<string, unknown>)['data'] as T)
|
|
211
|
+
},
|
|
212
|
+
options.timeoutMs ?? 1_000,
|
|
213
|
+
event === undefined ? 'a server event' : `the event "${event}"`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
sendRaw(frame: string | Uint8Array): void {
|
|
218
|
+
this.#ws.send(frame)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
nextRaw(options: TestWsWaitOptions = {}): Promise<string | Uint8Array> {
|
|
222
|
+
return this.#wait<string | Uint8Array>(
|
|
223
|
+
entry => !entry.rawConsumed,
|
|
224
|
+
(entry, resolve) => {
|
|
225
|
+
entry.rawConsumed = true
|
|
226
|
+
resolve(entry.frame)
|
|
227
|
+
},
|
|
228
|
+
options.timeoutMs ?? 1_000,
|
|
229
|
+
'a frame',
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
close(code = 1000, reason = ''): Promise<TestWsCloseInfo> {
|
|
234
|
+
if (!this.#ended) this.#ws.close(code, reason)
|
|
235
|
+
return this.#closed.promise
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
239
|
+
await this.close()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#wait<T>(
|
|
243
|
+
match: (entry: Entry) => boolean,
|
|
244
|
+
take: (entry: Entry, resolve: (value: T) => void, reject: (error: Error) => void) => void,
|
|
245
|
+
timeoutMs: number,
|
|
246
|
+
what: string,
|
|
247
|
+
): Promise<T> {
|
|
248
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>()
|
|
249
|
+
const found = this.#entries.find(match)
|
|
250
|
+
if (found !== undefined) {
|
|
251
|
+
take(found, resolve, reject)
|
|
252
|
+
return promise
|
|
253
|
+
}
|
|
254
|
+
if (this.#ended) {
|
|
255
|
+
reject(new Error(`The WebSocket connection is closed: no ${what} will arrive`))
|
|
256
|
+
return promise
|
|
257
|
+
}
|
|
258
|
+
const timer = setTimeout(() => {
|
|
259
|
+
this.#waiters.delete(waiter)
|
|
260
|
+
reject(new Error(`Timed out after ${timeoutMs} ms waiting for ${what}`))
|
|
261
|
+
}, timeoutMs)
|
|
262
|
+
const waiter: Waiter = {
|
|
263
|
+
match,
|
|
264
|
+
take: entry => {
|
|
265
|
+
clearTimeout(timer)
|
|
266
|
+
take(entry, resolve, reject)
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
this.#waiters.add(waiter)
|
|
270
|
+
void this.#closed.promise.then(() => {
|
|
271
|
+
if (!this.#waiters.delete(waiter)) return
|
|
272
|
+
clearTimeout(timer)
|
|
273
|
+
reject(new Error(`The WebSocket connection closed while waiting for ${what}`))
|
|
274
|
+
})
|
|
275
|
+
return promise
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#receive(frame: string | Uint8Array): void {
|
|
279
|
+
const entry: Entry = { frame, envelope: decode(frame), consumed: false, rawConsumed: false }
|
|
280
|
+
this.#entries.push(entry)
|
|
281
|
+
for (const waiter of this.#waiters) {
|
|
282
|
+
if (!waiter.match(entry)) continue
|
|
283
|
+
this.#waiters.delete(waiter)
|
|
284
|
+
waiter.take(entry)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Connects to a WebSocket server over the network (end-to-end tests, Node.js and Bun) and returns
|
|
291
|
+
* a {@link TestWsClient}. Rejects with a {@link WsHandshakeError} (`status: 0`: the client cannot
|
|
292
|
+
* read the status of a refused handshake) when the connection fails.
|
|
293
|
+
*
|
|
294
|
+
* ```ts
|
|
295
|
+
* await using client = await connectWebSocket(`ws://127.0.0.1:${port}/chat`, { headers: { 'x-api-key': KEY } })
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
298
|
+
export function connectWebSocket(url: string, options: ConnectWebSocketOptions = {}): Promise<TestWsClient> {
|
|
299
|
+
const Socket = WebSocket as unknown as new (
|
|
300
|
+
url: string,
|
|
301
|
+
init: { readonly headers?: Readonly<Record<string, string>>; readonly protocols?: readonly string[] },
|
|
302
|
+
) => WebSocket
|
|
303
|
+
const ws = new Socket(url, {
|
|
304
|
+
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
305
|
+
...(options.protocols === undefined ? {} : { protocols: options.protocols }),
|
|
306
|
+
})
|
|
307
|
+
ws.binaryType = 'arraybuffer'
|
|
308
|
+
const client = new NetworkClient(ws)
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
ws.close()
|
|
312
|
+
reject(new Error(`connectWebSocket(${url}): no handshake after ${options.timeoutMs ?? 2_000} ms`))
|
|
313
|
+
}, options.timeoutMs ?? 2_000)
|
|
314
|
+
ws.addEventListener('open', () => {
|
|
315
|
+
clearTimeout(timer)
|
|
316
|
+
resolve(client)
|
|
317
|
+
})
|
|
318
|
+
ws.addEventListener('error', () => {
|
|
319
|
+
clearTimeout(timer)
|
|
320
|
+
reject(new WsHandshakeError(0, {}, `connectWebSocket(${url}) failed`))
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
}
|