opinionated-machine 10.4.0 → 10.5.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 +11 -0
- package/README.md +86 -3
- package/dist/lib/api-contracts/apiRouteBuilder.d.ts +1 -1
- package/dist/lib/api-contracts/apiRouteBuilder.js +36 -1
- package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
- package/dist/lib/sse/index.d.ts +1 -0
- package/dist/lib/sse/index.js +1 -0
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/sseSendDiagnostics.d.ts +134 -0
- package/dist/lib/sse/sseSendDiagnostics.js +277 -0
- package/dist/lib/sse/sseSendDiagnostics.js.map +1 -0
- package/dist/lib/testing/apiSseEventValidation.d.ts +40 -0
- package/dist/lib/testing/apiSseEventValidation.js +78 -0
- package/dist/lib/testing/apiSseEventValidation.js.map +1 -0
- package/dist/lib/testing/apiSseHttpHelpers.d.ts +168 -0
- package/dist/lib/testing/apiSseHttpHelpers.js +214 -0
- package/dist/lib/testing/apiSseHttpHelpers.js.map +1 -0
- package/dist/lib/testing/apiSseInjectHelpers.d.ts +17 -2
- package/dist/lib/testing/apiSseInjectHelpers.js +227 -56
- package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/apiSseTestTypes.d.ts +83 -3
- package/dist/lib/testing/index.d.ts +3 -2
- package/dist/lib/testing/index.js +1 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +52 -0
- package/dist/lib/testing/sseHttpClient.js +77 -0
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseTestTypes.d.ts +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sseSendDiagnostics.js","sourceRoot":"","sources":["../../../lib/sse/sseSendDiagnostics.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-aware SSE event validation, shared by the inject helpers (`injectApiSSE`) and the
|
|
3
|
+
* real-HTTP ones (`connectApiSSE`, `SSEHttpClient.apiEvents`) so both paths produce the same
|
|
4
|
+
* discriminated union, validated against the same schemas and reporting the same errors.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import type { SSEEventSchemas } from '@lokalise/api-contracts';
|
|
9
|
+
import { type ApiContract } from '@lokalise/api-contracts';
|
|
10
|
+
import type { ParsedSSEEvent } from '../sse/sseParser.ts';
|
|
11
|
+
import type { ApiSSEEvent } from './apiSseTestTypes.ts';
|
|
12
|
+
/**
|
|
13
|
+
* The contract's SSE schemas, merged across every declared status, or a thrown error naming
|
|
14
|
+
* the reader that asked for them.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveApiSseSchemas(contract: ApiContract, reader: string): SSEEventSchemas;
|
|
17
|
+
/**
|
|
18
|
+
* Validate one parsed event against the contract's schemas and return it as a member of the
|
|
19
|
+
* contract's event union.
|
|
20
|
+
*
|
|
21
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
22
|
+
* @throws if the contract declares no schema for the event name, if `data` isn't valid JSON,
|
|
23
|
+
* or if the payload doesn't match the declared schema
|
|
24
|
+
*/
|
|
25
|
+
export declare function validateApiSseEvent<Contract extends ApiContract>(schemaByEventName: SSEEventSchemas, event: ParsedSSEEvent, reader: string): ApiSSEEvent<Contract>;
|
|
26
|
+
/** Media type an SSE response must carry. */
|
|
27
|
+
export declare const SSE_CONTENT_TYPE = "text/event-stream";
|
|
28
|
+
/** Strip `; charset=…` style parameters from a media type. */
|
|
29
|
+
export declare function mediaTypeOf(contentType: string | undefined): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Reject a response that is not an event stream, naming the reader that asked for one.
|
|
32
|
+
*
|
|
33
|
+
* Shared by both read paths so an endpoint answering with a JSON error (a 401 before
|
|
34
|
+
* `sse.start()`, say) fails with its status and body on either — rather than as zero events,
|
|
35
|
+
* which reads as a timeout on the HTTP path and an empty array on the inject one.
|
|
36
|
+
*
|
|
37
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
38
|
+
* @param body - Response body, when the caller can produce it without consuming a live stream
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertSSEResponse(statusCode: number, contentType: string | undefined, reader: string, body?: string): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-aware SSE event validation, shared by the inject helpers (`injectApiSSE`) and the
|
|
3
|
+
* real-HTTP ones (`connectApiSSE`, `SSEHttpClient.apiEvents`) so both paths produce the same
|
|
4
|
+
* discriminated union, validated against the same schemas and reporting the same errors.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import { getSseSchemaByEventName } from '@lokalise/api-contracts';
|
|
9
|
+
import { truncateBody } from "./sseInjectShared.js";
|
|
10
|
+
/**
|
|
11
|
+
* The contract's SSE schemas, merged across every declared status, or a thrown error naming
|
|
12
|
+
* the reader that asked for them.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveApiSseSchemas(contract, reader) {
|
|
15
|
+
const schemaByEventName = getSseSchemaByEventName(contract);
|
|
16
|
+
if (!schemaByEventName) {
|
|
17
|
+
throw new Error(`${reader} — the contract declares no SSE response`);
|
|
18
|
+
}
|
|
19
|
+
return schemaByEventName;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validate one parsed event against the contract's schemas and return it as a member of the
|
|
23
|
+
* contract's event union.
|
|
24
|
+
*
|
|
25
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
26
|
+
* @throws if the contract declares no schema for the event name, if `data` isn't valid JSON,
|
|
27
|
+
* or if the payload doesn't match the declared schema
|
|
28
|
+
*/
|
|
29
|
+
export function validateApiSseEvent(schemaByEventName, event, reader) {
|
|
30
|
+
// An SSE event without an `event:` field is a `message` event per the spec.
|
|
31
|
+
const name = event.event ?? 'message';
|
|
32
|
+
const schema = schemaByEventName[name];
|
|
33
|
+
if (!schema) {
|
|
34
|
+
throw new Error(`${reader} — the contract declares no schema for event "${name}"`);
|
|
35
|
+
}
|
|
36
|
+
let parsedJson;
|
|
37
|
+
try {
|
|
38
|
+
parsedJson = JSON.parse(event.data);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new Error(`${reader} — data of event "${name}" is not valid JSON: ${err.message}; data: ${truncateBody(event.data)}`);
|
|
42
|
+
}
|
|
43
|
+
const parsed = schema.safeParse(parsedJson);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
throw new Error(`${reader} — data of event "${name}" does not match the declared schema: ${parsed.error.message}; data: ${truncateBody(event.data)}`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...(event.id !== undefined && { id: event.id }),
|
|
49
|
+
...(event.retry !== undefined && { retry: event.retry }),
|
|
50
|
+
event: name,
|
|
51
|
+
data: parsed.data,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Media type an SSE response must carry. */
|
|
55
|
+
export const SSE_CONTENT_TYPE = 'text/event-stream';
|
|
56
|
+
/** Strip `; charset=…` style parameters from a media type. */
|
|
57
|
+
export function mediaTypeOf(contentType) {
|
|
58
|
+
return contentType?.split(';')[0]?.trim().toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reject a response that is not an event stream, naming the reader that asked for one.
|
|
62
|
+
*
|
|
63
|
+
* Shared by both read paths so an endpoint answering with a JSON error (a 401 before
|
|
64
|
+
* `sse.start()`, say) fails with its status and body on either — rather than as zero events,
|
|
65
|
+
* which reads as a timeout on the HTTP path and an empty array on the inject one.
|
|
66
|
+
*
|
|
67
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
68
|
+
* @param body - Response body, when the caller can produce it without consuming a live stream
|
|
69
|
+
*/
|
|
70
|
+
export function assertSSEResponse(statusCode, contentType, reader, body) {
|
|
71
|
+
const mediaType = mediaTypeOf(contentType);
|
|
72
|
+
if (mediaType === SSE_CONTENT_TYPE) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const bodySuffix = body === undefined || body === '' ? '' : ` Body: ${truncateBody(body)}`;
|
|
76
|
+
throw new Error(`${reader} — response is not an SSE stream (status ${statusCode}, content-type ${mediaType ?? 'absent'}); use bodyForStatus(${statusCode}) for declared error responses.${bodySuffix}`);
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=apiSseEventValidation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiSseEventValidation.js","sourceRoot":"","sources":["../../../lib/testing/apiSseEventValidation.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type ApiContract } from '@lokalise/api-contracts';
|
|
2
|
+
import type { SpiedSSESession, SSESessionSpy } from '../sse/SSESessionSpy.ts';
|
|
3
|
+
import { type SSEDiagnosticsScope, type SSESendFailure } from '../sse/sseSendDiagnostics.ts';
|
|
4
|
+
import type { ApiSSEEvent, InjectApiSSEParams } from './apiSseTestTypes.ts';
|
|
5
|
+
import { SSEHttpClient } from './sseHttpClient.ts';
|
|
6
|
+
/**
|
|
7
|
+
* Request params for {@link connectApiSSE}, derived from a `defineApiContract` contract.
|
|
8
|
+
*
|
|
9
|
+
* The same shape `injectApiSSE` takes — `pathParams`, `body`, `queryParams` and `headers` are
|
|
10
|
+
* each required only when the contract declares the matching request schema, `headers` also
|
|
11
|
+
* accepts a (sync or async) factory, and `pathPrefix` is always optional.
|
|
12
|
+
*/
|
|
13
|
+
export type ConnectApiSSEParams<Contract extends ApiContract> = InjectApiSSEParams<Contract>;
|
|
14
|
+
/** Options for waiting on server-side registration, driven by a standalone session spy. */
|
|
15
|
+
export type ConnectApiSSEWithSpyOptions<TSession extends SpiedSSESession> = {
|
|
16
|
+
/**
|
|
17
|
+
* Wait for server-side connection registration after HTTP headers are received, removing
|
|
18
|
+
* the race between `connect()` returning and the handler finishing its registration.
|
|
19
|
+
*
|
|
20
|
+
* Only meaningful for `keepAlive` sessions — an `autoClose` route closes its session as the
|
|
21
|
+
* handler returns, before the wait can claim it.
|
|
22
|
+
*/
|
|
23
|
+
awaitServerConnection: {
|
|
24
|
+
/** A standalone spy, wired to the route via `createSSESessionSpy()`'s `routeOptions` */
|
|
25
|
+
spy: SSESessionSpy<TSession>;
|
|
26
|
+
/** Timeout in milliseconds (default: 5000) */
|
|
27
|
+
timeout?: number;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/** Result of {@link connectApiSSE} when `awaitServerConnection` is used. */
|
|
31
|
+
export type ConnectApiSSEResult<Contract extends ApiContract, TSession extends SpiedSSESession> = {
|
|
32
|
+
client: ApiSSEHttpClient<Contract>;
|
|
33
|
+
serverConnection: TSession;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A live SSE connection over real HTTP, read through the contract that declares it.
|
|
37
|
+
*
|
|
38
|
+
* The contract-typed counterpart of {@link SSEHttpClient}: events arrive incrementally, as
|
|
39
|
+
* they do there, but each one is JSON-parsed, validated against the contract's `sseResponse` /
|
|
40
|
+
* `sseBody` schemas and typed as a discriminated union on `event` — the same events
|
|
41
|
+
* `injectApiSSE` produces, so a suite can move an assertion between the two paths unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ApiSSEHttpClient<Contract extends ApiContract> {
|
|
44
|
+
/** The underlying untyped client, for the parts of it this wrapper doesn't cover. */
|
|
45
|
+
readonly raw: SSEHttpClient;
|
|
46
|
+
private readonly contract;
|
|
47
|
+
private readonly scope;
|
|
48
|
+
/** @internal Built by {@link connectApiSSE}. */
|
|
49
|
+
constructor(raw: SSEHttpClient, contract: Contract, scope: SSEDiagnosticsScope);
|
|
50
|
+
/**
|
|
51
|
+
* The fetch `Response`, available before any event is consumed — so status and headers can
|
|
52
|
+
* be asserted while the handler is still producing events.
|
|
53
|
+
*/
|
|
54
|
+
get response(): Response;
|
|
55
|
+
/**
|
|
56
|
+
* Yield the contract's events as they arrive, typed and validated per event name.
|
|
57
|
+
*
|
|
58
|
+
* Throws — before yielding anything — if the endpoint answered with something other than an
|
|
59
|
+
* event stream (an error raised before `sse.start()`, say), naming its status and body
|
|
60
|
+
* instead of reporting an empty stream.
|
|
61
|
+
*
|
|
62
|
+
* @param signal - Optional `AbortSignal` to stop the generator early
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* for await (const event of client.events()) {
|
|
67
|
+
* if (event.event === 'issue') expect(event.data.severity).toBe('minor')
|
|
68
|
+
* if (event.event === 'review') break
|
|
69
|
+
* }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
events(signal?: AbortSignal): AsyncGenerator<ApiSSEEvent<Contract>, void, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* Collect events until a count is reached or a predicate matches, each one typed and
|
|
75
|
+
* validated against the contract.
|
|
76
|
+
*
|
|
77
|
+
* A collection that ends short — the stream closed early, or the wait timed out — usually
|
|
78
|
+
* means the handler failed to send an event it was supposed to; when that is what happened,
|
|
79
|
+
* the thrown error names the event and its validation issues instead of leaving the test to
|
|
80
|
+
* report a missing event with no reason.
|
|
81
|
+
*
|
|
82
|
+
* Throws straight away if the endpoint answered with something other than an event stream,
|
|
83
|
+
* rather than waiting out the timeout on a stream that was never going to arrive.
|
|
84
|
+
*
|
|
85
|
+
* @param countOrPredicate - Number of events to collect, or a predicate that ends collection
|
|
86
|
+
* (the matching event is included). The predicate is invoked exactly once per event.
|
|
87
|
+
* @param timeout - Maximum time to wait in milliseconds (default: 5000)
|
|
88
|
+
*/
|
|
89
|
+
collectEvents(countOrPredicate: number | ((event: ApiSSEEvent<Contract>) => boolean), timeout?: number): Promise<ApiSSEEvent<Contract>[]>;
|
|
90
|
+
/**
|
|
91
|
+
* Throw what the handler failed to send and did not recover from, if anything was recorded
|
|
92
|
+
* for this connection.
|
|
93
|
+
*
|
|
94
|
+
* A failure the route caught and streamed around left the response it meant to produce, so
|
|
95
|
+
* it is reported through {@link ApiSSEHttpClient.sendFailures} instead of failing the read.
|
|
96
|
+
*/
|
|
97
|
+
private assertNoSendFailures;
|
|
98
|
+
/**
|
|
99
|
+
* Reject a response that is not an event stream, with its status and body.
|
|
100
|
+
*
|
|
101
|
+
* Without this a `401` (or any other pre-stream error response) reads as a stream that
|
|
102
|
+
* never produced an event: `collectEvents` waits out its full timeout and reports "got 0",
|
|
103
|
+
* with the actual status nowhere in the failure.
|
|
104
|
+
*/
|
|
105
|
+
private assertStreamResponse;
|
|
106
|
+
/**
|
|
107
|
+
* Unregister the diagnostics scope once the stream is over, keeping what it recorded.
|
|
108
|
+
*
|
|
109
|
+
* `close()` is the usual trigger; a test that reads a stream to its end and never closes the
|
|
110
|
+
* client would otherwise leave the scope registered for the rest of the process.
|
|
111
|
+
*/
|
|
112
|
+
private releaseScopeIfClosed;
|
|
113
|
+
/**
|
|
114
|
+
* The sends the handler could not make on this connection — a payload that failed the
|
|
115
|
+
* contract's schema for its event, say — recorded instead of being left in the server log.
|
|
116
|
+
*
|
|
117
|
+
* Includes the failures the route recovered from (`handled: true`), which the readers pass
|
|
118
|
+
* over precisely because the response was still the one the route meant to produce.
|
|
119
|
+
*
|
|
120
|
+
* Only routes built with this package's `buildApiRoute` report them.
|
|
121
|
+
*/
|
|
122
|
+
sendFailures(): SSESendFailure[];
|
|
123
|
+
/** Close the connection from the client side. */
|
|
124
|
+
close(): void;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Connect to an SSE endpoint over real HTTP using a contract built with `defineApiContract`.
|
|
128
|
+
*
|
|
129
|
+
* The contract-aware counterpart of `SSEHttpClient.connect`: the method, path, query params,
|
|
130
|
+
* headers and body all come from the contract instead of being repeated as string literals
|
|
131
|
+
* next to it, and the events are typed and validated the way `injectApiSSE().events()` types
|
|
132
|
+
* them — so the tests that read a stream as it arrives keep the contract typing rather than
|
|
133
|
+
* casting `ParsedSSEEvent.data` by hand.
|
|
134
|
+
*
|
|
135
|
+
* Use this (over `injectApiSSE`) when the endpoint keeps its connection open: a `keepAlive`
|
|
136
|
+
* session never completes its response, so only a real HTTP connection can read it.
|
|
137
|
+
*
|
|
138
|
+
* @param baseUrl - Base URL of the running server (e.g. `SSETestServer.baseUrl`)
|
|
139
|
+
* @param contract - Contract built with `defineApiContract`
|
|
140
|
+
* @param params - Request params derived from the contract
|
|
141
|
+
* @param options - `awaitServerConnection`, to also wait for server-side registration
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```typescript
|
|
145
|
+
* const client = await connectApiSSE(server.baseUrl, lqaTextSegmentContract, { body })
|
|
146
|
+
*
|
|
147
|
+
* expect(client.response.status).toBe(200) // asserted while the handler is still working
|
|
148
|
+
* for await (const event of client.events()) {
|
|
149
|
+
* if (event.event === 'issue') expect(event.data.severity).toBe('minor') // typed
|
|
150
|
+
* }
|
|
151
|
+
* client.close()
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* // keepAlive route: wait for the server-side session, then drive it from the test
|
|
157
|
+
* const { spy, routeOptions } = createSSESessionSpy()
|
|
158
|
+
* const { client, serverConnection } = await connectApiSSE(
|
|
159
|
+
* server.baseUrl,
|
|
160
|
+
* tickStreamContract,
|
|
161
|
+
* { pathParams: { channelId: 'c1' }, queryParams: { count: 2 }, headers },
|
|
162
|
+
* { awaitServerConnection: { spy } },
|
|
163
|
+
* )
|
|
164
|
+
* await serverConnection.send('tick', { channelId: 'c1', n: 1 })
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export declare function connectApiSSE<const Contract extends ApiContract>(baseUrl: string, contract: Contract, params: ConnectApiSSEParams<Contract>): Promise<ApiSSEHttpClient<Contract>>;
|
|
168
|
+
export declare function connectApiSSE<const Contract extends ApiContract, TSession extends SpiedSSESession>(baseUrl: string, contract: Contract, params: ConnectApiSSEParams<Contract>, options: ConnectApiSSEWithSpyOptions<TSession>): Promise<ConnectApiSSEResult<Contract, TSession>>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { buildRequestPath } from '@lokalise/api-contracts';
|
|
2
|
+
import { describeSendFailures, openSSEDiagnosticsScope, unhandledSendFailures, } from "../sse/sseSendDiagnostics.js";
|
|
3
|
+
import { assertSSEResponse, mediaTypeOf, SSE_CONTENT_TYPE } from "./apiSseEventValidation.js";
|
|
4
|
+
import { SSEHttpClient } from "./sseHttpClient.js";
|
|
5
|
+
/**
|
|
6
|
+
* A live SSE connection over real HTTP, read through the contract that declares it.
|
|
7
|
+
*
|
|
8
|
+
* The contract-typed counterpart of {@link SSEHttpClient}: events arrive incrementally, as
|
|
9
|
+
* they do there, but each one is JSON-parsed, validated against the contract's `sseResponse` /
|
|
10
|
+
* `sseBody` schemas and typed as a discriminated union on `event` — the same events
|
|
11
|
+
* `injectApiSSE` produces, so a suite can move an assertion between the two paths unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export class ApiSSEHttpClient {
|
|
14
|
+
/** The underlying untyped client, for the parts of it this wrapper doesn't cover. */
|
|
15
|
+
raw;
|
|
16
|
+
contract;
|
|
17
|
+
scope;
|
|
18
|
+
/** @internal Built by {@link connectApiSSE}. */
|
|
19
|
+
constructor(raw, contract, scope) {
|
|
20
|
+
this.raw = raw;
|
|
21
|
+
this.contract = contract;
|
|
22
|
+
this.scope = scope;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The fetch `Response`, available before any event is consumed — so status and headers can
|
|
26
|
+
* be asserted while the handler is still producing events.
|
|
27
|
+
*/
|
|
28
|
+
get response() {
|
|
29
|
+
return this.raw.response;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Yield the contract's events as they arrive, typed and validated per event name.
|
|
33
|
+
*
|
|
34
|
+
* Throws — before yielding anything — if the endpoint answered with something other than an
|
|
35
|
+
* event stream (an error raised before `sse.start()`, say), naming its status and body
|
|
36
|
+
* instead of reporting an empty stream.
|
|
37
|
+
*
|
|
38
|
+
* @param signal - Optional `AbortSignal` to stop the generator early
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* for await (const event of client.events()) {
|
|
43
|
+
* if (event.event === 'issue') expect(event.data.severity).toBe('minor')
|
|
44
|
+
* if (event.event === 'review') break
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
async *events(signal) {
|
|
49
|
+
await this.assertStreamResponse('events()');
|
|
50
|
+
yield* this.raw.apiEvents(this.contract, signal);
|
|
51
|
+
if (!signal?.aborted) {
|
|
52
|
+
// The server ended the stream: anything the handler failed to send is known now, and
|
|
53
|
+
// is why an expected event never arrived.
|
|
54
|
+
this.assertNoSendFailures('events()');
|
|
55
|
+
this.releaseScopeIfClosed();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Collect events until a count is reached or a predicate matches, each one typed and
|
|
60
|
+
* validated against the contract.
|
|
61
|
+
*
|
|
62
|
+
* A collection that ends short — the stream closed early, or the wait timed out — usually
|
|
63
|
+
* means the handler failed to send an event it was supposed to; when that is what happened,
|
|
64
|
+
* the thrown error names the event and its validation issues instead of leaving the test to
|
|
65
|
+
* report a missing event with no reason.
|
|
66
|
+
*
|
|
67
|
+
* Throws straight away if the endpoint answered with something other than an event stream,
|
|
68
|
+
* rather than waiting out the timeout on a stream that was never going to arrive.
|
|
69
|
+
*
|
|
70
|
+
* @param countOrPredicate - Number of events to collect, or a predicate that ends collection
|
|
71
|
+
* (the matching event is included). The predicate is invoked exactly once per event.
|
|
72
|
+
* @param timeout - Maximum time to wait in milliseconds (default: 5000)
|
|
73
|
+
*/
|
|
74
|
+
async collectEvents(countOrPredicate, timeout) {
|
|
75
|
+
await this.assertStreamResponse('collectEvents()');
|
|
76
|
+
// Whether the caller's predicate matched is remembered as it runs, so satisfaction can be
|
|
77
|
+
// decided afterwards without invoking it a second time on the events it already saw.
|
|
78
|
+
let matched = false;
|
|
79
|
+
const target = typeof countOrPredicate === 'number'
|
|
80
|
+
? countOrPredicate
|
|
81
|
+
: (event) => {
|
|
82
|
+
matched = countOrPredicate(event) || matched;
|
|
83
|
+
return matched;
|
|
84
|
+
};
|
|
85
|
+
let collected;
|
|
86
|
+
try {
|
|
87
|
+
collected = await this.raw.collectApiEvents(this.contract, target, timeout);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// The collection failed outright: every recorded failure is context worth reporting,
|
|
91
|
+
// including the ones the route recovered from.
|
|
92
|
+
const failures = this.scope.failures();
|
|
93
|
+
this.releaseScopeIfClosed();
|
|
94
|
+
if (failures.length === 0) {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`collectEvents() — ${describeSendFailures(failures)}\nRaised while collecting: ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
}
|
|
99
|
+
// `collectEvents` also returns short when the server closed the stream before the target
|
|
100
|
+
// was met, which is exactly what a failed send looks like from here.
|
|
101
|
+
const satisfied = typeof countOrPredicate === 'number' ? collected.length >= countOrPredicate : matched;
|
|
102
|
+
if (!satisfied) {
|
|
103
|
+
this.assertNoSendFailures('collectEvents()');
|
|
104
|
+
}
|
|
105
|
+
this.releaseScopeIfClosed();
|
|
106
|
+
return collected;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Throw what the handler failed to send and did not recover from, if anything was recorded
|
|
110
|
+
* for this connection.
|
|
111
|
+
*
|
|
112
|
+
* A failure the route caught and streamed around left the response it meant to produce, so
|
|
113
|
+
* it is reported through {@link ApiSSEHttpClient.sendFailures} instead of failing the read.
|
|
114
|
+
*/
|
|
115
|
+
assertNoSendFailures(reader) {
|
|
116
|
+
const failures = unhandledSendFailures(this.scope.failures());
|
|
117
|
+
if (failures.length > 0) {
|
|
118
|
+
throw new Error(`${reader} — ${describeSendFailures(failures)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Reject a response that is not an event stream, with its status and body.
|
|
123
|
+
*
|
|
124
|
+
* Without this a `401` (or any other pre-stream error response) reads as a stream that
|
|
125
|
+
* never produced an event: `collectEvents` waits out its full timeout and reports "got 0",
|
|
126
|
+
* with the actual status nowhere in the failure.
|
|
127
|
+
*/
|
|
128
|
+
async assertStreamResponse(reader) {
|
|
129
|
+
const { response } = this.raw;
|
|
130
|
+
const contentType = response.headers.get('content-type') ?? undefined;
|
|
131
|
+
let body;
|
|
132
|
+
if (mediaTypeOf(contentType) !== SSE_CONTENT_TYPE) {
|
|
133
|
+
// Only read the body once the response is known not to be a stream, and never from the
|
|
134
|
+
// live response, whose body the client still needs.
|
|
135
|
+
body = await readBodySnapshot(response);
|
|
136
|
+
this.scope.dispose();
|
|
137
|
+
}
|
|
138
|
+
assertSSEResponse(response.status, contentType, reader, body);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Unregister the diagnostics scope once the stream is over, keeping what it recorded.
|
|
142
|
+
*
|
|
143
|
+
* `close()` is the usual trigger; a test that reads a stream to its end and never closes the
|
|
144
|
+
* client would otherwise leave the scope registered for the rest of the process.
|
|
145
|
+
*/
|
|
146
|
+
releaseScopeIfClosed() {
|
|
147
|
+
if (this.raw.isClosed) {
|
|
148
|
+
this.scope.dispose();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The sends the handler could not make on this connection — a payload that failed the
|
|
153
|
+
* contract's schema for its event, say — recorded instead of being left in the server log.
|
|
154
|
+
*
|
|
155
|
+
* Includes the failures the route recovered from (`handled: true`), which the readers pass
|
|
156
|
+
* over precisely because the response was still the one the route meant to produce.
|
|
157
|
+
*
|
|
158
|
+
* Only routes built with this package's `buildApiRoute` report them.
|
|
159
|
+
*/
|
|
160
|
+
sendFailures() {
|
|
161
|
+
return this.scope.failures();
|
|
162
|
+
}
|
|
163
|
+
/** Close the connection from the client side. */
|
|
164
|
+
close() {
|
|
165
|
+
this.scope.dispose();
|
|
166
|
+
this.raw.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** The body of a non-stream response, for an error message; never worth failing over. */
|
|
170
|
+
async function readBodySnapshot(response) {
|
|
171
|
+
try {
|
|
172
|
+
return await response.clone().text();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Already consumed, or aborted mid-read: the status and content-type still say enough.
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export async function connectApiSSE(baseUrl, contract, params, options) {
|
|
180
|
+
// biome-ignore lint/suspicious/noExplicitAny: params shape depends on the contract
|
|
181
|
+
const requestParams = params;
|
|
182
|
+
const path = buildRequestPath(contract.pathResolver(requestParams.pathParams), requestParams.pathPrefix);
|
|
183
|
+
// `headers` may be a factory, exactly as the contract client and `injectApiSSE` accept it.
|
|
184
|
+
const callerHeaders = typeof requestParams.headers === 'function'
|
|
185
|
+
? await requestParams.headers()
|
|
186
|
+
: requestParams.headers;
|
|
187
|
+
// Records the sends the handler could not make, matched to this connection by a header the
|
|
188
|
+
// route builder honours only for scopes open in this process.
|
|
189
|
+
const scope = openSSEDiagnosticsScope();
|
|
190
|
+
const connectOptions = {
|
|
191
|
+
method: contract.method,
|
|
192
|
+
headers: { ...callerHeaders, ...scope.headers },
|
|
193
|
+
...(requestParams.queryParams !== undefined && { query: requestParams.queryParams }),
|
|
194
|
+
...(requestParams.body !== undefined && { body: requestParams.body }),
|
|
195
|
+
};
|
|
196
|
+
// A connection that never happened has no reader to dispose its scope later, and a scope
|
|
197
|
+
// left registered outlives the test that opened it.
|
|
198
|
+
try {
|
|
199
|
+
if (!options) {
|
|
200
|
+
const raw = await SSEHttpClient.connect(baseUrl, path, connectOptions);
|
|
201
|
+
return new ApiSSEHttpClient(raw, contract, scope);
|
|
202
|
+
}
|
|
203
|
+
const { client: raw, serverConnection } = await SSEHttpClient.connect(baseUrl, path, {
|
|
204
|
+
...connectOptions,
|
|
205
|
+
awaitServerConnection: options.awaitServerConnection,
|
|
206
|
+
});
|
|
207
|
+
return { client: new ApiSSEHttpClient(raw, contract, scope), serverConnection };
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
scope.dispose();
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=apiSseHttpHelpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiSseHttpHelpers.js","sourceRoot":"","sources":["../../../lib/testing/apiSseHttpHelpers.ts"],"names":[],"mappings":"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"}
|
|
@@ -31,8 +31,12 @@ export declare function bindApiEvents<Contract extends ApiContract>(contract: Co
|
|
|
31
31
|
* statuses expose no JSON body through `bodyForStatus`; read them with `events()`, or use
|
|
32
32
|
* `injectByApiContract` when you want the JSON side.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* The response is injected as a stream, so `head` resolves as soon as the handler starts
|
|
35
|
+
* streaming and `stream()` yields each event as it is written — a test can assert
|
|
36
|
+
* progressive delivery without a listening server. `closed` and `events()` still wait for
|
|
37
|
+
* the response to complete, so an endpoint that never closes its stream (a `keepAlive`
|
|
38
|
+
* session) can only be read through `stream()`; use `SSEHttpClient` / `connectApiSSE`
|
|
39
|
+
* against a real server for those.
|
|
36
40
|
*
|
|
37
41
|
* @param app - Fastify instance
|
|
38
42
|
* @param contract - Contract built with `defineApiContract`
|
|
@@ -55,6 +59,17 @@ export declare function bindApiEvents<Contract extends ApiContract>(contract: Co
|
|
|
55
59
|
*
|
|
56
60
|
* @example
|
|
57
61
|
* ```typescript
|
|
62
|
+
* // Progressive delivery: each event is observed while the handler is still working
|
|
63
|
+
* const { head, stream } = injectApiSSE(app, lqaTextSegmentContract, { body: { segment } })
|
|
64
|
+
* expect((await head).statusCode).toBe(200)
|
|
65
|
+
*
|
|
66
|
+
* for await (const event of stream()) {
|
|
67
|
+
* if (event.event === 'issue') expect(handlerFinished).toBe(false)
|
|
68
|
+
* }
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
58
73
|
* // A documented pre-stream error response, typed by the contract's 400 schema
|
|
59
74
|
* const { bodyForStatus } = injectApiSSE(app, lqaTextSegmentContract, { body: { segment: '' } })
|
|
60
75
|
* const error = await bodyForStatus(400)
|