opinionated-machine 10.0.0 → 10.3.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/CHANGELOG.md +23 -0
- package/README.md +190 -4
- package/dist/lib/sse/SSESessionSpy.d.ts +39 -7
- package/dist/lib/sse/SSESessionSpy.js +30 -1
- package/dist/lib/sse/SSESessionSpy.js.map +1 -1
- package/dist/lib/sse/index.d.ts +1 -1
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/testing/apiSseInjectHelpers.d.ts +64 -0
- package/dist/lib/testing/apiSseInjectHelpers.js +212 -0
- package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -0
- package/dist/lib/testing/apiSseTestTypes.d.ts +202 -0
- package/dist/lib/testing/apiSseTestTypes.js +2 -0
- package/dist/lib/testing/apiSseTestTypes.js.map +1 -0
- package/dist/lib/testing/index.d.ts +5 -2
- package/dist/lib/testing/index.js +2 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +108 -7
- package/dist/lib/testing/sseHttpClient.js +132 -19
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseInjectHelpers.js +1 -12
- package/dist/lib/testing/sseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/sseInjectShared.d.ts +7 -0
- package/dist/lib/testing/sseInjectShared.js +19 -0
- package/dist/lib/testing/sseInjectShared.js.map +1 -0
- package/dist/lib/testing/sseSessionSpyFactory.d.ts +109 -0
- package/dist/lib/testing/sseSessionSpyFactory.js +100 -0
- package/dist/lib/testing/sseSessionSpyFactory.js.map +1 -0
- package/dist/lib/testing/sseTestTypes.d.ts +12 -1
- package/package.json +1 -1
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
import { stringify } from 'fast-querystring';
|
|
2
2
|
import { parseSSEBuffer } from "../sse/sseParser.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether `fetch()` can send this value as-is. Everything else is JSON-stringified,
|
|
5
|
+
* so binary and form payloads are not silently mangled into `{}` or `{"0":1}`.
|
|
6
|
+
*/
|
|
7
|
+
function isFetchBody(body) {
|
|
8
|
+
return (typeof body === 'string' ||
|
|
9
|
+
body instanceof URLSearchParams ||
|
|
10
|
+
body instanceof FormData ||
|
|
11
|
+
body instanceof Blob ||
|
|
12
|
+
body instanceof ArrayBuffer ||
|
|
13
|
+
ArrayBuffer.isView(body) ||
|
|
14
|
+
body instanceof ReadableStream);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether the body carries its own content type, which `fetch()` sets correctly
|
|
18
|
+
* (form encoding, multipart boundary, blob type) and we must not overwrite.
|
|
19
|
+
*/
|
|
20
|
+
function describesOwnContentType(body) {
|
|
21
|
+
return body instanceof URLSearchParams || body instanceof FormData || body instanceof Blob;
|
|
22
|
+
}
|
|
23
|
+
function hasContentTypeHeader(headers) {
|
|
24
|
+
return Object.keys(headers).some((header) => header.toLowerCase() === 'content-type');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the value handed to `fetch()` as the request body, defaulting the
|
|
28
|
+
* content type (into `headers`) whenever we are the ones choosing the encoding.
|
|
29
|
+
*/
|
|
30
|
+
function buildRequestBody(body, method, headers) {
|
|
31
|
+
if (body === undefined) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (method === 'GET') {
|
|
35
|
+
throw new Error("SSEHttpClient.connect(): a request body requires a non-GET method. Pass e.g. { method: 'POST', body }.");
|
|
36
|
+
}
|
|
37
|
+
if (isFetchBody(body)) {
|
|
38
|
+
if (!describesOwnContentType(body) && !hasContentTypeHeader(headers)) {
|
|
39
|
+
headers['content-type'] = 'application/json';
|
|
40
|
+
}
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
const serializedBody = JSON.stringify(body);
|
|
44
|
+
if (serializedBody === undefined) {
|
|
45
|
+
throw new Error(`SSEHttpClient.connect(): a body of type ${typeof body} cannot be serialized to JSON. Pass a JSON-serializable value, a string, or a body fetch() accepts natively.`);
|
|
46
|
+
}
|
|
47
|
+
if (!hasContentTypeHeader(headers)) {
|
|
48
|
+
headers['content-type'] = 'application/json';
|
|
49
|
+
}
|
|
50
|
+
return serializedBody;
|
|
51
|
+
}
|
|
3
52
|
/**
|
|
4
53
|
* SSE client for testing long-lived connections using real HTTP.
|
|
5
54
|
*
|
|
@@ -10,6 +59,13 @@ import { parseSSEBuffer } from "../sse/sseParser.js";
|
|
|
10
59
|
* - **Long-lived connections** that stay open indefinitely
|
|
11
60
|
* - **Real-time notifications** where events arrive over time
|
|
12
61
|
* - **Push-based streaming** where the client waits for server-initiated events
|
|
62
|
+
* - **Assertions on the wire while the handler is still running** - `connect()`
|
|
63
|
+
* resolves as soon as headers arrive, so `response.status` / `response.headers`
|
|
64
|
+
* can be checked before the handler produces its first event
|
|
65
|
+
*
|
|
66
|
+
* GET, POST, PUT and PATCH are all supported (see `method` / `body` in
|
|
67
|
+
* {@link SSEHttpConnectOptions}), so POST SSE endpoints that take a request
|
|
68
|
+
* body can be tested over real HTTP too.
|
|
13
69
|
*
|
|
14
70
|
* **When to use SSEHttpClient vs SSEInjectClient:**
|
|
15
71
|
*
|
|
@@ -53,20 +109,44 @@ import { parseSSEBuffer } from "../sse/sseParser.js";
|
|
|
53
109
|
* ```
|
|
54
110
|
*/
|
|
55
111
|
export class SSEHttpClient {
|
|
56
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* The fetch Response object. Available immediately after connect() returns,
|
|
114
|
+
* before any event is consumed, so status and headers can be asserted while
|
|
115
|
+
* the handler is still running.
|
|
116
|
+
*
|
|
117
|
+
* The response body is only locked once events are first consumed, so
|
|
118
|
+
* `response.json()` / `response.text()` still work for endpoints that
|
|
119
|
+
* answered with a regular HTTP response instead of a stream (for example an
|
|
120
|
+
* error raised before `sse.start()`). Read that body *before* calling
|
|
121
|
+
* `close()` - `close()` aborts the request, which rejects any pending or
|
|
122
|
+
* subsequent body read with an `AbortError`.
|
|
123
|
+
*/
|
|
57
124
|
response;
|
|
58
125
|
abortController;
|
|
59
|
-
|
|
126
|
+
responseBody;
|
|
127
|
+
streamReader;
|
|
60
128
|
decoder = new TextDecoder();
|
|
61
129
|
buffer = '';
|
|
62
130
|
closed = false;
|
|
63
131
|
constructor(response, abortController) {
|
|
64
132
|
this.response = response;
|
|
65
133
|
this.abortController = abortController;
|
|
66
|
-
|
|
67
|
-
|
|
134
|
+
this.responseBody = response.body;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Lazily acquire the stream reader, locking the response body on first use.
|
|
138
|
+
*
|
|
139
|
+
* A missing body is reported here rather than from the constructor, so a
|
|
140
|
+
* bodiless response (e.g. `204`) can still be inspected via `response`.
|
|
141
|
+
*/
|
|
142
|
+
get reader() {
|
|
143
|
+
if (!this.streamReader) {
|
|
144
|
+
if (!this.responseBody) {
|
|
145
|
+
throw new Error(`SSE response has no body to stream (status ${this.response.status}). Inspect \`client.response\` instead of consuming events.`);
|
|
146
|
+
}
|
|
147
|
+
this.streamReader = this.responseBody.getReader();
|
|
68
148
|
}
|
|
69
|
-
this.
|
|
149
|
+
return this.streamReader;
|
|
70
150
|
}
|
|
71
151
|
static async connect(baseUrl, path, options) {
|
|
72
152
|
// Build path with query string
|
|
@@ -77,24 +157,51 @@ export class SSEHttpClient {
|
|
|
77
157
|
pathWithQuery = `${path}?${queryString}`;
|
|
78
158
|
}
|
|
79
159
|
}
|
|
160
|
+
// Normalize once - contracts spell methods in lowercase, the wire and Node use uppercase
|
|
161
|
+
const method = (options?.method ?? 'GET').toUpperCase();
|
|
162
|
+
const headers = {
|
|
163
|
+
Accept: 'text/event-stream',
|
|
164
|
+
...options?.headers,
|
|
165
|
+
};
|
|
166
|
+
const body = buildRequestBody(options?.body, method, headers);
|
|
80
167
|
// Connect - fetch() returns when headers are received
|
|
81
168
|
const abortController = new AbortController();
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
},
|
|
169
|
+
const requestInit = {
|
|
170
|
+
method,
|
|
171
|
+
headers,
|
|
172
|
+
body,
|
|
87
173
|
signal: abortController.signal,
|
|
88
|
-
}
|
|
174
|
+
};
|
|
175
|
+
if (body instanceof ReadableStream) {
|
|
176
|
+
// A streamed request body has to declare half-duplex explicitly, or fetch() rejects it
|
|
177
|
+
;
|
|
178
|
+
requestInit.duplex = 'half';
|
|
179
|
+
}
|
|
180
|
+
const response = await fetch(`${baseUrl}${pathWithQuery}`, requestInit);
|
|
89
181
|
const client = new SSEHttpClient(response, abortController);
|
|
90
182
|
// If awaitServerConnection is specified, wait for server-side registration
|
|
91
183
|
if (options && 'awaitServerConnection' in options && options.awaitServerConnection) {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
timeout: timeout ?? 5000,
|
|
95
|
-
predicate: (conn) => conn.request.url === pathWithQuery,
|
|
96
|
-
}
|
|
97
|
-
|
|
184
|
+
const awaitOptions = options.awaitServerConnection;
|
|
185
|
+
const waitOptions = {
|
|
186
|
+
timeout: awaitOptions.timeout ?? 5000,
|
|
187
|
+
predicate: (conn) => conn.request.url === pathWithQuery && conn.request.method.toUpperCase() === method,
|
|
188
|
+
};
|
|
189
|
+
try {
|
|
190
|
+
// Both branches call the same method; the spy is invariant in its session
|
|
191
|
+
// type, so they cannot be collapsed into one reference.
|
|
192
|
+
const serverConnection = 'spy' in awaitOptions
|
|
193
|
+
? await awaitOptions.spy.waitForConnection(waitOptions)
|
|
194
|
+
: await awaitOptions.controller.connectionSpy.waitForConnection(waitOptions);
|
|
195
|
+
return { client, serverConnection };
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// The HTTP connection is already established and the caller never gets a
|
|
199
|
+
// handle to it, so close it here. Left open, a keep-alive route keeps the
|
|
200
|
+
// socket streaming and the test's `app.close()` hangs, hiding this error
|
|
201
|
+
// behind a suite-level timeout.
|
|
202
|
+
client.close();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
98
205
|
}
|
|
99
206
|
return client;
|
|
100
207
|
}
|
|
@@ -249,11 +356,17 @@ export class SSEHttpClient {
|
|
|
249
356
|
*
|
|
250
357
|
* This aborts the underlying fetch request. Call this when done
|
|
251
358
|
* consuming events to clean up resources.
|
|
359
|
+
*
|
|
360
|
+
* Aborting also discards any unread response body, so for a non-stream
|
|
361
|
+
* response (an error raised before `sse.start()`, say) read
|
|
362
|
+
* `response.json()` / `response.text()` before calling this.
|
|
252
363
|
*/
|
|
253
364
|
close() {
|
|
254
365
|
this.closed = true;
|
|
255
|
-
// Cancel the reader first to prevent unhandled rejections from pending reads
|
|
256
|
-
|
|
366
|
+
// Cancel the reader first to prevent unhandled rejections from pending reads.
|
|
367
|
+
// Only touch it if events were actually consumed - acquiring it here would
|
|
368
|
+
// lock a body the caller may still want to read as JSON/text.
|
|
369
|
+
this.streamReader?.cancel().catch(() => {
|
|
257
370
|
// Expected: may already be closed or errored
|
|
258
371
|
});
|
|
259
372
|
this.abortController.abort();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sseHttpClient.js","sourceRoot":"","sources":["../../../lib/testing/sseHttpClient.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAG5C,OAAO,EAAuB,cAAc,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"sseHttpClient.js","sourceRoot":"","sources":["../../../lib/testing/sseHttpClient.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const BODY_TRUNCATE_LIMIT = 500;
|
|
3
|
-
const truncateBody = (body) => {
|
|
4
|
-
if (body.length <= BODY_TRUNCATE_LIMIT) {
|
|
5
|
-
return body;
|
|
6
|
-
}
|
|
7
|
-
// Step back one unit if the cut would split a surrogate pair, so the
|
|
8
|
-
// snippet never ends in a lone (invalid) surrogate.
|
|
9
|
-
const lastCode = body.charCodeAt(BODY_TRUNCATE_LIMIT - 1);
|
|
10
|
-
const end = lastCode >= 0xd800 && lastCode <= 0xdbff ? BODY_TRUNCATE_LIMIT - 1 : BODY_TRUNCATE_LIMIT;
|
|
11
|
-
return `${body.slice(0, end)}…`;
|
|
12
|
-
};
|
|
1
|
+
import { truncateBody } from "./sseInjectShared.js";
|
|
13
2
|
/**
|
|
14
3
|
* Build a `bodyForStatus` accessor bound to one inject call. The closure
|
|
15
4
|
* captures the contract's schemas map so the resulting helper knows which
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sseInjectHelpers.js","sourceRoot":"","sources":["../../../lib/testing/sseInjectHelpers.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sseInjectHelpers.js","sourceRoot":"","sources":["../../../lib/testing/sseInjectHelpers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by the contract-aware SSE inject helpers (legacy
|
|
3
|
+
* `SSEContractDefinition` and `defineApiContract` flavours alike).
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
/** Truncate a long body string for error messages. */
|
|
8
|
+
const BODY_TRUNCATE_LIMIT = 500;
|
|
9
|
+
export const truncateBody = (body) => {
|
|
10
|
+
if (body.length <= BODY_TRUNCATE_LIMIT) {
|
|
11
|
+
return body;
|
|
12
|
+
}
|
|
13
|
+
// Step back one unit if the cut would split a surrogate pair, so the
|
|
14
|
+
// snippet never ends in a lone (invalid) surrogate.
|
|
15
|
+
const lastCode = body.charCodeAt(BODY_TRUNCATE_LIMIT - 1);
|
|
16
|
+
const end = lastCode >= 0xd800 && lastCode <= 0xdbff ? BODY_TRUNCATE_LIMIT - 1 : BODY_TRUNCATE_LIMIT;
|
|
17
|
+
return `${body.slice(0, end)}…`;
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=sseInjectShared.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sseInjectShared.js","sourceRoot":"","sources":["../../../lib/testing/sseInjectShared.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,sDAAsD;AACtD,MAAM,mBAAmB,GAAG,GAAG,CAAA;AAE/B,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,IAAY,EAAU,EAAE;IACnD,IAAI,IAAI,CAAC,MAAM,IAAI,mBAAmB,EAAE,CAAC;QACvC,OAAO,IAAI,CAAA;IACb,CAAC;IACD,qEAAqE;IACrE,oDAAoD;IACpD,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,mBAAmB,GAAG,CAAC,CAAC,CAAA;IACzD,MAAM,GAAG,GACP,QAAQ,IAAI,MAAM,IAAI,QAAQ,IAAI,MAAM,CAAC,CAAC,CAAC,mBAAmB,GAAG,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAA;IAC1F,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAA;AACjC,CAAC,CAAA"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { SSESession as ApiSSESession, SSECloseInitiator } from '@lokalise/fastify-api-contracts';
|
|
2
|
+
import { type SpiedSSESession, SSESessionSpy } from '../sse/SSESessionSpy.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The SSE lifecycle hooks a route may declare, as far as a spy is concerned.
|
|
5
|
+
*
|
|
6
|
+
* Structurally a subset of the SSE route options of both `buildApiRoute` /
|
|
7
|
+
* `buildFastifyApiRoute` and this package's own `buildFastifyRoute`, so any of
|
|
8
|
+
* their option objects can be handed to {@link CreateSSESessionSpyResult.withSpy}.
|
|
9
|
+
*/
|
|
10
|
+
export type SSESessionSpyHooks<TSession extends SpiedSSESession = ApiSSESession> = {
|
|
11
|
+
onConnect?: (connection: TSession) => void | Promise<void>;
|
|
12
|
+
onClose?: (connection: TSession, initiator: SSECloseInitiator) => void | Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The SSE lifecycle hooks that feed a standalone {@link SSESessionSpy}.
|
|
16
|
+
*
|
|
17
|
+
* Shaped to be spread straight into route options that accept SSE lifecycle
|
|
18
|
+
* hooks — `buildApiRoute` / `buildFastifyApiRoute` options, or this package's
|
|
19
|
+
* own `buildFastifyRoute` options.
|
|
20
|
+
*
|
|
21
|
+
* Spreading these over options that declare `onConnect` / `onClose` of their own
|
|
22
|
+
* would silently drop one side or the other; use
|
|
23
|
+
* {@link CreateSSESessionSpyResult.withSpy} for such routes, which chains both.
|
|
24
|
+
*/
|
|
25
|
+
export type SSESessionSpyRouteOptions<TSession extends SpiedSSESession = ApiSSESession> = {
|
|
26
|
+
onConnect: (connection: TSession) => void | Promise<void>;
|
|
27
|
+
onClose: (connection: TSession, initiator: SSECloseInitiator) => void | Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Result of {@link createSSESessionSpy}.
|
|
31
|
+
*/
|
|
32
|
+
export type CreateSSESessionSpyResult<TSession extends SpiedSSESession = ApiSSESession> = {
|
|
33
|
+
/** The spy, ready to hand to `SSEHttpClient.connect`'s `awaitServerConnection`. */
|
|
34
|
+
spy: SSESessionSpy<TSession>;
|
|
35
|
+
/**
|
|
36
|
+
* `{ onConnect, onClose }` to spread into the options of a route that declares
|
|
37
|
+
* no SSE lifecycle hooks of its own. Equivalent to `withSpy()`.
|
|
38
|
+
*/
|
|
39
|
+
routeOptions: SSESessionSpyRouteOptions<TSession>;
|
|
40
|
+
/**
|
|
41
|
+
* Merge the spy's hooks into a route's own options.
|
|
42
|
+
*
|
|
43
|
+
* Any `onConnect` / `onClose` the route already declares is kept and runs
|
|
44
|
+
* first; the spy is notified once that hook settles. Every other option is
|
|
45
|
+
* passed through untouched. Use this instead of spreading
|
|
46
|
+
* {@link CreateSSESessionSpyResult.routeOptions}, which would replace the
|
|
47
|
+
* route's hooks rather than compose with them.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* app.route(
|
|
52
|
+
* buildApiRoute(contract, handler, withSpy({
|
|
53
|
+
* heartbeat: false,
|
|
54
|
+
* onConnect: (connection) => subscriptions.add(connection.id),
|
|
55
|
+
* })),
|
|
56
|
+
* )
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
withSpy: <TOptions extends object>(options?: TOptions & SSESessionSpyHooks<TSession>) => Omit<TOptions, 'onConnect' | 'onClose'> & SSESessionSpyRouteOptions<TSession>;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Create a standalone connection spy plus the route hooks that drive it.
|
|
63
|
+
*
|
|
64
|
+
* `AbstractSSEController` exposes a spy of its own via `connectionSpy` (gated on
|
|
65
|
+
* `{ enableConnectionSpy: true }`). Routes built with `buildApiRoute` have no
|
|
66
|
+
* such controller, so this factory drives a spy from the route's `onConnect` /
|
|
67
|
+
* `onClose` hooks instead. That removes the race between `connect()` returning
|
|
68
|
+
* (HTTP headers received) and the server-side handler finishing its connection
|
|
69
|
+
* registration.
|
|
70
|
+
*
|
|
71
|
+
* **The hooks have to be passed to the `buildApiRoute()` call that builds the
|
|
72
|
+
* route.** The route builder captures them when it constructs the handler, so
|
|
73
|
+
* attaching them to an already-built `RouteOptions` object has no effect. A
|
|
74
|
+
* route owned by production code therefore has to accept SSE route options (or
|
|
75
|
+
* the hooks themselves) as a parameter for a test to be able to spy on it.
|
|
76
|
+
*
|
|
77
|
+
* **Only useful for sessions that outlive the handler** — `keepAlive` sessions.
|
|
78
|
+
* An `autoClose` route closes its session as the handler returns, before the
|
|
79
|
+
* test can claim it, so `awaitServerConnection` on such a route races and
|
|
80
|
+
* usually times out. Omit `awaitServerConnection` there and assert on the
|
|
81
|
+
* events the client received instead.
|
|
82
|
+
*
|
|
83
|
+
* The spy observes `@lokalise/fastify-api-contracts` sessions by default, which
|
|
84
|
+
* is what `buildApiRoute` hands to its hooks. Pass this package's `SSESession`
|
|
85
|
+
* as the type argument when wiring it to `buildFastifyRoute` instead.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```typescript
|
|
89
|
+
* const { spy, routeOptions } = createSSESessionSpy()
|
|
90
|
+
*
|
|
91
|
+
* // in the app-under-test's route registration
|
|
92
|
+
* app.route(buildApiRoute(contract, handler, { ...routeOptions }))
|
|
93
|
+
*
|
|
94
|
+
* // in the test
|
|
95
|
+
* const { client, serverConnection } = await SSEHttpClient.connect(baseUrl, path, {
|
|
96
|
+
* awaitServerConnection: { spy },
|
|
97
|
+
* })
|
|
98
|
+
* // serverConnection is registered server-side and ready to use
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* // Route with lifecycle hooks of its own: compose instead of replacing them
|
|
104
|
+
* const { spy, withSpy } = createSSESessionSpy()
|
|
105
|
+
*
|
|
106
|
+
* app.route(buildApiRoute(contract, handler, withSpy(productionRouteOptions)))
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export declare function createSSESessionSpy<TSession extends SpiedSSESession = ApiSSESession>(): CreateSSESessionSpyResult<TSession>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { SSESessionSpy } from "../sse/SSESessionSpy.js";
|
|
2
|
+
const isThenable = (value) => typeof value?.then === 'function';
|
|
3
|
+
/**
|
|
4
|
+
* Run a route's own lifecycle hook, then notify the spy — even if the hook fails.
|
|
5
|
+
*
|
|
6
|
+
* The spy is an observer of the connection, not a participant in it: a hook that
|
|
7
|
+
* throws is a failure of the app under test, and swallowing the connection event
|
|
8
|
+
* on top of it would surface as an unrelated `waitForConnection` timeout much
|
|
9
|
+
* later. The hook's own failure is propagated to the route builder unchanged.
|
|
10
|
+
*
|
|
11
|
+
* The spy is notified *after* the hook, mirroring `AbstractSSEController`, so a
|
|
12
|
+
* hook that enriches the session does so before any waiter observes it.
|
|
13
|
+
*/
|
|
14
|
+
function runHookThenNotify(hook, notify) {
|
|
15
|
+
let result;
|
|
16
|
+
try {
|
|
17
|
+
result = hook();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
notify();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
if (isThenable(result)) {
|
|
24
|
+
return result.then(() => notify(), (error) => {
|
|
25
|
+
notify();
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
notify();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create a standalone connection spy plus the route hooks that drive it.
|
|
33
|
+
*
|
|
34
|
+
* `AbstractSSEController` exposes a spy of its own via `connectionSpy` (gated on
|
|
35
|
+
* `{ enableConnectionSpy: true }`). Routes built with `buildApiRoute` have no
|
|
36
|
+
* such controller, so this factory drives a spy from the route's `onConnect` /
|
|
37
|
+
* `onClose` hooks instead. That removes the race between `connect()` returning
|
|
38
|
+
* (HTTP headers received) and the server-side handler finishing its connection
|
|
39
|
+
* registration.
|
|
40
|
+
*
|
|
41
|
+
* **The hooks have to be passed to the `buildApiRoute()` call that builds the
|
|
42
|
+
* route.** The route builder captures them when it constructs the handler, so
|
|
43
|
+
* attaching them to an already-built `RouteOptions` object has no effect. A
|
|
44
|
+
* route owned by production code therefore has to accept SSE route options (or
|
|
45
|
+
* the hooks themselves) as a parameter for a test to be able to spy on it.
|
|
46
|
+
*
|
|
47
|
+
* **Only useful for sessions that outlive the handler** — `keepAlive` sessions.
|
|
48
|
+
* An `autoClose` route closes its session as the handler returns, before the
|
|
49
|
+
* test can claim it, so `awaitServerConnection` on such a route races and
|
|
50
|
+
* usually times out. Omit `awaitServerConnection` there and assert on the
|
|
51
|
+
* events the client received instead.
|
|
52
|
+
*
|
|
53
|
+
* The spy observes `@lokalise/fastify-api-contracts` sessions by default, which
|
|
54
|
+
* is what `buildApiRoute` hands to its hooks. Pass this package's `SSESession`
|
|
55
|
+
* as the type argument when wiring it to `buildFastifyRoute` instead.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const { spy, routeOptions } = createSSESessionSpy()
|
|
60
|
+
*
|
|
61
|
+
* // in the app-under-test's route registration
|
|
62
|
+
* app.route(buildApiRoute(contract, handler, { ...routeOptions }))
|
|
63
|
+
*
|
|
64
|
+
* // in the test
|
|
65
|
+
* const { client, serverConnection } = await SSEHttpClient.connect(baseUrl, path, {
|
|
66
|
+
* awaitServerConnection: { spy },
|
|
67
|
+
* })
|
|
68
|
+
* // serverConnection is registered server-side and ready to use
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* // Route with lifecycle hooks of its own: compose instead of replacing them
|
|
74
|
+
* const { spy, withSpy } = createSSESessionSpy()
|
|
75
|
+
*
|
|
76
|
+
* app.route(buildApiRoute(contract, handler, withSpy(productionRouteOptions)))
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function createSSESessionSpy() {
|
|
80
|
+
const spy = new SSESessionSpy();
|
|
81
|
+
// `TOptions extends object` rather than `extends SSESessionSpyHooks`: with an
|
|
82
|
+
// all-optional constraint, TypeScript falls back to the constraint itself for
|
|
83
|
+
// an argument that declares no hooks, which would drop every other route
|
|
84
|
+
// option from the result type. Hook compatibility is enforced by the
|
|
85
|
+
// intersection in the parameter position instead.
|
|
86
|
+
const withSpy = (options) => {
|
|
87
|
+
const { onConnect, onClose, ...passThrough } = options ?? {};
|
|
88
|
+
return {
|
|
89
|
+
...passThrough,
|
|
90
|
+
onConnect: (connection) => runHookThenNotify(() => onConnect?.(connection), () => spy.addConnection(connection)),
|
|
91
|
+
onClose: (connection, initiator) => runHookThenNotify(() => onClose?.(connection, initiator), () => spy.addDisconnection(connection.id)),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
spy,
|
|
96
|
+
routeOptions: withSpy(),
|
|
97
|
+
withSpy,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=sseSessionSpyFactory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sseSessionSpyFactory.js","sourceRoot":"","sources":["../../../lib/testing/sseSessionSpyFactory.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwB,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAiE7E,MAAM,UAAU,GAAG,CAAC,KAAc,EAA6B,EAAE,CAC/D,OAAQ,KAAwC,EAAE,IAAI,KAAK,UAAU,CAAA;AAEvE;;;;;;;;;;GAUG;AACH,SAAS,iBAAiB,CACxB,IAAgC,EAChC,MAAkB;IAElB,IAAI,MAA4B,CAAA;IAChC,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,EAAE,CAAA;IACjB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,EAAE,CAAA;QACR,MAAM,KAAK,CAAA;IACb,CAAC;IAED,IAAI,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QACvB,OAAO,MAAM,CAAC,IAAI,CAChB,GAAG,EAAE,CAAC,MAAM,EAAE,EACd,CAAC,KAAc,EAAE,EAAE;YACjB,MAAM,EAAE,CAAA;YACR,MAAM,KAAK,CAAA;QACb,CAAC,CACF,CAAA;IACH,CAAC;IAED,MAAM,EAAE,CAAA;AACV,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAM,UAAU,mBAAmB;IAGjC,MAAM,GAAG,GAAG,IAAI,aAAa,EAAY,CAAA;IAEzC,8EAA8E;IAC9E,8EAA8E;IAC9E,yEAAyE;IACzE,qEAAqE;IACrE,kDAAkD;IAClD,MAAM,OAAO,GAAG,CACd,OAAiD,EAC8B,EAAE;QACjF,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,WAAW,EAAE,GAC1C,OAAO,IAAK,EAA8C,CAAA;QAE5D,OAAO;YACL,GAAI,WAAuD;YAC3D,SAAS,EAAE,CAAC,UAAU,EAAE,EAAE,CACxB,iBAAiB,CACf,GAAG,EAAE,CAAC,SAAS,EAAE,CAAC,UAAU,CAAC,EAC7B,GAAG,EAAE,CAAC,GAAG,CAAC,aAAa,CAAC,UAAU,CAAC,CACpC;YACH,OAAO,EAAE,CAAC,UAAU,EAAE,SAAS,EAAE,EAAE,CACjC,iBAAiB,CACf,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,SAAS,CAAC,EACtC,GAAG,EAAE,CAAC,GAAG,CAAC,gBAAgB,CAAC,UAAU,CAAC,EAAE,CAAC,CAC1C;SACJ,CAAA;IACH,CAAC,CAAA;IAED,OAAO;QACL,GAAG;QACH,YAAY,EAAE,OAAO,EAAE;QACvB,OAAO;KACR,CAAA;AACH,CAAC"}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { AnySSEContractDefinition, HttpStatusCode } from '@lokalise/api-contracts';
|
|
2
|
+
import type { InjectOptions } from 'fastify';
|
|
2
3
|
import type { z } from 'zod';
|
|
3
4
|
import type { ParsedSSEEvent } from '../sse/sseParser.ts';
|
|
5
|
+
/**
|
|
6
|
+
* HTTP methods Fastify's `inject()` accepts, in either case.
|
|
7
|
+
*
|
|
8
|
+
* Derived from Fastify's own inject options instead of being hand-listed, so it
|
|
9
|
+
* tracks whatever the installed Fastify supports and never needs to be redefined
|
|
10
|
+
* downstream. Note that Fastify's `HTTPMethods` is wider than this - it also
|
|
11
|
+
* covers `SEARCH`, `QUERY` and the WebDAV verbs, which `inject()` does not take.
|
|
12
|
+
*/
|
|
13
|
+
export type SSEInjectMethod = NonNullable<InjectOptions['method']>;
|
|
4
14
|
/** Safely infer the output type of an optional Zod schema property. */
|
|
5
15
|
type InferOptionalSchema<T, Fallback = unknown> = NonNullable<T> extends z.ZodTypeAny ? z.infer<NonNullable<T>> : Fallback;
|
|
6
16
|
/**
|
|
@@ -56,7 +66,8 @@ export interface SSETestConnection {
|
|
|
56
66
|
*/
|
|
57
67
|
export type SSEConnectOptions = {
|
|
58
68
|
headers?: Record<string, string>;
|
|
59
|
-
method
|
|
69
|
+
/** Any method `inject()` accepts (default: `'POST'` for `connectWithBody`) */
|
|
70
|
+
method?: SSEInjectMethod;
|
|
60
71
|
body?: unknown;
|
|
61
72
|
};
|
|
62
73
|
/**
|