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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { getSseSchemaByEventName, resolveResponseEntry, } from '@lokalise/api-contracts';
|
|
2
|
+
import { injectByApiContract } from '@lokalise/fastify-api-contracts';
|
|
3
|
+
import { parseSSEEvents } from "../sse/sseParser.js";
|
|
4
|
+
import { truncateBody } from "./sseInjectShared.js";
|
|
5
|
+
const SSE_CONTENT_TYPE = 'text/event-stream';
|
|
6
|
+
/** Read a response header that light-my-request may expose as an array. */
|
|
7
|
+
function readHeader(value) {
|
|
8
|
+
return Array.isArray(value) ? value[0] : value;
|
|
9
|
+
}
|
|
10
|
+
/** Strip `; charset=…` style parameters from a media type. */
|
|
11
|
+
function mediaTypeOf(contentType) {
|
|
12
|
+
return contentType?.split(';')[0]?.trim().toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
const STATUS_RANGE_KEYS = ['1xx', '2xx', '3xx', '4xx', '5xx'];
|
|
15
|
+
/**
|
|
16
|
+
* The `responsesByStatusCode` key that serves a status, following the same
|
|
17
|
+
* exact → range → `'default'` precedence as `resolveResponseEntry`.
|
|
18
|
+
*
|
|
19
|
+
* Diagnostics only. `resolveResponseEntry` collapses "no entry for this status" and "an entry
|
|
20
|
+
* exists but none of its content-map descriptors matched the response's content-type" into a
|
|
21
|
+
* single `null`; this tells the two apart so the error names the actual problem.
|
|
22
|
+
*/
|
|
23
|
+
function findResponseKeyForStatus(responsesByStatusCode, statusCode) {
|
|
24
|
+
if (responsesByStatusCode[statusCode]) {
|
|
25
|
+
return String(statusCode);
|
|
26
|
+
}
|
|
27
|
+
const rangeKey = STATUS_RANGE_KEYS[Math.floor(statusCode / 100) - 1];
|
|
28
|
+
if (rangeKey && responsesByStatusCode[rangeKey]) {
|
|
29
|
+
return rangeKey;
|
|
30
|
+
}
|
|
31
|
+
return responsesByStatusCode.default ? 'default' : undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The JSON schema the contract declares for the status a response actually carries, or a
|
|
35
|
+
* thrown error naming why there isn't one.
|
|
36
|
+
*
|
|
37
|
+
* Resolution follows the same exact → range → `'default'` precedence (and content-type
|
|
38
|
+
* matching) the contract client uses, so a stream on one status and JSON bodies on the others
|
|
39
|
+
* resolve independently.
|
|
40
|
+
*/
|
|
41
|
+
function resolveJsonSchemaForStatus(responsesByStatusCode, statusCode, res) {
|
|
42
|
+
const contentType = readHeader(res.headers['content-type']);
|
|
43
|
+
// Non-strict resolution: a response without a content-type still resolves to the entry's
|
|
44
|
+
// declared kind, which keeps hand-rolled test handlers working.
|
|
45
|
+
const resolved = resolveResponseEntry(responsesByStatusCode, statusCode, contentType, false);
|
|
46
|
+
if (!resolved) {
|
|
47
|
+
const declaredKey = findResponseKeyForStatus(responsesByStatusCode, statusCode);
|
|
48
|
+
throw new Error(declaredKey === undefined
|
|
49
|
+
? `bodyForStatus(${statusCode}) — no response declared for status ${statusCode} in contract.responsesByStatusCode`
|
|
50
|
+
: `bodyForStatus(${statusCode}) — the '${declaredKey}' entry of contract.responsesByStatusCode declares no body for content-type '${mediaTypeOf(contentType) ?? 'absent'}'; body: ${truncateBody(res.body)}`);
|
|
51
|
+
}
|
|
52
|
+
if (resolved.kind !== 'json') {
|
|
53
|
+
// A dual-mode status lands here: `injectApiSSE` asks for the stream, so that is what the
|
|
54
|
+
// status resolved to. The type layer rules this out, so reaching it means a cast.
|
|
55
|
+
const hint = resolved.kind === 'sse'
|
|
56
|
+
? ` — injectApiSSE requests '${SSE_CONTENT_TYPE}', so a status declaring a stream always answers with it; read it with events()`
|
|
57
|
+
: '';
|
|
58
|
+
throw new Error(`bodyForStatus(${statusCode}) — the contract declares a '${resolved.kind}' response for status ${statusCode}, not a JSON body${hint}`);
|
|
59
|
+
}
|
|
60
|
+
return resolved.schema;
|
|
61
|
+
}
|
|
62
|
+
/** JSON-parse and schema-validate a response body, reporting either failure in context. */
|
|
63
|
+
function parseJsonBody(schema, statusCode, body) {
|
|
64
|
+
let parsedJson;
|
|
65
|
+
try {
|
|
66
|
+
parsedJson = JSON.parse(body);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new Error(`bodyForStatus(${statusCode}) — body is not valid JSON: ${err.message}; body: ${truncateBody(body)}`);
|
|
70
|
+
}
|
|
71
|
+
const parsed = schema.safeParse(parsedJson);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
throw new Error(`bodyForStatus(${statusCode}) — body does not match the declared schema: ${parsed.error.message}; body: ${truncateBody(body)}`);
|
|
74
|
+
}
|
|
75
|
+
return parsed.data;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build a `bodyForStatus` accessor bound to one `injectApiSSE` call.
|
|
79
|
+
*
|
|
80
|
+
* @internal Exported only for unit testing — not part of the public API
|
|
81
|
+
* (the testing barrel re-exports `injectApiSSE` by name).
|
|
82
|
+
*/
|
|
83
|
+
export function bindApiBodyForStatus(contract, closed) {
|
|
84
|
+
// A generic arrow function can't be assigned directly to the generic method signature,
|
|
85
|
+
// so the whole closure is cast once. Keep in sync with `InjectApiSSEResult['bodyForStatus']`.
|
|
86
|
+
return (async (statusCode) => {
|
|
87
|
+
const res = await closed;
|
|
88
|
+
const expected = statusCode;
|
|
89
|
+
if (res.statusCode !== expected) {
|
|
90
|
+
throw new Error(`bodyForStatus(${expected}) — actual status ${res.statusCode}, body: ${truncateBody(res.body)}`);
|
|
91
|
+
}
|
|
92
|
+
const schema = resolveJsonSchemaForStatus(contract.responsesByStatusCode, expected, res);
|
|
93
|
+
return parseJsonBody(schema, expected, res.body);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build an `events` accessor bound to one `injectApiSSE` call: parses the SSE body and
|
|
98
|
+
* validates every event against the contract's `sseBody` schemas.
|
|
99
|
+
*
|
|
100
|
+
* @internal Exported only for unit testing — not part of the public API.
|
|
101
|
+
*/
|
|
102
|
+
export function bindApiEvents(contract, closed) {
|
|
103
|
+
return async () => {
|
|
104
|
+
const res = await closed;
|
|
105
|
+
// Merges the SSE schemas of every declared status, not just the successful ones.
|
|
106
|
+
const schemaByEventName = getSseSchemaByEventName(contract);
|
|
107
|
+
if (!schemaByEventName) {
|
|
108
|
+
throw new Error('events() — the contract declares no SSE response');
|
|
109
|
+
}
|
|
110
|
+
const contentType = mediaTypeOf(readHeader(res.headers['content-type']));
|
|
111
|
+
if (contentType !== SSE_CONTENT_TYPE) {
|
|
112
|
+
throw new Error(`events() — response is not an SSE stream (status ${res.statusCode}, content-type ${contentType ?? 'absent'}); use bodyForStatus(${res.statusCode}) for declared error responses. Body: ${truncateBody(res.body)}`);
|
|
113
|
+
}
|
|
114
|
+
return parseSSEEvents(res.body).map((event) => {
|
|
115
|
+
// An SSE event without an `event:` field is a `message` event per the spec.
|
|
116
|
+
const name = event.event ?? 'message';
|
|
117
|
+
const schema = schemaByEventName[name];
|
|
118
|
+
if (!schema) {
|
|
119
|
+
throw new Error(`events() — the contract declares no schema for event "${name}"`);
|
|
120
|
+
}
|
|
121
|
+
let parsedJson;
|
|
122
|
+
try {
|
|
123
|
+
parsedJson = JSON.parse(event.data);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
throw new Error(`events() — data of event "${name}" is not valid JSON: ${err.message}; data: ${truncateBody(event.data)}`);
|
|
127
|
+
}
|
|
128
|
+
const parsed = schema.safeParse(parsedJson);
|
|
129
|
+
if (!parsed.success) {
|
|
130
|
+
throw new Error(`events() — data of event "${name}" does not match the declared schema: ${parsed.error.message}; data: ${truncateBody(event.data)}`);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
...(event.id !== undefined && { id: event.id }),
|
|
134
|
+
...(event.retry !== undefined && { retry: event.retry }),
|
|
135
|
+
event: name,
|
|
136
|
+
data: parsed.data,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Inject an SSE request using a contract built with `defineApiContract` + `sseResponse` /
|
|
143
|
+
* `sseBody` (the newer `@lokalise/api-contracts` API).
|
|
144
|
+
*
|
|
145
|
+
* The `defineApiContract` counterpart of `injectSSE` / `injectPayloadSSE`, which are typed
|
|
146
|
+
* against the legacy `SSEContractDefinition`. One function covers every method: the HTTP verb
|
|
147
|
+
* comes from the contract, and `params` (`pathParams` / `queryParams` / `headers` / `body` /
|
|
148
|
+
* `pathPrefix`) is the same shape `injectByApiContract` takes, so a body is required exactly
|
|
149
|
+
* when the contract declares `requestBodySchema`.
|
|
150
|
+
*
|
|
151
|
+
* The request always carries `accept: text/event-stream` (a caller-supplied `accept` still
|
|
152
|
+
* wins), so a status declaring a stream answers with it — dual-mode statuses included. Those
|
|
153
|
+
* statuses expose no JSON body through `bodyForStatus`; read them with `events()`, or use
|
|
154
|
+
* `injectByApiContract` when you want the JSON side.
|
|
155
|
+
*
|
|
156
|
+
* Best for SSE endpoints that complete — Fastify's `inject()` waits for the whole response.
|
|
157
|
+
* For long-lived connections, use `SSEHttpClient` against a real HTTP server.
|
|
158
|
+
*
|
|
159
|
+
* @param app - Fastify instance
|
|
160
|
+
* @param contract - Contract built with `defineApiContract`
|
|
161
|
+
* @param params - Request params derived from the contract
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* const { closed, bodyForStatus, events } = injectApiSSE(app, lqaTextSegmentContract, {
|
|
166
|
+
* body: { segment: 'hello' },
|
|
167
|
+
* })
|
|
168
|
+
*
|
|
169
|
+
* // Typed events, validated against the contract's sseResponse schemas
|
|
170
|
+
* for (const event of await events()) {
|
|
171
|
+
* if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
|
|
172
|
+
* }
|
|
173
|
+
*
|
|
174
|
+
* // Or the raw body, for assertions the typed accessors don't cover
|
|
175
|
+
* expect((await closed).statusCode).toBe(200)
|
|
176
|
+
* ```
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* // A documented pre-stream error response, typed by the contract's 400 schema
|
|
181
|
+
* const { bodyForStatus } = injectApiSSE(app, lqaTextSegmentContract, { body: { segment: '' } })
|
|
182
|
+
* const error = await bodyForStatus(400)
|
|
183
|
+
* expect(error.message).toBe('segment must not be empty')
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function injectApiSSE(app, contract, params) {
|
|
187
|
+
// biome-ignore lint/suspicious/noExplicitAny: params shape depends on the contract
|
|
188
|
+
const requestParams = params;
|
|
189
|
+
const closed = injectByApiContract(app, contract, {
|
|
190
|
+
...requestParams,
|
|
191
|
+
// `accept` first so an explicit caller header still wins; headers may be a factory,
|
|
192
|
+
// which `injectByApiContract` resolves for us — resolve the caller's here too.
|
|
193
|
+
headers: async () => ({
|
|
194
|
+
accept: SSE_CONTENT_TYPE,
|
|
195
|
+
...(typeof requestParams.headers === 'function'
|
|
196
|
+
? await requestParams.headers()
|
|
197
|
+
: requestParams.headers),
|
|
198
|
+
}),
|
|
199
|
+
}).then((res) => ({
|
|
200
|
+
statusCode: res.statusCode,
|
|
201
|
+
headers: res.headers,
|
|
202
|
+
body: res.body,
|
|
203
|
+
}));
|
|
204
|
+
return {
|
|
205
|
+
closed,
|
|
206
|
+
bodyForStatus: bindApiBodyForStatus(contract, closed),
|
|
207
|
+
// `events` is typed `never` for contracts that declare no SSE response, which no concrete
|
|
208
|
+
// function satisfies — the binder returns the callable form and it is narrowed here.
|
|
209
|
+
events: bindApiEvents(contract, closed),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=apiSseInjectHelpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiSseInjectHelpers.js","sourceRoot":"","sources":["../../../lib/testing/apiSseInjectHelpers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { ApiContract, ClientErrorHttpStatusCode, ExpandStatusRangeKey, HttpStatusCode, HttpStatusCodeRange, InformationalHttpStatusCode, RedirectionHttpStatusCode, ServerErrorHttpStatusCode, SuccessfulHttpStatusCode } from '@lokalise/api-contracts';
|
|
2
|
+
import type { InjectByApiContractParams } from '@lokalise/fastify-api-contracts';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
import type { SSEResponse } from './sseTestTypes.ts';
|
|
5
|
+
/**
|
|
6
|
+
* Request params for {@link injectApiSSE}, derived from a `defineApiContract` contract.
|
|
7
|
+
*
|
|
8
|
+
* Identical to the params of `injectByApiContract` from
|
|
9
|
+
* `@lokalise/fastify-api-contracts` — `pathParams`, `body`, `queryParams` and
|
|
10
|
+
* `headers` are each required only when the contract declares the matching
|
|
11
|
+
* request schema, `headers` also accepts a (sync or async) factory, and
|
|
12
|
+
* `pathPrefix` is always optional.
|
|
13
|
+
*/
|
|
14
|
+
export type InjectApiSSEParams<Contract extends ApiContract> = InjectByApiContractParams<Contract>;
|
|
15
|
+
/** The `responsesByStatusCode` map of a contract. */
|
|
16
|
+
type Responses<Contract extends ApiContract> = Contract['responsesByStatusCode'];
|
|
17
|
+
/** The concrete status codes a contract declares exactly (non-wildcard keys). */
|
|
18
|
+
type ExactStatusCodes<Contract extends ApiContract> = keyof Responses<Contract> & HttpStatusCode;
|
|
19
|
+
/** Status codes covered by any range key (e.g. `'2xx'`, `'4xx'`) the contract declares. */
|
|
20
|
+
type RangeStatusCodes<Contract extends ApiContract> = {
|
|
21
|
+
[Key in keyof Responses<Contract> & HttpStatusCodeRange]: ExpandStatusRangeKey<Key>;
|
|
22
|
+
}[keyof Responses<Contract> & HttpStatusCodeRange];
|
|
23
|
+
/**
|
|
24
|
+
* Maps a `responsesByStatusCode` key to the statuses it covers, mirroring the runtime
|
|
25
|
+
* lookup precedence (exact → range → `'default'`): a concrete key stays as-is; a range key
|
|
26
|
+
* expands to its status class minus the exactly-declared codes; `'default'` expands to every
|
|
27
|
+
* status not covered by an exact or range key.
|
|
28
|
+
*/
|
|
29
|
+
type StatusesForKey<Contract extends ApiContract, Key> = Key extends 'default' ? Exclude<HttpStatusCode, ExactStatusCodes<Contract> | RangeStatusCodes<Contract>> : Key extends HttpStatusCodeRange ? Exclude<ExpandStatusRangeKey<Key>, ExactStatusCodes<Contract>> : Key;
|
|
30
|
+
/** The range key a concrete status code falls into. */
|
|
31
|
+
type RangeKeyOf<Status extends HttpStatusCode> = Status extends InformationalHttpStatusCode ? '1xx' : Status extends SuccessfulHttpStatusCode ? '2xx' : Status extends RedirectionHttpStatusCode ? '3xx' : Status extends ClientErrorHttpStatusCode ? '4xx' : Status extends ServerErrorHttpStatusCode ? '5xx' : never;
|
|
32
|
+
/** The media type `injectApiSSE` always asks for — see {@link StreamSchemasOfEntry}. */
|
|
33
|
+
type SSEMediaType = 'text/event-stream';
|
|
34
|
+
/**
|
|
35
|
+
* The `event name -> schema` map of an SSE body descriptor, or `never` for any other
|
|
36
|
+
* descriptor (a bare Zod schema is JSON, `blobBody()` is opaque bytes).
|
|
37
|
+
*/
|
|
38
|
+
type SseSchemasOfDescriptor<Descriptor> = Descriptor extends {
|
|
39
|
+
_tag: 'SseBody';
|
|
40
|
+
schemaByEventName: infer Schemas;
|
|
41
|
+
} ? Schemas : never;
|
|
42
|
+
/**
|
|
43
|
+
* Every SSE schema map a response entry declares, across all of its media types — the
|
|
44
|
+
* type-level counterpart of the maps `getSseSchemaByEventName` collects at runtime.
|
|
45
|
+
*/
|
|
46
|
+
type SseSchemasOfEntry<Entry> = Entry extends {
|
|
47
|
+
content: infer Content;
|
|
48
|
+
} ? SseSchemasOfDescriptor<Content[keyof Content]> : never;
|
|
49
|
+
/**
|
|
50
|
+
* The SSE schema map an entry declares under `text/event-stream` specifically.
|
|
51
|
+
*
|
|
52
|
+
* `injectApiSSE` always sends `accept: text/event-stream`, so this is the descriptor a
|
|
53
|
+
* status resolves to whenever it declares one — even on a dual-mode content map that also
|
|
54
|
+
* carries a JSON schema.
|
|
55
|
+
*/
|
|
56
|
+
type StreamSchemasOfEntry<Entry> = Entry extends {
|
|
57
|
+
content: infer Content;
|
|
58
|
+
} ? SseSchemasOfDescriptor<Content[SSEMediaType & keyof Content]> : never;
|
|
59
|
+
/**
|
|
60
|
+
* The JSON Zod schema of a single response entry, or `never` when this helper can't reach a
|
|
61
|
+
* JSON body there. A bare schema is JSON; a content-map entry contributes the schemas of its
|
|
62
|
+
* non-blob, non-SSE descriptors.
|
|
63
|
+
*
|
|
64
|
+
* An entry that declares a `text/event-stream` body has no reachable JSON side: the request
|
|
65
|
+
* asks for the stream, so a dual-mode status answers with SSE and `bodyForStatus` would throw.
|
|
66
|
+
* Read those with `events()` instead.
|
|
67
|
+
*/
|
|
68
|
+
type JsonSchemaOfEntry<Entry> = [StreamSchemasOfEntry<Entry>] extends [never] ? Entry extends z.ZodType ? Entry : Entry extends {
|
|
69
|
+
content: infer Content;
|
|
70
|
+
} ? Extract<Content[keyof Content], z.ZodType> : never : never;
|
|
71
|
+
/** Indexes a responses map with a key that may not exist on it (yielding `never` if it doesn't). */
|
|
72
|
+
type EntryAt<Contract extends ApiContract, Key> = NonNullable<Responses<Contract>[Key & keyof Responses<Contract>]>;
|
|
73
|
+
/** The response entry that serves a concrete status, following exact → range → `'default'`. */
|
|
74
|
+
type EntryForStatus<Contract extends ApiContract, Status extends HttpStatusCode> = [
|
|
75
|
+
EntryAt<Contract, Status>
|
|
76
|
+
] extends [never] ? [EntryAt<Contract, RangeKeyOf<Status>>] extends [never] ? EntryAt<Contract, 'default'> : EntryAt<Contract, RangeKeyOf<Status>> : EntryAt<Contract, Status>;
|
|
77
|
+
/**
|
|
78
|
+
* Status codes for which the contract declares a JSON response body.
|
|
79
|
+
*
|
|
80
|
+
* Resolves to `never` for contracts that declare none, so `bodyForStatus` is uncallable there.
|
|
81
|
+
* Range and `'default'` keys expand to the concrete statuses they serve, so a contract
|
|
82
|
+
* declaring only `4xx` still allows `bodyForStatus(404)`.
|
|
83
|
+
*/
|
|
84
|
+
export type ApiDeclaredResponseStatus<Contract extends ApiContract> = {
|
|
85
|
+
[Key in keyof Responses<Contract>]: [JsonSchemaOfEntry<EntryAt<Contract, Key>>] extends [never] ? never : StatusesForKey<Contract, Key>;
|
|
86
|
+
}[keyof Responses<Contract>] & HttpStatusCode;
|
|
87
|
+
/** Infers a schema's output type, or `never` when there is no schema. */
|
|
88
|
+
type InferJsonBody<Schema> = Schema extends z.ZodType ? z.output<Schema> : never;
|
|
89
|
+
/** The parsed JSON response body a contract declares for a concrete status. */
|
|
90
|
+
export type ApiDeclaredResponseBody<Contract extends ApiContract, Status extends HttpStatusCode> = InferJsonBody<JsonSchemaOfEntry<EntryForStatus<Contract, Status>>>;
|
|
91
|
+
/**
|
|
92
|
+
* Union of the SSE schema maps a contract declares, over *every* status key.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately not `InferSseSuccessResponses`, which only looks at success / `'2xx'` /
|
|
95
|
+
* `'default'` keys: the runtime `getSseSchemaByEventName` merges the maps of every entry in
|
|
96
|
+
* `responsesByStatusCode`, so a stream declared under e.g. `'4xx'` produces validated events
|
|
97
|
+
* too and has to be visible here.
|
|
98
|
+
*/
|
|
99
|
+
type ApiSSEEventSchemas<Contract extends ApiContract> = SseSchemasOfEntry<NonNullable<Responses<Contract>[keyof Responses<Contract>]>>;
|
|
100
|
+
/**
|
|
101
|
+
* The event names of a union of schema maps.
|
|
102
|
+
*
|
|
103
|
+
* `keyof` a union yields only the keys shared by every member, which collapses to `never` as
|
|
104
|
+
* soon as two statuses declare different events — so distribute first and union the keys,
|
|
105
|
+
* mirroring the runtime merge.
|
|
106
|
+
*/
|
|
107
|
+
type SseEventNamesOf<Schemas> = Schemas extends unknown ? keyof Schemas & string : never;
|
|
108
|
+
/**
|
|
109
|
+
* The schema(s) a union of maps declares for one event name. Maps that don't declare it drop
|
|
110
|
+
* out; two maps declaring it with different schemas yield a union, since the runtime merge
|
|
111
|
+
* keeps only one of them and the reader can't tell which.
|
|
112
|
+
*/
|
|
113
|
+
type SseSchemaForEventName<Schemas, Name extends string> = Schemas extends Record<Name, infer Schema> ? Schema : never;
|
|
114
|
+
/** Builds the event union for an already-resolved set of schema maps. */
|
|
115
|
+
type ApiSSEEventOf<Schemas> = {
|
|
116
|
+
[Name in SseEventNamesOf<Schemas>]: {
|
|
117
|
+
/** Event ID, when the server sent an `id:` field. */
|
|
118
|
+
id?: string;
|
|
119
|
+
/** Event name, as sent in the `event:` field (defaults to `message`). */
|
|
120
|
+
event: Name;
|
|
121
|
+
/** Reconnection hint in milliseconds, when the server sent a `retry:` field. */
|
|
122
|
+
retry?: number;
|
|
123
|
+
/** `data:` payload, JSON-parsed and validated against the contract's schema. */
|
|
124
|
+
data: InferJsonBody<SseSchemaForEventName<Schemas, Name>>;
|
|
125
|
+
};
|
|
126
|
+
}[SseEventNamesOf<Schemas>];
|
|
127
|
+
/**
|
|
128
|
+
* Discriminated union of the SSE events a contract declares, with `data` parsed and typed
|
|
129
|
+
* per event name. `never` for a contract that declares no SSE response at all.
|
|
130
|
+
*/
|
|
131
|
+
export type ApiSSEEvent<Contract extends ApiContract> = ApiSSEEventOf<ApiSSEEventSchemas<Contract>>;
|
|
132
|
+
/** Whether a contract declares an SSE response on any status. */
|
|
133
|
+
type HasApiSSEResponse<Contract extends ApiContract> = [ApiSSEEventSchemas<Contract>] extends [
|
|
134
|
+
never
|
|
135
|
+
] ? false : true;
|
|
136
|
+
/**
|
|
137
|
+
* The callable form of {@link InjectApiSSEResult.events}.
|
|
138
|
+
*
|
|
139
|
+
* Always a function, so the internal binder has a type to return regardless of what the
|
|
140
|
+
* contract declares; the result type below hides it behind {@link HasApiSSEResponse}.
|
|
141
|
+
*/
|
|
142
|
+
export type ApiSSEEventReader<Contract extends ApiContract> = () => Promise<ApiSSEEvent<Contract>[]>;
|
|
143
|
+
/**
|
|
144
|
+
* Result of an {@link injectApiSSE} call.
|
|
145
|
+
*
|
|
146
|
+
* The `defineApiContract` counterpart of `InjectSSEResult`: same `closed` promise and
|
|
147
|
+
* `bodyForStatus` accessor, plus `events()` for reading the stream typed against the
|
|
148
|
+
* contract's `sseResponse` / `sseBody` schemas.
|
|
149
|
+
*/
|
|
150
|
+
export type InjectApiSSEResult<Contract extends ApiContract> = {
|
|
151
|
+
/**
|
|
152
|
+
* Resolves when the response completes with the full SSE body.
|
|
153
|
+
* Parse the body with `parseSSEEvents()` — or use `events()` for typed events.
|
|
154
|
+
*/
|
|
155
|
+
closed: Promise<SSEResponse>;
|
|
156
|
+
/**
|
|
157
|
+
* Awaits the response, asserts the status code matches, parses the body against the
|
|
158
|
+
* contract's JSON schema for that status, and returns the parsed object. Useful for
|
|
159
|
+
* asserting on the documented error responses a handler emits before streaming starts.
|
|
160
|
+
*
|
|
161
|
+
* Throws (with the offending status and a truncated body snippet) if:
|
|
162
|
+
* - the actual status code doesn't match the expected one;
|
|
163
|
+
* - the contract declares no JSON body for that status;
|
|
164
|
+
* - the body isn't valid JSON;
|
|
165
|
+
* - the body doesn't match the declared Zod schema.
|
|
166
|
+
*
|
|
167
|
+
* At the type level, `statusCode` is constrained to the statuses the contract declares a
|
|
168
|
+
* JSON body for. Contracts without any can't call this method (`statusCode: never`).
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* const { bodyForStatus } = injectApiSSE(app, contract, { body })
|
|
173
|
+
* const error = await bodyForStatus(400) // typed as z.output<400-schema>
|
|
174
|
+
* expect(error.message).toBe('Bad request')
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
bodyForStatus<Status extends ApiDeclaredResponseStatus<Contract>>(statusCode: Status): Promise<ApiDeclaredResponseBody<Contract, Status>>;
|
|
178
|
+
/**
|
|
179
|
+
* Awaits the response, parses the SSE body, and validates every event against the
|
|
180
|
+
* contract's SSE schemas, returning them as a discriminated union on `event`.
|
|
181
|
+
*
|
|
182
|
+
* Events are typed from the SSE schemas of *every* status the contract declares, merged
|
|
183
|
+
* exactly as the runtime merges them — a contract streaming different events on two
|
|
184
|
+
* statuses yields the union of both.
|
|
185
|
+
*
|
|
186
|
+
* Throws if the response isn't an SSE stream (use `bodyForStatus` for the documented
|
|
187
|
+
* error statuses), if an event name isn't declared by the contract, or if an event payload
|
|
188
|
+
* doesn't match its schema.
|
|
189
|
+
*
|
|
190
|
+
* A contract that declares no SSE response at all types this as `never`, so calling it is
|
|
191
|
+
* a compile error rather than a guaranteed throw — reach for `injectByApiContract` there.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* const events = await injectApiSSE(app, contract, { body }).events()
|
|
196
|
+
* const review = events.find((event) => event.event === 'review')
|
|
197
|
+
* expect(review?.data.score).toBe(42) // `data` typed by the `review` schema
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
events: HasApiSSEResponse<Contract> extends true ? ApiSSEEventReader<Contract> : never;
|
|
201
|
+
};
|
|
202
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiSseTestTypes.js","sourceRoot":"","sources":["../../../lib/testing/apiSseTestTypes.ts"],"names":[],"mappings":""}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { injectApiSSE } from './apiSseInjectHelpers.js';
|
|
2
|
+
export type { ApiDeclaredResponseBody, ApiDeclaredResponseStatus, ApiSSEEvent, ApiSSEEventReader, InjectApiSSEParams, InjectApiSSEResult, } from './apiSseTestTypes.js';
|
|
3
|
+
export { type HasSessionSpy, SSEHttpClient, type SSEHttpConnectOptions, type SSEHttpConnectResult, type SSEHttpConnectWithSessionSpyOptions, type SSEHttpConnectWithSpyOptions, type SSEHttpMethod, } from './sseHttpClient.js';
|
|
2
4
|
export { SSEInjectClient, SSEInjectConnection } from './sseInjectClient.js';
|
|
3
5
|
export { injectPayloadSSE, injectSSE } from './sseInjectHelpers.js';
|
|
6
|
+
export { type CreateSSESessionSpyResult, createSSESessionSpy, type SSESessionSpyHooks, type SSESessionSpyRouteOptions, } from './sseSessionSpyFactory.js';
|
|
4
7
|
export { SSETestServer } from './sseTestServer.js';
|
|
5
|
-
export type { InjectPayloadSSEOptions, InjectSSEOptions, InjectSSEResult, SSEConnectOptions, SSEResponse, SSETestConnection, } from './sseTestTypes.js';
|
|
8
|
+
export type { InjectPayloadSSEOptions, InjectSSEOptions, InjectSSEResult, SSEConnectOptions, SSEInjectMethod, SSEResponse, SSETestConnection, } from './sseTestTypes.js';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export { injectApiSSE } from './apiSseInjectHelpers.js';
|
|
1
2
|
export { SSEHttpClient, } from './sseHttpClient.js';
|
|
2
3
|
export { SSEInjectClient, SSEInjectConnection } from './sseInjectClient.js';
|
|
3
4
|
export { injectPayloadSSE, injectSSE } from './sseInjectHelpers.js';
|
|
5
|
+
export { createSSESessionSpy, } from './sseSessionSpyFactory.js';
|
|
4
6
|
export { SSETestServer } from './sseTestServer.js';
|
|
5
7
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,aAAa,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AASvD,OAAO,EAEL,aAAa,GAMd,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC3E,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EAEL,mBAAmB,GAGpB,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SSESession } from '../routes/fastifyRouteTypes.ts';
|
|
2
|
-
import type { SSESessionSpy } from '../sse/SSESessionSpy.ts';
|
|
2
|
+
import type { SpiedSSESession, SSESessionSpy } from '../sse/SSESessionSpy.ts';
|
|
3
3
|
import { type ParsedSSEEvent } from '../sse/sseParser.ts';
|
|
4
4
|
/**
|
|
5
5
|
* Interface for objects that have a sessionSpy (e.g., SSE controllers in test mode).
|
|
@@ -7,6 +7,16 @@ import { type ParsedSSEEvent } from '../sse/sseParser.ts';
|
|
|
7
7
|
export type HasSessionSpy = {
|
|
8
8
|
connectionSpy: SSESessionSpy;
|
|
9
9
|
};
|
|
10
|
+
/** Canonical, on-the-wire spelling of a supported method. */
|
|
11
|
+
type NormalizedSSEHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
|
|
12
|
+
/**
|
|
13
|
+
* HTTP methods supported when connecting to an SSE endpoint.
|
|
14
|
+
*
|
|
15
|
+
* Both spellings are accepted and normalized internally, so the lowercase form
|
|
16
|
+
* used by route contracts (`buildContract({ method: 'post' })`) can be handed
|
|
17
|
+
* over as-is.
|
|
18
|
+
*/
|
|
19
|
+
export type SSEHttpMethod = NormalizedSSEHttpMethod | Lowercase<NormalizedSSEHttpMethod>;
|
|
10
20
|
/**
|
|
11
21
|
* Options for connecting to an SSE endpoint via HTTP.
|
|
12
22
|
*/
|
|
@@ -15,9 +25,28 @@ export type SSEHttpConnectOptions = {
|
|
|
15
25
|
query?: Record<string, string | undefined>;
|
|
16
26
|
/** Additional headers to send with the request */
|
|
17
27
|
headers?: Record<string, string>;
|
|
28
|
+
/** HTTP method to use, upper- or lowercase (default: 'GET') */
|
|
29
|
+
method?: SSEHttpMethod;
|
|
30
|
+
/**
|
|
31
|
+
* Request body.
|
|
32
|
+
*
|
|
33
|
+
* Values `fetch()` can send natively - strings, `URLSearchParams`, `FormData`,
|
|
34
|
+
* `Blob`/`File`, `ArrayBuffer`, typed arrays (`Buffer`, `Uint8Array`, ...) and
|
|
35
|
+
* `ReadableStream` - are passed through untouched. Anything else is
|
|
36
|
+
* JSON-stringified.
|
|
37
|
+
*
|
|
38
|
+
* `content-type: application/json` is defaulted for JSON-stringified and string
|
|
39
|
+
* bodies, unless `headers` already provides a content type. Bodies that describe
|
|
40
|
+
* their own encoding (`URLSearchParams`, `FormData`, `Blob`) are left for
|
|
41
|
+
* `fetch()` to label, so their content type is never overwritten with a wrong one.
|
|
42
|
+
*
|
|
43
|
+
* Requires a non-GET `method`.
|
|
44
|
+
*/
|
|
45
|
+
body?: unknown;
|
|
18
46
|
};
|
|
19
47
|
/**
|
|
20
|
-
* Options for connecting with automatic server-side connection waiting
|
|
48
|
+
* Options for connecting with automatic server-side connection waiting,
|
|
49
|
+
* driven by a controller's built-in `connectionSpy`.
|
|
21
50
|
*/
|
|
22
51
|
export type SSEHttpConnectWithSpyOptions = SSEHttpConnectOptions & {
|
|
23
52
|
/**
|
|
@@ -32,12 +61,32 @@ export type SSEHttpConnectWithSpyOptions = SSEHttpConnectOptions & {
|
|
|
32
61
|
timeout?: number;
|
|
33
62
|
};
|
|
34
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* Options for connecting with automatic server-side connection waiting,
|
|
66
|
+
* driven by a standalone spy from `createSSESessionSpy()`.
|
|
67
|
+
*
|
|
68
|
+
* Use this for routes built with `buildApiRoute`, which have no controller to
|
|
69
|
+
* read a `connectionSpy` off of.
|
|
70
|
+
*/
|
|
71
|
+
export type SSEHttpConnectWithSessionSpyOptions<TSession extends SpiedSSESession> = SSEHttpConnectOptions & {
|
|
72
|
+
/**
|
|
73
|
+
* Wait for server-side connection registration after HTTP headers are received.
|
|
74
|
+
* This eliminates the race condition between `connect()` returning and the
|
|
75
|
+
* server-side handler completing connection registration.
|
|
76
|
+
*/
|
|
77
|
+
awaitServerConnection: {
|
|
78
|
+
/** A standalone spy, wired to the route via `createSSESessionSpy()`'s `routeOptions` */
|
|
79
|
+
spy: SSESessionSpy<TSession>;
|
|
80
|
+
/** Timeout in milliseconds (default: 5000) */
|
|
81
|
+
timeout?: number;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
35
84
|
/**
|
|
36
85
|
* Result when connecting with awaitServerConnection option.
|
|
37
86
|
*/
|
|
38
|
-
export type SSEHttpConnectResult = {
|
|
87
|
+
export type SSEHttpConnectResult<TSession extends SpiedSSESession = SSESession> = {
|
|
39
88
|
client: SSEHttpClient;
|
|
40
|
-
serverConnection:
|
|
89
|
+
serverConnection: TSession;
|
|
41
90
|
};
|
|
42
91
|
/**
|
|
43
92
|
* SSE client for testing long-lived connections using real HTTP.
|
|
@@ -49,6 +98,13 @@ export type SSEHttpConnectResult = {
|
|
|
49
98
|
* - **Long-lived connections** that stay open indefinitely
|
|
50
99
|
* - **Real-time notifications** where events arrive over time
|
|
51
100
|
* - **Push-based streaming** where the client waits for server-initiated events
|
|
101
|
+
* - **Assertions on the wire while the handler is still running** - `connect()`
|
|
102
|
+
* resolves as soon as headers arrive, so `response.status` / `response.headers`
|
|
103
|
+
* can be checked before the handler produces its first event
|
|
104
|
+
*
|
|
105
|
+
* GET, POST, PUT and PATCH are all supported (see `method` / `body` in
|
|
106
|
+
* {@link SSEHttpConnectOptions}), so POST SSE endpoints that take a request
|
|
107
|
+
* body can be tested over real HTTP too.
|
|
52
108
|
*
|
|
53
109
|
* **When to use SSEHttpClient vs SSEInjectClient:**
|
|
54
110
|
*
|
|
@@ -92,14 +148,33 @@ export type SSEHttpConnectResult = {
|
|
|
92
148
|
* ```
|
|
93
149
|
*/
|
|
94
150
|
export declare class SSEHttpClient {
|
|
95
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* The fetch Response object. Available immediately after connect() returns,
|
|
153
|
+
* before any event is consumed, so status and headers can be asserted while
|
|
154
|
+
* the handler is still running.
|
|
155
|
+
*
|
|
156
|
+
* The response body is only locked once events are first consumed, so
|
|
157
|
+
* `response.json()` / `response.text()` still work for endpoints that
|
|
158
|
+
* answered with a regular HTTP response instead of a stream (for example an
|
|
159
|
+
* error raised before `sse.start()`). Read that body *before* calling
|
|
160
|
+
* `close()` - `close()` aborts the request, which rejects any pending or
|
|
161
|
+
* subsequent body read with an `AbortError`.
|
|
162
|
+
*/
|
|
96
163
|
readonly response: Response;
|
|
97
164
|
private readonly abortController;
|
|
98
|
-
private readonly
|
|
165
|
+
private readonly responseBody;
|
|
166
|
+
private streamReader;
|
|
99
167
|
private readonly decoder;
|
|
100
168
|
private buffer;
|
|
101
169
|
private closed;
|
|
102
170
|
private constructor();
|
|
171
|
+
/**
|
|
172
|
+
* Lazily acquire the stream reader, locking the response body on first use.
|
|
173
|
+
*
|
|
174
|
+
* A missing body is reported here rather than from the constructor, so a
|
|
175
|
+
* bodiless response (e.g. `204`) can still be inspected via `response`.
|
|
176
|
+
*/
|
|
177
|
+
private get reader();
|
|
103
178
|
/**
|
|
104
179
|
* Connect to an SSE endpoint.
|
|
105
180
|
*
|
|
@@ -109,7 +184,7 @@ export declare class SSEHttpClient {
|
|
|
109
184
|
*
|
|
110
185
|
* @param baseUrl - Base URL of the server (e.g., 'http://localhost:3000')
|
|
111
186
|
* @param path - SSE endpoint path (e.g., '/api/notifications')
|
|
112
|
-
* @param options - Connection options (query params, headers)
|
|
187
|
+
* @param options - Connection options (method, body, query params, headers)
|
|
113
188
|
* @returns Connected SSE client ready to receive events
|
|
114
189
|
*
|
|
115
190
|
* @example
|
|
@@ -121,6 +196,16 @@ export declare class SSEHttpClient {
|
|
|
121
196
|
* { query: { userId: '123' }, headers: { authorization: 'Bearer token' } }
|
|
122
197
|
* )
|
|
123
198
|
*
|
|
199
|
+
* // POST with a JSON body (content-type defaults to application/json)
|
|
200
|
+
* const client = await SSEHttpClient.connect(
|
|
201
|
+
* 'http://localhost:3000',
|
|
202
|
+
* '/api/chat/completions',
|
|
203
|
+
* { method: 'POST', body: { message: 'Hello', stream: true } }
|
|
204
|
+
* )
|
|
205
|
+
* // Headers are on the wire before the handler finished its slow work
|
|
206
|
+
* expect(client.response.status).toBe(200)
|
|
207
|
+
* expect(client.response.headers.get('content-type')).toContain('text/event-stream')
|
|
208
|
+
*
|
|
124
209
|
* // With awaitServerConnection (waits for server-side registration)
|
|
125
210
|
* const { client, serverConnection } = await SSEHttpClient.connect(
|
|
126
211
|
* 'http://localhost:3000',
|
|
@@ -129,9 +214,20 @@ export declare class SSEHttpClient {
|
|
|
129
214
|
* )
|
|
130
215
|
* // serverConnection is ready to use immediately
|
|
131
216
|
* await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
|
|
217
|
+
*
|
|
218
|
+
* // Same, for a `buildApiRoute` route with no controller: wire a standalone
|
|
219
|
+
* // spy into the route's hooks with createSSESessionSpy()
|
|
220
|
+
* const { spy, routeOptions } = createSSESessionSpy()
|
|
221
|
+
* const { client, serverConnection } = await SSEHttpClient.connect(
|
|
222
|
+
* 'http://localhost:3000',
|
|
223
|
+
* '/api/stream',
|
|
224
|
+
* { awaitServerConnection: { spy } }
|
|
225
|
+
* )
|
|
226
|
+
* await serverConnection.send('test', {})
|
|
132
227
|
* ```
|
|
133
228
|
*/
|
|
134
229
|
static connect(baseUrl: string, path: string, options: SSEHttpConnectWithSpyOptions): Promise<SSEHttpConnectResult>;
|
|
230
|
+
static connect<TSession extends SpiedSSESession>(baseUrl: string, path: string, options: SSEHttpConnectWithSessionSpyOptions<TSession>): Promise<SSEHttpConnectResult<TSession>>;
|
|
135
231
|
static connect(baseUrl: string, path: string, options?: SSEHttpConnectOptions): Promise<SSEHttpClient>;
|
|
136
232
|
/**
|
|
137
233
|
* Async generator that yields parsed SSE events as they arrive.
|
|
@@ -198,6 +294,11 @@ export declare class SSEHttpClient {
|
|
|
198
294
|
*
|
|
199
295
|
* This aborts the underlying fetch request. Call this when done
|
|
200
296
|
* consuming events to clean up resources.
|
|
297
|
+
*
|
|
298
|
+
* Aborting also discards any unread response body, so for a non-stream
|
|
299
|
+
* response (an error raised before `sse.start()`, say) read
|
|
300
|
+
* `response.json()` / `response.text()` before calling this.
|
|
201
301
|
*/
|
|
202
302
|
close(): void;
|
|
203
303
|
}
|
|
304
|
+
export {};
|