@znight/testing 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/testing",
3
- "version": "0.5.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.5.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 { readGraphQLRoots, readHttpRoots, readMessagingRoots, readScheduleRoots } from './extensions.ts'
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 {}
@@ -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
+ }