@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/README.md
CHANGED
|
@@ -230,14 +230,15 @@ await using app = await TestApp.create({
|
|
|
230
230
|
})
|
|
231
231
|
```
|
|
232
232
|
|
|
233
|
-
| Member
|
|
234
|
-
|
|
|
235
|
-
| `inject(request)`
|
|
236
|
-
| `injectSse(request)`
|
|
237
|
-
| `injectStream(request)`
|
|
238
|
-
| `
|
|
239
|
-
| `
|
|
240
|
-
| `
|
|
233
|
+
| Member | Description |
|
|
234
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
235
|
+
| `inject(request)` | In-memory request through the HTTP plugin: `{ method = 'GET', url, headers?, body?, query?, cookies?, signedCookies? }`. |
|
|
236
|
+
| `injectSse(request)` | An event stream (Server-Sent Events) read in memory like `EventSource` (see below). |
|
|
237
|
+
| `injectStream(request)` | In-memory request resolving at the response headers, its `body` a `ReadableStream` read as it is produced. |
|
|
238
|
+
| `connect(path, options?)` | In-memory WebSocket connection through `WebSocketPlugin` (`@znight/websocket`, see below). |
|
|
239
|
+
| `get(key)` | `app.container.get(key)` (class, token, alias or display name). |
|
|
240
|
+
| `close()` | Stops the application (idempotent). Also `await using app = await TestApp.create(...)`. |
|
|
241
|
+
| `app` / `container` | The `Application` and its `Container`. |
|
|
241
242
|
|
|
242
243
|
`inject()` resolves with `{ status, headers, body, bytes, cookies, json() }` (`body` decoded as
|
|
243
244
|
UTF-8, `bytes` the raw body, for downloads, `cookies` the `set-cookie` headers parsed by name); it
|
|
@@ -425,6 +426,27 @@ without importing any feature package:
|
|
|
425
426
|
| `extensions.schedule.jobs[].token` | `TestApp` boot roots |
|
|
426
427
|
| `extensions.graphql.types[kind: 'object'].fields[].resolver.{ token, guards, loaders }` | `TestApp` boot roots |
|
|
427
428
|
|
|
429
|
+
### WebSocket connections
|
|
430
|
+
|
|
431
|
+
`app.connect(path, { query?, headers?, protocols?, origin?, cookies? })` runs the real WebSocket
|
|
432
|
+
handshake of `WebSocketPlugin` (`@znight/websocket`) through the HTTP pipeline, in memory, and
|
|
433
|
+
resolves with a `TestWsClient`: `request(event, data?)` (the reply's data; rejects with a
|
|
434
|
+
`WsRequestError` `{ code, message, details }`), `next(event?)` (the oldest server event not taken
|
|
435
|
+
yet: frames are buffered from the start), `send`, `sendRaw`, `nextRaw`, `frames`, `close()` and
|
|
436
|
+
`closed`. A refused handshake rejects with a `WsHandshakeError` (`status`, `headers`, `body`,
|
|
437
|
+
`json()`). `connectWebSocket(url, { headers?, protocols? })` gives the same client over the network
|
|
438
|
+
(the global `WebSocket` of Node.js and Bun).
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
import { connectWebSocket, WsHandshakeError } from '@znight/testing'
|
|
442
|
+
|
|
443
|
+
await using alice = await app.connect('/chat', { protocols: ['chat.v1'] })
|
|
444
|
+
expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
|
|
445
|
+
await expect(app.connect('/chat', { origin: 'https://evil.example' })).rejects.toBeInstanceOf(WsHandshakeError)
|
|
446
|
+
|
|
447
|
+
await using remote = await connectWebSocket(`ws://127.0.0.1:${port}/chat`, { protocols: ['chat.v1'] })
|
|
448
|
+
```
|
|
449
|
+
|
|
428
450
|
Runtime contract with `@znight/http`: the plugin named `'http'` exposes
|
|
429
451
|
`inject(request: { method: string; url: string; headers?; body?; query?; cookies?; signedCookies? }): Promise<{ status: number; headers; body: string; bytes?: Uint8Array; cookies?: Record<string, SetCookieInfo>; json<T>(): T }>`
|
|
430
452
|
(`HttpInjector`, `HttpInjectResult`), dispatching in memory. `TestApp` forwards every request field
|
|
@@ -435,6 +457,12 @@ as is (signing `signedCookies` is the plugin's job), returns the plugin's respon
|
|
|
435
457
|
`HttpPlugin` of `@znight/http` ≥ 0.1.0), the method defaulting to `GET`; the response shapes are
|
|
436
458
|
`InjectStreamResponse` and `InjectSseResponse` (structural copies of the `@znight/http` types).
|
|
437
459
|
|
|
460
|
+
Runtime contract with `@znight/websocket`: the plugin named `'websocket'` exposes
|
|
461
|
+
`connect(path, options?): Promise<TestWsClient>` (`WebSocketConnector`). `WsHandshakeError` and
|
|
462
|
+
`WsRequestError` match the errors of `@znight/websocket` structurally (`instanceof` checks their
|
|
463
|
+
`name` and `status` / `code`). `TestApp` boots the gateways of `metadata.extensions.websocket` with
|
|
464
|
+
their guards.
|
|
465
|
+
|
|
438
466
|
Runtime contract with every plugin: the optional `initRoots(context: { metadata }): readonly ProviderKey[]`
|
|
439
467
|
member of `Plugin` (`@znight/core`; `PluginInitRoots` names a plugin implementing it) returns the
|
|
440
468
|
providers (classes or tokens) the plugin resolves at boot that the metadata does not make entry
|
package/dist/index.d.mts
CHANGED
|
@@ -349,6 +349,92 @@ export declare function overrideFactory<T>(key: ProviderKey<T>, factory: Provide
|
|
|
349
349
|
export declare function createSpy<F extends AnyFunction = UntypedFunction>(implementation?: F): MockFunction<F>;
|
|
350
350
|
/** Whether `value` is a spy created by {@link createSpy}. */
|
|
351
351
|
export declare function isBuiltinSpy(value: unknown): boolean;
|
|
352
|
+
/**
|
|
353
|
+
* WebSocket test clients (RFC 0003 §5.10). `TestApp.connect()` delegates to the plugin named
|
|
354
|
+
* `websocket` (`WebSocketPlugin` of `@znight/websocket`, an in-memory connection);
|
|
355
|
+
* `connectWebSocket(url)` wraps the global `WebSocket` (Node.js and Bun) with the same interface.
|
|
356
|
+
* `@znight/testing` depends on no feature package: the errors match structurally.
|
|
357
|
+
*/
|
|
358
|
+
/** How a connection closed. */
|
|
359
|
+
interface TestWsCloseInfo {
|
|
360
|
+
readonly code: number;
|
|
361
|
+
readonly reason: string;
|
|
362
|
+
}
|
|
363
|
+
/** Options of the waiting methods of {@link TestWsClient}. */
|
|
364
|
+
interface TestWsWaitOptions {
|
|
365
|
+
/** Default: 1 000 ms (`request()`: 5 000 ms). */
|
|
366
|
+
readonly timeoutMs?: number;
|
|
367
|
+
}
|
|
368
|
+
/** A WebSocket client speaking the `events` protocol of `@znight/websocket`, or raw frames. */
|
|
369
|
+
interface TestWsClient extends AsyncDisposable {
|
|
370
|
+
/** The negotiated subprotocol, `''` when none. */
|
|
371
|
+
readonly protocol: string;
|
|
372
|
+
/** Every frame received, in order: JSON objects decoded, other text frames as strings, binary frames as bytes. */
|
|
373
|
+
readonly frames: readonly unknown[];
|
|
374
|
+
/** Sends an event without id. */
|
|
375
|
+
send(event: string, data?: unknown): void;
|
|
376
|
+
/** Sends a request; resolves with the `data` of its reply, rejects with a {@link WsRequestError}. */
|
|
377
|
+
request<T = unknown>(event: string, data?: unknown, options?: TestWsWaitOptions): Promise<T>;
|
|
378
|
+
/** The `data` of the oldest server event (matching `event`) not taken yet: frames are buffered from the start. */
|
|
379
|
+
next<T = unknown>(event?: string, options?: TestWsWaitOptions): Promise<T>;
|
|
380
|
+
sendRaw(frame: string | Uint8Array): void;
|
|
381
|
+
nextRaw(options?: TestWsWaitOptions): Promise<string | Uint8Array>;
|
|
382
|
+
close(code?: number, reason?: string): Promise<TestWsCloseInfo>;
|
|
383
|
+
readonly closed: Promise<TestWsCloseInfo>;
|
|
384
|
+
}
|
|
385
|
+
/** Options of `TestApp.connect()`. */
|
|
386
|
+
interface TestWsConnectOptions {
|
|
387
|
+
readonly query?: Readonly<Record<string, string | readonly string[]>>;
|
|
388
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
389
|
+
readonly protocols?: readonly string[];
|
|
390
|
+
/** The `origin` header (a browser's). Default: none. */
|
|
391
|
+
readonly origin?: string;
|
|
392
|
+
readonly cookies?: Readonly<Record<string, string>>;
|
|
393
|
+
}
|
|
394
|
+
/** Options of {@link connectWebSocket}. */
|
|
395
|
+
interface ConnectWebSocketOptions {
|
|
396
|
+
/** Handshake headers (Node.js and Bun clients accept them; browsers cannot set any). */
|
|
397
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
398
|
+
readonly protocols?: readonly string[];
|
|
399
|
+
/** Default: 2 000 ms. */
|
|
400
|
+
readonly timeoutMs?: number;
|
|
401
|
+
}
|
|
402
|
+
/** What `TestApp.connect()` expects from the plugin named `'websocket'`. */
|
|
403
|
+
interface WebSocketConnector {
|
|
404
|
+
connect(path: string, options?: TestWsConnectOptions): Promise<TestWsClient>;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A refused handshake (`status`, `headers`, `body`, `json()`). `instanceof` also matches the
|
|
408
|
+
* `WsHandshakeError` of `@znight/websocket` (what `TestApp.connect()` rejects with).
|
|
409
|
+
*/
|
|
410
|
+
export declare class WsHandshakeError extends Error {
|
|
411
|
+
readonly status: number;
|
|
412
|
+
readonly headers: Readonly<Record<string, string | string[]>>;
|
|
413
|
+
readonly body: string;
|
|
414
|
+
constructor(status: number, headers?: Readonly<Record<string, string | string[]>>, body?: string);
|
|
415
|
+
static override [Symbol.hasInstance](value: unknown): boolean;
|
|
416
|
+
/** The body parsed as JSON. */
|
|
417
|
+
json<T = unknown>(): T;
|
|
418
|
+
}
|
|
419
|
+
/** The failure reply of a request (`code`, `message`, `details`); matches `@znight/websocket`'s too. */
|
|
420
|
+
export declare class WsRequestError extends Error {
|
|
421
|
+
readonly code: string;
|
|
422
|
+
readonly details: unknown;
|
|
423
|
+
constructor(code: string, message: string, details?: unknown);
|
|
424
|
+
static override [Symbol.hasInstance](value: unknown): boolean;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Connects to a WebSocket server over the network (end-to-end tests, Node.js and Bun) and returns
|
|
428
|
+
* a {@link TestWsClient}. Rejects with a {@link WsHandshakeError} (`status: 0`: the client cannot
|
|
429
|
+
* read the status of a refused handshake) when the connection fails.
|
|
430
|
+
*
|
|
431
|
+
* ```ts
|
|
432
|
+
* await using client = await connectWebSocket(`ws://127.0.0.1:${port}/chat`, { headers: { 'x-api-key': KEY } })
|
|
433
|
+
* ```
|
|
434
|
+
*/
|
|
435
|
+
export declare function connectWebSocket(url: string, options?: ConnectWebSocketOptions): Promise<TestWsClient>;
|
|
436
|
+
/** Name of the WebSocket plugin `TestApp.connect()` delegates to (`WebSocketPlugin` from `@znight/websocket`). */
|
|
437
|
+
export declare const WEBSOCKET_PLUGIN_NAME = "websocket";
|
|
352
438
|
/** Name of the HTTP plugin `TestApp.inject()` delegates to (`HttpPlugin.configure()` from `@znight/http`). */
|
|
353
439
|
export declare const HTTP_PLUGIN_NAME = "http";
|
|
354
440
|
/** An in-memory HTTP request for {@link TestApp.inject}. */
|
|
@@ -589,6 +675,20 @@ export declare class TestApp implements AsyncDisposable {
|
|
|
589
675
|
* @throws ZnightError ZNIGHT001 when the application has no HTTP plugin, ZNIGHT021 once closed.
|
|
590
676
|
*/
|
|
591
677
|
inject(request: InjectRequest): Promise<InjectResponse>;
|
|
678
|
+
/**
|
|
679
|
+
* Opens an in-memory WebSocket connection through the WebSocket plugin (`WebSocketPlugin` of
|
|
680
|
+
* `@znight/websocket`): the real handshake through the HTTP pipeline (guards, origin and
|
|
681
|
+
* subprotocol checks), then an in-memory transport. `path` is the URL path, HTTP prefix included.
|
|
682
|
+
* Rejects with a `WsHandshakeError` when the handshake is refused.
|
|
683
|
+
*
|
|
684
|
+
* ```ts
|
|
685
|
+
* await using alice = await app.connect('/chat', { protocols: ['chat.v1'] })
|
|
686
|
+
* expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
|
|
687
|
+
* ```
|
|
688
|
+
*
|
|
689
|
+
* @throws ZnightError ZNIGHT001 when the application has no WebSocket plugin, ZNIGHT021 once closed.
|
|
690
|
+
*/
|
|
691
|
+
connect(path: string, options?: TestWsConnectOptions): Promise<TestWsClient>;
|
|
592
692
|
/**
|
|
593
693
|
* Dispatches a request in memory and resolves at the response headers; the body is read as it is
|
|
594
694
|
* produced (`response.body`). Cancelling the body (or aborting `signal`) disconnects the client.
|
|
@@ -693,5 +793,5 @@ export declare class TestBed {
|
|
|
693
793
|
static create<T>(target: ProviderClass<T> | ClassKey<T>, options: TestBedOptions): Promise<TestBedResult<T>>;
|
|
694
794
|
static create<T = unknown>(target: TypedToken<T>, options: TestBedOptions): Promise<TestBedResult<T>>;
|
|
695
795
|
}
|
|
696
|
-
export type { AnyFunction, AopMode, ConfigProperties, ConfigValues, CreateMetadataOptions, CreateMockOptions, DeepMock, DeepMockFunction, HttpInjectResult, HttpInjector, HttpStreamInjector, InjectRequest, InjectResponse, InjectSseResponse, InjectStreamRequest, InjectStreamResponse, MockFnFactory, MockFnOption, MockFnSource, MockFunction, MockRegistry, MockResult, MockState, Mocked, MockedMember, OverrideDefinitionEntry, OverrideEntry, OverrideFactoryOptions, PluginInitRoots, ResolvedMockFn, SetCookieInfo, SseMessage, TestAppOptions, TestBedOptions, TestBedResult, TestDependency, TestKey, TestMetadataSpec, TestProvider, UntypedFunction };
|
|
796
|
+
export type { AnyFunction, AopMode, ConfigProperties, ConfigValues, ConnectWebSocketOptions, CreateMetadataOptions, CreateMockOptions, DeepMock, DeepMockFunction, HttpInjectResult, HttpInjector, HttpStreamInjector, InjectRequest, InjectResponse, InjectSseResponse, InjectStreamRequest, InjectStreamResponse, MockFnFactory, MockFnOption, MockFnSource, MockFunction, MockRegistry, MockResult, MockState, Mocked, MockedMember, OverrideDefinitionEntry, OverrideEntry, OverrideFactoryOptions, PluginInitRoots, ResolvedMockFn, SetCookieInfo, SseMessage, TestAppOptions, TestBedOptions, TestBedResult, TestDependency, TestKey, TestMetadataSpec, TestProvider, TestWsClient, TestWsCloseInfo, TestWsConnectOptions, TestWsWaitOptions, UntypedFunction, WebSocketConnector };
|
|
697
797
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
CHANGED
|
@@ -709,6 +709,19 @@ function readGraphQLRoots(metadata) {
|
|
|
709
709
|
}
|
|
710
710
|
return roots;
|
|
711
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Tokens a WebSocket application needs at boot (`extensions.websocket`, RFC 0003 §7.1): every
|
|
714
|
+
* gateway, its handshake and message guards and the guards of its handlers.
|
|
715
|
+
*/
|
|
716
|
+
function readWebSocketRoots(metadata) {
|
|
717
|
+
const roots = [];
|
|
718
|
+
for (const gateway of arrayOf(extension(metadata, "websocket")?.["gateways"])) {
|
|
719
|
+
if (!isRecord(gateway) || typeof gateway["token"] !== "string") continue;
|
|
720
|
+
roots.push(gateway["token"], ...tokensOf(gateway["handshakeGuards"]), ...tokensOf(gateway["messageGuards"]));
|
|
721
|
+
for (const handler of arrayOf(gateway["handlers"])) if (isRecord(handler)) roots.push(...tokensOf(handler["guards"]));
|
|
722
|
+
}
|
|
723
|
+
return roots;
|
|
724
|
+
}
|
|
712
725
|
// src/metadata-index.ts
|
|
713
726
|
/**
|
|
714
727
|
* Read-only index over compiled metadata: provider definitions, aliases, externals and the key
|
|
@@ -827,6 +840,8 @@ function assertMetadata(metadata) {
|
|
|
827
840
|
if (metadata.version !== METADATA_VERSION) throw new ZnightError(ErrorCode.VersionMismatch, `Unsupported metadata version ${String(metadata.version)} (expected ${METADATA_VERSION})`, { hints: ["Regenerate the metadata with the @znight/compiler version matching @znight/testing"] });
|
|
828
841
|
}
|
|
829
842
|
// src/test-app.ts
|
|
843
|
+
/** Name of the WebSocket plugin `TestApp.connect()` delegates to (`WebSocketPlugin` from `@znight/websocket`). */
|
|
844
|
+
const WEBSOCKET_PLUGIN_NAME = "websocket";
|
|
830
845
|
/** Name of the HTTP plugin `TestApp.inject()` delegates to (`HttpPlugin.configure()` from `@znight/http`). */
|
|
831
846
|
const HTTP_PLUGIN_NAME = "http";
|
|
832
847
|
/**
|
|
@@ -917,6 +932,30 @@ var TestApp = class TestApp {
|
|
|
917
932
|
}
|
|
918
933
|
}
|
|
919
934
|
/**
|
|
935
|
+
* Opens an in-memory WebSocket connection through the WebSocket plugin (`WebSocketPlugin` of
|
|
936
|
+
* `@znight/websocket`): the real handshake through the HTTP pipeline (guards, origin and
|
|
937
|
+
* subprotocol checks), then an in-memory transport. `path` is the URL path, HTTP prefix included.
|
|
938
|
+
* Rejects with a `WsHandshakeError` when the handshake is refused.
|
|
939
|
+
*
|
|
940
|
+
* ```ts
|
|
941
|
+
* await using alice = await app.connect('/chat', { protocols: ['chat.v1'] })
|
|
942
|
+
* expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
|
|
943
|
+
* ```
|
|
944
|
+
*
|
|
945
|
+
* @throws ZnightError ZNIGHT001 when the application has no WebSocket plugin, ZNIGHT021 once closed.
|
|
946
|
+
*/
|
|
947
|
+
connect(path, options) {
|
|
948
|
+
try {
|
|
949
|
+
if (this.#closed) throw new ZnightError(ErrorCode.LifecycleFailed, "TestApp.connect(): the application is closed");
|
|
950
|
+
if (typeof path !== "string") throw new TypeError("[znight] TestApp.connect() expects a path");
|
|
951
|
+
const plugin = this.app.getPlugin(WEBSOCKET_PLUGIN_NAME);
|
|
952
|
+
if (plugin === void 0 || typeof plugin.connect !== "function") throw new ZnightError(ErrorCode.MissingProvider, "TestApp.connect() requires the WebSocket plugin", { hints: ["Add WebSocketPlugin.configure() from @znight/websocket (and HttpPlugin) to TestApp.create({ plugins })"] });
|
|
953
|
+
return plugin.connect(path, options);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
return Promise.reject(error);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
920
959
|
* Dispatches a request in memory and resolves at the response headers; the body is read as it is
|
|
921
960
|
* produced (`response.body`). Cancelling the body (or aborting `signal`) disconnects the client.
|
|
922
961
|
*
|
|
@@ -1011,7 +1050,8 @@ function initOptions(options, index, overrides) {
|
|
|
1011
1050
|
...readHttpRoots(metadata),
|
|
1012
1051
|
...readMessagingRoots(metadata),
|
|
1013
1052
|
...readScheduleRoots(metadata),
|
|
1014
|
-
...readGraphQLRoots(metadata)
|
|
1053
|
+
...readGraphQLRoots(metadata),
|
|
1054
|
+
...readWebSocketRoots(metadata)
|
|
1015
1055
|
].filter((token) => index.isProvider(token));
|
|
1016
1056
|
if (roots.length === 0) return {};
|
|
1017
1057
|
} else {
|
|
@@ -1579,6 +1619,207 @@ var TestBed = class {
|
|
|
1579
1619
|
}
|
|
1580
1620
|
}
|
|
1581
1621
|
};
|
|
1582
|
-
|
|
1622
|
+
// src/websocket.ts
|
|
1623
|
+
/**
|
|
1624
|
+
* A refused handshake (`status`, `headers`, `body`, `json()`). `instanceof` also matches the
|
|
1625
|
+
* `WsHandshakeError` of `@znight/websocket` (what `TestApp.connect()` rejects with).
|
|
1626
|
+
*/
|
|
1627
|
+
var WsHandshakeError = class extends Error {
|
|
1628
|
+
status;
|
|
1629
|
+
headers;
|
|
1630
|
+
body;
|
|
1631
|
+
constructor(status, headers = {}, body = "") {
|
|
1632
|
+
super(`The WebSocket handshake was refused with ${status}: ${body}`);
|
|
1633
|
+
this.name = "WsHandshakeError";
|
|
1634
|
+
this.status = status;
|
|
1635
|
+
this.headers = headers;
|
|
1636
|
+
this.body = body;
|
|
1637
|
+
}
|
|
1638
|
+
static [Symbol.hasInstance](value) {
|
|
1639
|
+
return value instanceof Error && value.name === "WsHandshakeError" && typeof value.status === "number";
|
|
1640
|
+
}
|
|
1641
|
+
/** The body parsed as JSON. */
|
|
1642
|
+
json() {
|
|
1643
|
+
return JSON.parse(this.body);
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
/** The failure reply of a request (`code`, `message`, `details`); matches `@znight/websocket`'s too. */
|
|
1647
|
+
var WsRequestError = class extends Error {
|
|
1648
|
+
code;
|
|
1649
|
+
details;
|
|
1650
|
+
constructor(code, message, details) {
|
|
1651
|
+
super(message);
|
|
1652
|
+
this.name = "WsRequestError";
|
|
1653
|
+
this.code = code;
|
|
1654
|
+
this.details = details;
|
|
1655
|
+
}
|
|
1656
|
+
static [Symbol.hasInstance](value) {
|
|
1657
|
+
return value instanceof Error && value.name === "WsRequestError" && typeof value.code === "string";
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
function decode(frame) {
|
|
1661
|
+
if (typeof frame !== "string") return void 0;
|
|
1662
|
+
try {
|
|
1663
|
+
const value = JSON.parse(frame);
|
|
1664
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
1665
|
+
} catch {
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
/** A {@link TestWsClient} over a global `WebSocket`. */
|
|
1670
|
+
var NetworkClient = class {
|
|
1671
|
+
#ws;
|
|
1672
|
+
#entries = [];
|
|
1673
|
+
#waiters = /* @__PURE__ */ new Set();
|
|
1674
|
+
#closed = Promise.withResolvers();
|
|
1675
|
+
#ended = false;
|
|
1676
|
+
#nextId = 1;
|
|
1677
|
+
constructor(ws) {
|
|
1678
|
+
this.#ws = ws;
|
|
1679
|
+
ws.addEventListener("message", (event) => {
|
|
1680
|
+
const data = event.data;
|
|
1681
|
+
this.#receive(typeof data === "string" ? data : new Uint8Array(data));
|
|
1682
|
+
});
|
|
1683
|
+
ws.addEventListener("close", (event) => {
|
|
1684
|
+
this.#ended = true;
|
|
1685
|
+
this.#closed.resolve({
|
|
1686
|
+
code: event.code,
|
|
1687
|
+
reason: event.reason
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
get protocol() {
|
|
1692
|
+
return this.#ws.protocol;
|
|
1693
|
+
}
|
|
1694
|
+
get frames() {
|
|
1695
|
+
return this.#entries.map((entry) => entry.envelope ?? entry.frame);
|
|
1696
|
+
}
|
|
1697
|
+
get closed() {
|
|
1698
|
+
return this.#closed.promise;
|
|
1699
|
+
}
|
|
1700
|
+
send(event, data) {
|
|
1701
|
+
this.#ws.send(JSON.stringify(data === void 0 ? { event } : {
|
|
1702
|
+
event,
|
|
1703
|
+
data
|
|
1704
|
+
}));
|
|
1705
|
+
}
|
|
1706
|
+
request(event, data, options = {}) {
|
|
1707
|
+
const id = this.#nextId++;
|
|
1708
|
+
const reply = this.#wait((entry) => !entry.consumed && entry.envelope?.["id"] === id && entry.envelope["event"] === void 0, (entry, resolve, reject) => {
|
|
1709
|
+
entry.consumed = true;
|
|
1710
|
+
const envelope = entry.envelope;
|
|
1711
|
+
const error = envelope["error"];
|
|
1712
|
+
if (error === void 0) resolve(envelope["data"]);
|
|
1713
|
+
else reject(new WsRequestError(typeof error["code"] === "string" ? error["code"] : "INTERNAL", typeof error["message"] === "string" ? error["message"] : `The request "${event}" failed`, error["details"]));
|
|
1714
|
+
}, options.timeoutMs ?? 5e3, `the reply to "${event}" (id ${id})`);
|
|
1715
|
+
this.#ws.send(JSON.stringify(data === void 0 ? {
|
|
1716
|
+
event,
|
|
1717
|
+
id
|
|
1718
|
+
} : {
|
|
1719
|
+
event,
|
|
1720
|
+
data,
|
|
1721
|
+
id
|
|
1722
|
+
}));
|
|
1723
|
+
return reply;
|
|
1724
|
+
}
|
|
1725
|
+
next(event, options = {}) {
|
|
1726
|
+
return this.#wait((entry) => !entry.consumed && typeof entry.envelope?.["event"] === "string" && (event === void 0 || entry.envelope["event"] === event), (entry, resolve) => {
|
|
1727
|
+
entry.consumed = true;
|
|
1728
|
+
resolve(entry.envelope["data"]);
|
|
1729
|
+
}, options.timeoutMs ?? 1e3, event === void 0 ? "a server event" : `the event "${event}"`);
|
|
1730
|
+
}
|
|
1731
|
+
sendRaw(frame) {
|
|
1732
|
+
this.#ws.send(frame);
|
|
1733
|
+
}
|
|
1734
|
+
nextRaw(options = {}) {
|
|
1735
|
+
return this.#wait((entry) => !entry.rawConsumed, (entry, resolve) => {
|
|
1736
|
+
entry.rawConsumed = true;
|
|
1737
|
+
resolve(entry.frame);
|
|
1738
|
+
}, options.timeoutMs ?? 1e3, "a frame");
|
|
1739
|
+
}
|
|
1740
|
+
close(code = 1e3, reason = "") {
|
|
1741
|
+
if (!this.#ended) this.#ws.close(code, reason);
|
|
1742
|
+
return this.#closed.promise;
|
|
1743
|
+
}
|
|
1744
|
+
async [Symbol.asyncDispose]() {
|
|
1745
|
+
await this.close();
|
|
1746
|
+
}
|
|
1747
|
+
#wait(match, take, timeoutMs, what) {
|
|
1748
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1749
|
+
const found = this.#entries.find(match);
|
|
1750
|
+
if (found !== void 0) {
|
|
1751
|
+
take(found, resolve, reject);
|
|
1752
|
+
return promise;
|
|
1753
|
+
}
|
|
1754
|
+
if (this.#ended) {
|
|
1755
|
+
reject(/* @__PURE__ */ new Error(`The WebSocket connection is closed: no ${what} will arrive`));
|
|
1756
|
+
return promise;
|
|
1757
|
+
}
|
|
1758
|
+
const timer = setTimeout(() => {
|
|
1759
|
+
this.#waiters.delete(waiter);
|
|
1760
|
+
reject(/* @__PURE__ */ new Error(`Timed out after ${timeoutMs} ms waiting for ${what}`));
|
|
1761
|
+
}, timeoutMs);
|
|
1762
|
+
const waiter = {
|
|
1763
|
+
match,
|
|
1764
|
+
take: (entry) => {
|
|
1765
|
+
clearTimeout(timer);
|
|
1766
|
+
take(entry, resolve, reject);
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
this.#waiters.add(waiter);
|
|
1770
|
+
this.#closed.promise.then(() => {
|
|
1771
|
+
if (!this.#waiters.delete(waiter)) return;
|
|
1772
|
+
clearTimeout(timer);
|
|
1773
|
+
reject(/* @__PURE__ */ new Error(`The WebSocket connection closed while waiting for ${what}`));
|
|
1774
|
+
});
|
|
1775
|
+
return promise;
|
|
1776
|
+
}
|
|
1777
|
+
#receive(frame) {
|
|
1778
|
+
const entry = {
|
|
1779
|
+
frame,
|
|
1780
|
+
envelope: decode(frame),
|
|
1781
|
+
consumed: false,
|
|
1782
|
+
rawConsumed: false
|
|
1783
|
+
};
|
|
1784
|
+
this.#entries.push(entry);
|
|
1785
|
+
for (const waiter of this.#waiters) {
|
|
1786
|
+
if (!waiter.match(entry)) continue;
|
|
1787
|
+
this.#waiters.delete(waiter);
|
|
1788
|
+
waiter.take(entry);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
};
|
|
1792
|
+
/**
|
|
1793
|
+
* Connects to a WebSocket server over the network (end-to-end tests, Node.js and Bun) and returns
|
|
1794
|
+
* a {@link TestWsClient}. Rejects with a {@link WsHandshakeError} (`status: 0`: the client cannot
|
|
1795
|
+
* read the status of a refused handshake) when the connection fails.
|
|
1796
|
+
*
|
|
1797
|
+
* ```ts
|
|
1798
|
+
* await using client = await connectWebSocket(`ws://127.0.0.1:${port}/chat`, { headers: { 'x-api-key': KEY } })
|
|
1799
|
+
* ```
|
|
1800
|
+
*/
|
|
1801
|
+
function connectWebSocket(url, options = {}) {
|
|
1802
|
+
const ws = new WebSocket(url, {
|
|
1803
|
+
...options.headers === void 0 ? {} : { headers: options.headers },
|
|
1804
|
+
...options.protocols === void 0 ? {} : { protocols: options.protocols }
|
|
1805
|
+
});
|
|
1806
|
+
ws.binaryType = "arraybuffer";
|
|
1807
|
+
const client = new NetworkClient(ws);
|
|
1808
|
+
return new Promise((resolve, reject) => {
|
|
1809
|
+
const timer = setTimeout(() => {
|
|
1810
|
+
ws.close();
|
|
1811
|
+
reject(/* @__PURE__ */ new Error(`connectWebSocket(${url}): no handshake after ${options.timeoutMs ?? 2e3} ms`));
|
|
1812
|
+
}, options.timeoutMs ?? 2e3);
|
|
1813
|
+
ws.addEventListener("open", () => {
|
|
1814
|
+
clearTimeout(timer);
|
|
1815
|
+
resolve(client);
|
|
1816
|
+
});
|
|
1817
|
+
ws.addEventListener("error", () => {
|
|
1818
|
+
clearTimeout(timer);
|
|
1819
|
+
reject(new WsHandshakeError(0, {}, `connectWebSocket(${url}) failed`));
|
|
1820
|
+
});
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
export { HTTP_PLUGIN_NAME, TestApp, TestBed, WEBSOCKET_PLUGIN_NAME, WsHandshakeError, WsRequestError, clearMock, connectWebSocket, createMetadata, createMock, createSpy, defineTestMetadata, detectMockFn, isBuiltinSpy, isMock, override, overrideFactory, resetMock, resolveMockFn, setDefaultMockFn, testToken };
|
|
1583
1824
|
|
|
1584
1825
|
//# sourceMappingURL=index.mjs.map
|