@fuzdev/fuz_app 0.21.0 → 0.23.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/dist/actions/register_action_ws.d.ts +56 -2
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +56 -3
- package/dist/testing/ws_round_trip.d.ts +111 -10
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +143 -48
- package/package.json +1 -1
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
* @module
|
|
23
23
|
*/
|
|
24
24
|
import type { Context, Hono } from 'hono';
|
|
25
|
-
import type { UpgradeWebSocket } from 'hono/ws';
|
|
25
|
+
import type { UpgradeWebSocket, WSContext } from 'hono/ws';
|
|
26
26
|
import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
|
|
27
27
|
import { type JsonrpcRequestId } from '../http/jsonrpc.js';
|
|
28
|
+
import type { Uuid } from '../uuid.js';
|
|
28
29
|
import type { ActionSpecUnion } from './action_spec.js';
|
|
29
|
-
import { BackendWebsocketTransport } from './transports_ws_backend.js';
|
|
30
|
+
import { BackendWebsocketTransport, type ConnectionIdentity } from './transports_ws_backend.js';
|
|
30
31
|
/**
|
|
31
32
|
* Minimum per-request context every handler receives.
|
|
32
33
|
*
|
|
@@ -51,6 +52,45 @@ export interface BaseHandlerContext {
|
|
|
51
52
|
}
|
|
52
53
|
/** Handler signature — receives validated input and per-request context. */
|
|
53
54
|
export type WsActionHandler<TCtx extends BaseHandlerContext> = (input: unknown, ctx: TCtx) => unknown;
|
|
55
|
+
/**
|
|
56
|
+
* Context passed to the `on_socket_open` hook.
|
|
57
|
+
*
|
|
58
|
+
* Fires after the transport has registered the new connection (so
|
|
59
|
+
* `connection_id` is valid) but before any client message can dispatch.
|
|
60
|
+
* Consumers use this to bootstrap per-socket domain state — e.g. undying
|
|
61
|
+
* spawns the per-account spirit unit and pushes an initial state snapshot.
|
|
62
|
+
*/
|
|
63
|
+
export interface SocketOpenContext {
|
|
64
|
+
/** The raw WebSocket context — exposed for edge cases; prefer `notify` for sends. */
|
|
65
|
+
ws: WSContext;
|
|
66
|
+
/** Connection id assigned by `BackendWebsocketTransport.add_connection`. */
|
|
67
|
+
connection_id: Uuid;
|
|
68
|
+
/** Auth identity registered for this connection. */
|
|
69
|
+
identity: ConnectionIdentity;
|
|
70
|
+
/**
|
|
71
|
+
* Send a JSON-RPC notification to just this socket. Mirrors `ctx.notify`
|
|
72
|
+
* on per-message handler contexts — same socket-scoped semantics.
|
|
73
|
+
*/
|
|
74
|
+
notify: (method: string, params: unknown) => void;
|
|
75
|
+
/** Fires when this socket closes — threaded through to every handler's `ctx.signal`. */
|
|
76
|
+
signal: AbortSignal;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Context passed to the `on_socket_close` hook.
|
|
80
|
+
*
|
|
81
|
+
* Fires before `transport.remove_connection` runs, so consumer cleanup can
|
|
82
|
+
* still read identity before it's torn down. Fires for both client-initiated
|
|
83
|
+
* closes (Hono onClose) and server-initiated closes via audit revocation
|
|
84
|
+
* (the audit guard calls `ws.close()`, which triggers Hono's onClose).
|
|
85
|
+
*/
|
|
86
|
+
export interface SocketCloseContext {
|
|
87
|
+
/** The raw WebSocket context at close time. */
|
|
88
|
+
ws: WSContext;
|
|
89
|
+
/** Connection id captured at open time. */
|
|
90
|
+
connection_id: Uuid;
|
|
91
|
+
/** Auth identity captured at open time — still valid even if the transport already cleaned up. */
|
|
92
|
+
identity: ConnectionIdentity;
|
|
93
|
+
}
|
|
54
94
|
/** Options for `register_action_ws`. */
|
|
55
95
|
export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
|
|
56
96
|
/** Mount path (e.g., `/api/ws`). */
|
|
@@ -80,6 +120,20 @@ export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
|
|
|
80
120
|
artificial_delay?: number;
|
|
81
121
|
/** Optional logger; defaults to `[ws]` namespace. */
|
|
82
122
|
log?: LoggerType;
|
|
123
|
+
/**
|
|
124
|
+
* Called once per socket, after the transport registers the connection.
|
|
125
|
+
* Awaited before any message is dispatched. Throwing logs an error and
|
|
126
|
+
* closes the socket with an `internal_error` frame — a failing bootstrap
|
|
127
|
+
* should not leave a partially-initialized socket alive.
|
|
128
|
+
*/
|
|
129
|
+
on_socket_open?: (ctx: SocketOpenContext) => void | Promise<void>;
|
|
130
|
+
/**
|
|
131
|
+
* Called once per socket on close, *before* the transport removes the
|
|
132
|
+
* connection. Receives `connection_id` and `identity` captured at open
|
|
133
|
+
* time, so it is safe to read even when the audit guard has already torn
|
|
134
|
+
* down the transport's internal state. Errors are logged and swallowed.
|
|
135
|
+
*/
|
|
136
|
+
on_socket_close?: (ctx: SocketCloseContext) => void | Promise<void>;
|
|
83
137
|
}
|
|
84
138
|
/** Result of `register_action_ws`. */
|
|
85
139
|
export interface RegisterActionWsResult {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAC,OAAO,EAAE,IAAI,EAAC,MAAM,MAAM,CAAC;AACxC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"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"}
|
|
@@ -51,7 +51,7 @@ import { BackendWebsocketTransport } from './transports_ws_backend.js';
|
|
|
51
51
|
* `create_ws_auth_guard` or broadcast on audit events.
|
|
52
52
|
*/
|
|
53
53
|
export const register_action_ws = (options) => {
|
|
54
|
-
const { path, app, upgradeWebSocket, specs, handlers, extend_context, transport = new BackendWebsocketTransport(), artificial_delay = 0, log = new Logger('[ws]'), } = options;
|
|
54
|
+
const { path, app, upgradeWebSocket, specs, handlers, extend_context, transport = new BackendWebsocketTransport(), artificial_delay = 0, log = new Logger('[ws]'), on_socket_open, on_socket_close, } = options;
|
|
55
55
|
// Build spec lookup for per-action auth and input validation.
|
|
56
56
|
const spec_by_method = new Map(specs.map((spec) => [spec.method, spec]));
|
|
57
57
|
app.get(path, upgradeWebSocket((c) => {
|
|
@@ -75,10 +75,51 @@ export const register_action_ws = (options) => {
|
|
|
75
75
|
// a single socket-scoped signal is sufficient today since cancel
|
|
76
76
|
// granularity tracks connection lifetime, not individual requests.
|
|
77
77
|
const socket_abort_controller = new AbortController();
|
|
78
|
+
// Identity is assembled at upgrade time so `on_socket_close` can
|
|
79
|
+
// still read it after the audit guard tears the transport record
|
|
80
|
+
// down; `BackendWebsocketTransport.#revoke_connection` clears the
|
|
81
|
+
// identity map before Hono fires onClose.
|
|
82
|
+
const identity = { token_hash, account_id, api_token_id };
|
|
83
|
+
// Captured on open, consumed on close. Null before onOpen fires or
|
|
84
|
+
// when a consumer never opens (e.g. immediate disconnect).
|
|
85
|
+
let captured_connection_id = null;
|
|
86
|
+
// Socket-scoped notification helper — routes to this socket only,
|
|
87
|
+
// matches the `ctx.notify` semantics exposed to per-message handlers.
|
|
88
|
+
const notify_socket = (ws) => (notify_method, notify_params) => {
|
|
89
|
+
try {
|
|
90
|
+
const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
|
|
91
|
+
ws.send(JSON.stringify(notification));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
log.error('notify send failed:', notify_method, error);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
78
97
|
return {
|
|
79
|
-
onOpen: (event, ws) => {
|
|
98
|
+
onOpen: async (event, ws) => {
|
|
80
99
|
const connection_id = transport.add_connection(ws, token_hash, account_id, api_token_id);
|
|
100
|
+
captured_connection_id = connection_id;
|
|
81
101
|
log.debug('ws opened', connection_id, event);
|
|
102
|
+
if (on_socket_open) {
|
|
103
|
+
try {
|
|
104
|
+
await on_socket_open({
|
|
105
|
+
ws,
|
|
106
|
+
connection_id,
|
|
107
|
+
identity,
|
|
108
|
+
notify: notify_socket(ws),
|
|
109
|
+
signal: socket_abort_controller.signal,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
log.error('on_socket_open failed — closing socket:', error);
|
|
114
|
+
try {
|
|
115
|
+
ws.send(JSON.stringify(create_jsonrpc_error_response(null, jsonrpc_error_messages.internal_error())));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// ignore — socket may already be dead
|
|
119
|
+
}
|
|
120
|
+
ws.close(1011, 'socket bootstrap failed');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
82
123
|
},
|
|
83
124
|
onMessage: async (event, ws) => {
|
|
84
125
|
let json;
|
|
@@ -178,8 +219,20 @@ export const register_action_ws = (options) => {
|
|
|
178
219
|
ws.send(JSON.stringify(create_jsonrpc_error_response_from_thrown(id, error)));
|
|
179
220
|
}
|
|
180
221
|
},
|
|
181
|
-
onClose: (event, ws) => {
|
|
222
|
+
onClose: async (event, ws) => {
|
|
182
223
|
socket_abort_controller.abort();
|
|
224
|
+
if (on_socket_close && captured_connection_id) {
|
|
225
|
+
try {
|
|
226
|
+
await on_socket_close({
|
|
227
|
+
ws,
|
|
228
|
+
connection_id: captured_connection_id,
|
|
229
|
+
identity,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
log.error('on_socket_close failed:', error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
183
236
|
transport.remove_connection(ws);
|
|
184
237
|
log.debug('ws closed', event);
|
|
185
238
|
},
|
|
@@ -8,16 +8,25 @@
|
|
|
8
8
|
* token id), and exposes a `connect()` factory returning a
|
|
9
9
|
* `MockWsClient` per connection.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Three layers are exported:
|
|
12
12
|
*
|
|
13
13
|
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
14
|
-
* `create_stub_upgrade`, `MinimalActionEnvironment
|
|
15
|
-
* fuz_app's own dispatcher tests
|
|
16
|
-
* one-off tests.
|
|
14
|
+
* `create_stub_upgrade`, `MinimalActionEnvironment`,
|
|
15
|
+
* `dispatch_ws_message`) — used by fuz_app's own dispatcher tests
|
|
16
|
+
* and by consumers wiring tight one-off tests.
|
|
17
17
|
* - **Harness** (`create_ws_test_harness`, `MockWsClient`,
|
|
18
18
|
* `keeper_identity`) — the high-level driver. Give it specs +
|
|
19
|
-
* handlers, get back `{transport, connect()}`.
|
|
20
|
-
*
|
|
19
|
+
* handlers, get back `{transport, connect()}`. `connect()` is async
|
|
20
|
+
* and resolves after `on_socket_open` completes, so broadcasts sent
|
|
21
|
+
* immediately after `await harness.connect()` reach the client.
|
|
22
|
+
* - **Round-trip helpers** — `is_notification` / `is_notification_with`
|
|
23
|
+
* / `is_response_for` predicates, JSON-RPC wire-frame types
|
|
24
|
+
* (`JsonrpcNotificationFrame`, `JsonrpcSuccessResponseFrame`,
|
|
25
|
+
* `JsonrpcErrorResponseFrame` — distinct from the runtime Zod types
|
|
26
|
+
* in `http/jsonrpc.ts` so tests can narrow `params` / `result`),
|
|
27
|
+
* and `build_broadcast_api` for wiring a typed broadcast API against
|
|
28
|
+
* the harness's transport. Used by consumer round-trip test suites
|
|
29
|
+
* to replace ~100 lines of verbatim-identical glue.
|
|
21
30
|
*
|
|
22
31
|
* Hono's wire upgrade is skipped — the Node test runtime has no
|
|
23
32
|
* `@hono/node-ws` adapter — but the full dispatch path is exercised
|
|
@@ -36,6 +45,7 @@ import { type BaseHandlerContext, type RegisterActionWsOptions, type WsActionHan
|
|
|
36
45
|
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
|
|
37
46
|
import { type RequestContext } from '../auth/request_context.js';
|
|
38
47
|
import { type CredentialType } from '../hono_context.js';
|
|
48
|
+
import { JSONRPC_VERSION } from '../http/jsonrpc.js';
|
|
39
49
|
import { type Uuid } from '../uuid.js';
|
|
40
50
|
/**
|
|
41
51
|
* A `WSContext` paired with capture arrays. Use `sends` to assert on
|
|
@@ -120,17 +130,76 @@ export interface WsConnectIdentity {
|
|
|
120
130
|
export interface MockWsClient {
|
|
121
131
|
/** Send a JSON-RPC message (request or notification) to the server. */
|
|
122
132
|
send: (message: unknown) => Promise<void>;
|
|
123
|
-
/**
|
|
124
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Send a JSON-RPC request and await its response. Resolves with the
|
|
135
|
+
* `result`; throws with a useful message (code, text, and any `data`
|
|
136
|
+
* payload) on an error frame — without this, asserting on
|
|
137
|
+
* `result.foo` for a failed request throws
|
|
138
|
+
* `Cannot read property 'foo' of undefined`, which hides the real
|
|
139
|
+
* cause. Use `send` + `wait_for(is_response_for(id))` directly when
|
|
140
|
+
* you need to assert on the error frame itself.
|
|
141
|
+
*/
|
|
142
|
+
request: <R = unknown>(id: number | string, method: string, params: unknown, timeout_ms?: number) => Promise<R>;
|
|
143
|
+
/**
|
|
144
|
+
* Close the connection, firing `onClose`. Returns a promise that
|
|
145
|
+
* resolves once `on_socket_close` (and the transport's own cleanup)
|
|
146
|
+
* have completed — tests that assert on post-close state should await.
|
|
147
|
+
*/
|
|
148
|
+
close: (code?: number, reason?: string) => Promise<void>;
|
|
125
149
|
/** Every message the server has sent, in arrival order. */
|
|
126
150
|
readonly messages: ReadonlyArray<unknown>;
|
|
127
151
|
/**
|
|
128
152
|
* Wait until a message satisfies `predicate`. Matches are checked
|
|
129
153
|
* against already-received messages first, then new arrivals until
|
|
130
154
|
* the timeout (defaults to 1000ms).
|
|
155
|
+
*
|
|
156
|
+
* When `predicate` is a type guard (e.g. `is_notification_with<P>`),
|
|
157
|
+
* the result is narrowed automatically and callers don't need to
|
|
158
|
+
* spell `<JsonrpcNotificationFrame<P>>` on the call site.
|
|
131
159
|
*/
|
|
132
|
-
wait_for:
|
|
160
|
+
wait_for: {
|
|
161
|
+
<T>(predicate: (msg: unknown) => msg is T, timeout_ms?: number): Promise<T>;
|
|
162
|
+
<T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number): Promise<T>;
|
|
163
|
+
};
|
|
133
164
|
}
|
|
165
|
+
export interface JsonrpcNotificationFrame<P = unknown> {
|
|
166
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
167
|
+
method: string;
|
|
168
|
+
params: P;
|
|
169
|
+
}
|
|
170
|
+
export interface JsonrpcSuccessResponseFrame<R = unknown> {
|
|
171
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
172
|
+
id: number | string;
|
|
173
|
+
result: R;
|
|
174
|
+
}
|
|
175
|
+
export interface JsonrpcErrorResponseFrame<D = unknown> {
|
|
176
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
177
|
+
id: number | string;
|
|
178
|
+
error: {
|
|
179
|
+
code: number;
|
|
180
|
+
message: string;
|
|
181
|
+
data?: D;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Predicate matching a JSON-RPC notification with the given method name. */
|
|
185
|
+
export declare const is_notification: (method: string) => (msg: unknown) => boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Type-guard combinator: match a notification whose typed `params` satisfies
|
|
188
|
+
* `match`. Collapses the common test pattern of casting `msg` to
|
|
189
|
+
* `JsonrpcNotificationFrame<P>` in every predicate body.
|
|
190
|
+
*
|
|
191
|
+
* ```ts
|
|
192
|
+
* const match_roster_for = (id: Uuid) =>
|
|
193
|
+
* is_notification_with<RosterChangedParams>(
|
|
194
|
+
* WORLD_METHODS.roster_changed,
|
|
195
|
+
* (params) => params.character_id === id && !params.removed,
|
|
196
|
+
* );
|
|
197
|
+
* const roster = await client.wait_for(match_roster_for(char_id));
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
export declare const is_notification_with: <P>(method: string, match: (params: P) => boolean) => (msg: unknown) => msg is JsonrpcNotificationFrame<P>;
|
|
201
|
+
/** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
|
|
202
|
+
export declare const is_response_for: (id: number | string) => (msg: unknown) => boolean;
|
|
134
203
|
/** Options for `create_ws_test_harness`. */
|
|
135
204
|
export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
|
|
136
205
|
specs: ReadonlyArray<ActionSpecUnion>;
|
|
@@ -140,11 +209,22 @@ export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
|
|
|
140
209
|
transport?: BackendWebsocketTransport;
|
|
141
210
|
/** Optional logger. Defaults to a silent `[ws-test]` logger. */
|
|
142
211
|
log?: Logger;
|
|
212
|
+
/** Threaded straight through to `register_action_ws`. */
|
|
213
|
+
on_socket_open?: RegisterActionWsOptions<TCtx>['on_socket_open'];
|
|
214
|
+
/** Threaded straight through to `register_action_ws`. */
|
|
215
|
+
on_socket_close?: RegisterActionWsOptions<TCtx>['on_socket_close'];
|
|
143
216
|
}
|
|
144
217
|
/** A harness instance — transport handle + connection factory. */
|
|
145
218
|
export interface WsTestHarness {
|
|
146
219
|
transport: BackendWebsocketTransport;
|
|
147
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Open a mock connection. Resolves after `on_socket_open` (and the
|
|
222
|
+
* transport's `register_ws`) completes, so broadcasts issued
|
|
223
|
+
* immediately after the `await` reach the connection. Earlier
|
|
224
|
+
* revisions returned synchronously and required a `settle_open()`
|
|
225
|
+
* microtask drain — no longer necessary.
|
|
226
|
+
*/
|
|
227
|
+
connect: (identity?: WsConnectIdentity) => Promise<MockWsClient>;
|
|
148
228
|
}
|
|
149
229
|
/**
|
|
150
230
|
* Create a WebSocket test harness for the given specs + handlers.
|
|
@@ -158,4 +238,25 @@ export interface WsTestHarness {
|
|
|
158
238
|
export declare const create_ws_test_harness: <TCtx extends BaseHandlerContext>(options: CreateWsTestHarnessOptions<TCtx>) => WsTestHarness;
|
|
159
239
|
/** Convenience: default identity for keeper-authenticated connections. */
|
|
160
240
|
export declare const keeper_identity: () => WsConnectIdentity;
|
|
241
|
+
/**
|
|
242
|
+
* Wire a typed broadcast API against the harness's transport, matching
|
|
243
|
+
* how a consumer's real backend composes the stack. Returns the typed
|
|
244
|
+
* API so tests can call `.tx_run_created(...)` / `.workspace_changed(...)`
|
|
245
|
+
* etc. directly.
|
|
246
|
+
*
|
|
247
|
+
* ```ts
|
|
248
|
+
* const harness = create_ws_test_harness<BaseHandlerContext>({specs, handlers});
|
|
249
|
+
* const broadcast = build_broadcast_api<MyBackendActionsApi>({
|
|
250
|
+
* harness,
|
|
251
|
+
* specs: my_broadcast_action_specs,
|
|
252
|
+
* });
|
|
253
|
+
* const client = await harness.connect(keeper_identity());
|
|
254
|
+
* await broadcast.tx_run_created({run_id: '...', ...});
|
|
255
|
+
* await client.wait_for(is_notification('tx_run_created'));
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
export declare const build_broadcast_api: <TApi>(options: {
|
|
259
|
+
harness: WsTestHarness;
|
|
260
|
+
specs: ReadonlyArray<ActionSpecUnion>;
|
|
261
|
+
}) => TApi;
|
|
161
262
|
//# sourceMappingURL=ws_round_trip.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAO,MAAM,MAAM,CAAC;AACxC,OAAO,EACN,SAAS,EAET,KAAK,gBAAgB,EAErB,KAAK,QAAQ,EACb,MAAM,SAAS,CAAC;AACjB,OAAO,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAE/C,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,2BAA2B,CAAC;AAE/D,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,kCAAkC,CAAC;AAE7E,OAAO,EAEN,KAAK,kBAAkB,EACvB,KAAK,uBAAuB,EAC5B,KAAK,eAAe,EACpB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAC,yBAAyB,EAAC,MAAM,qCAAqC,CAAC;AAC9E,OAAO,EAAsB,KAAK,cAAc,EAAC,MAAM,4BAA4B,CAAC;AAEpF,OAAO,EAA6C,KAAK,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACnG,OAAO,EAAC,eAAe,EAAC,MAAM,oBAAoB,CAAC;AAOnD,OAAO,EAAc,KAAK,IAAI,EAAC,MAAM,YAAY,CAAC;AAMlD;;;GAGG;AACH,MAAM,WAAW,MAAM;IACtB,EAAE,EAAE,SAAS,CAAC;IACd,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB,MAAM,EAAE,KAAK,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAC,CAAC,CAAC;CAChD;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,QAAO,MAajC,CAAC;AAEF,8CAA8C;AAC9C,MAAM,WAAW,sBAAsB;IACtC,eAAe,EAAE,cAAc,CAAC;IAChC,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;OAGG;IACH,eAAe,CAAC,EAAE,cAAc,CAAC;CACjC;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GAAI,MAAM,sBAAsB,KAAG,OAWvE,CAAC;AAEF,uFAAuF;AACvF,MAAM,WAAW,WAAW;IAC3B,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,iBAAiB,EAAE,MAAM,CAAC,CAAC,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACtE;AAED;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,QAAO,WAatC,CAAC;AAEF;;;;GAIG;AACH,qBAAa,wBAAyB,YAAW,sBAAsB;;IACtE,QAAQ,EAAE,UAAU,GAAG,SAAS,CAAa;gBAEjC,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC;IAGjD,qBAAqB,IAAI,SAAS;IAGlC,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS;CAG/D;AAED;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAC/B,YAAY,WAAW,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,EAC9C,OAAO,YAAY,EACnB,IAAI,SAAS,KACX,OAAO,CAAC,IAAI,CAId,CAAC;AAMF,2CAA2C;AAC3C,MAAM,WAAW,iBAAiB;IACjC,wEAAwE;IACxE,UAAU,CAAC,EAAE,IAAI,CAAC;IAClB,yFAAyF;IACzF,eAAe,CAAC,EAAE,cAAc,CAAC;IACjC,mFAAmF;IACnF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,kFAAkF;IAClF,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC5B,uEAAuE;IACvE,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C;;;;;;;;OAQG;IACH,OAAO,EAAE,CAAC,CAAC,GAAG,OAAO,EACpB,EAAE,EAAE,MAAM,GAAG,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,OAAO,EACf,UAAU,CAAC,EAAE,MAAM,KACf,OAAO,CAAC,CAAC,CAAC,CAAC;IAChB;;;;OAIG;IACH,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IAC1C;;;;;;;;OAQG;IACH,QAAQ,EAAE;QACT,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,GAAG,IAAI,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;QAE5E,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;KACrF,CAAC;CACF;AAkBD,MAAM,WAAW,wBAAwB,CAAC,CAAC,GAAG,OAAO;IACpD,OAAO,EAAE,OAAO,eAAe,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,CAAC,CAAC;CACV;AAED,MAAM,WAAW,2BAA2B,CAAC,CAAC,GAAG,OAAO;IACvD,OAAO,EAAE,OAAO,eAAe,CAAC;IAChC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IACpB,MAAM,EAAE,CAAC,CAAC;CACV;AAED,MAAM,WAAW,yBAAyB,CAAC,CAAC,GAAG,OAAO;IACrD,OAAO,EAAE,OAAO,eAAe,CAAC;IAChC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IACpB,KAAK,EAAE;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,CAAC,CAAA;KAAC,CAAC;CACjD;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,GAC1B,QAAQ,MAAM,MACd,KAAK,OAAO,KAAG,OACsC,CAAC;AAExD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,GAC/B,CAAC,EAAE,QAAQ,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,KAAK,OAAO,MAChD,KAAK,OAAO,KAAG,GAAG,IAAI,wBAAwB,CAAC,CAAC,CAGE,CAAC;AAErD,gGAAgG;AAChG,eAAO,MAAM,eAAe,GAC1B,IAAI,MAAM,GAAG,MAAM,MACnB,KAAK,OAAO,KAAG,OAC8D,CAAC;AAEhF,4CAA4C;AAC5C,MAAM,WAAW,0BAA0B,CAAC,IAAI,SAAS,kBAAkB;IAC1E,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;IAChD,cAAc,CAAC,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC,gBAAgB,CAAC,CAAC;IACjE,kEAAkE;IAClE,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,cAAc,CAAC,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC,gBAAgB,CAAC,CAAC;IACjE,yDAAyD;IACzD,eAAe,CAAC,EAAE,uBAAuB,CAAC,IAAI,CAAC,CAAC,iBAAiB,CAAC,CAAC;CACnE;AAED,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC7B,SAAS,EAAE,yBAAyB,CAAC;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,CAAC,QAAQ,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;CACjE;AA4FD;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,GAAI,IAAI,SAAS,kBAAkB,EACrE,SAAS,0BAA0B,CAAC,IAAI,CAAC,KACvC,aA6KF,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,eAAe,QAAO,iBAGjC,CAAC;AAYH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,mBAAmB,GAAI,IAAI,EAAE,SAAS;IAClD,OAAO,EAAE,aAAa,CAAC;IACvB,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;CACtC,KAAG,IAIH,CAAC"}
|
|
@@ -8,16 +8,25 @@
|
|
|
8
8
|
* token id), and exposes a `connect()` factory returning a
|
|
9
9
|
* `MockWsClient` per connection.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Three layers are exported:
|
|
12
12
|
*
|
|
13
13
|
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
14
|
-
* `create_stub_upgrade`, `MinimalActionEnvironment
|
|
15
|
-
* fuz_app's own dispatcher tests
|
|
16
|
-
* one-off tests.
|
|
14
|
+
* `create_stub_upgrade`, `MinimalActionEnvironment`,
|
|
15
|
+
* `dispatch_ws_message`) — used by fuz_app's own dispatcher tests
|
|
16
|
+
* and by consumers wiring tight one-off tests.
|
|
17
17
|
* - **Harness** (`create_ws_test_harness`, `MockWsClient`,
|
|
18
18
|
* `keeper_identity`) — the high-level driver. Give it specs +
|
|
19
|
-
* handlers, get back `{transport, connect()}`.
|
|
20
|
-
*
|
|
19
|
+
* handlers, get back `{transport, connect()}`. `connect()` is async
|
|
20
|
+
* and resolves after `on_socket_open` completes, so broadcasts sent
|
|
21
|
+
* immediately after `await harness.connect()` reach the client.
|
|
22
|
+
* - **Round-trip helpers** — `is_notification` / `is_notification_with`
|
|
23
|
+
* / `is_response_for` predicates, JSON-RPC wire-frame types
|
|
24
|
+
* (`JsonrpcNotificationFrame`, `JsonrpcSuccessResponseFrame`,
|
|
25
|
+
* `JsonrpcErrorResponseFrame` — distinct from the runtime Zod types
|
|
26
|
+
* in `http/jsonrpc.ts` so tests can narrow `params` / `result`),
|
|
27
|
+
* and `build_broadcast_api` for wiring a typed broadcast API against
|
|
28
|
+
* the harness's transport. Used by consumer round-trip test suites
|
|
29
|
+
* to replace ~100 lines of verbatim-identical glue.
|
|
21
30
|
*
|
|
22
31
|
* Hono's wire upgrade is skipped — the Node test runtime has no
|
|
23
32
|
* `@hono/node-ws` adapter — but the full dispatch path is exercised
|
|
@@ -29,11 +38,15 @@
|
|
|
29
38
|
*/
|
|
30
39
|
import { WSContext, createWSMessageEvent, } from 'hono/ws';
|
|
31
40
|
import { Logger } from '@fuzdev/fuz_util/log.js';
|
|
41
|
+
import { ActionPeer } from '../actions/action_peer.js';
|
|
42
|
+
import { create_broadcast_api } from '../actions/broadcast_api.js';
|
|
32
43
|
import { register_action_ws, } from '../actions/register_action_ws.js';
|
|
33
44
|
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
|
|
34
45
|
import { REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
|
|
35
46
|
import { ROLE_KEEPER } from '../auth/role_schema.js';
|
|
36
47
|
import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
|
|
48
|
+
import { JSONRPC_VERSION } from '../http/jsonrpc.js';
|
|
49
|
+
import { create_jsonrpc_request, is_jsonrpc_error_response, is_jsonrpc_notification, is_jsonrpc_response, } from '../http/jsonrpc_helpers.js';
|
|
37
50
|
import { create_uuid } from '../uuid.js';
|
|
38
51
|
/**
|
|
39
52
|
* Build a real `WSContext` backed by in-memory `send`/`close` capture.
|
|
@@ -121,6 +134,27 @@ export const dispatch_ws_message = async (on_message, event, ws) => {
|
|
|
121
134
|
if (result instanceof Promise)
|
|
122
135
|
await result;
|
|
123
136
|
};
|
|
137
|
+
/** Predicate matching a JSON-RPC notification with the given method name. */
|
|
138
|
+
export const is_notification = (method) => (msg) => is_jsonrpc_notification(msg) && msg.method === method;
|
|
139
|
+
/**
|
|
140
|
+
* Type-guard combinator: match a notification whose typed `params` satisfies
|
|
141
|
+
* `match`. Collapses the common test pattern of casting `msg` to
|
|
142
|
+
* `JsonrpcNotificationFrame<P>` in every predicate body.
|
|
143
|
+
*
|
|
144
|
+
* ```ts
|
|
145
|
+
* const match_roster_for = (id: Uuid) =>
|
|
146
|
+
* is_notification_with<RosterChangedParams>(
|
|
147
|
+
* WORLD_METHODS.roster_changed,
|
|
148
|
+
* (params) => params.character_id === id && !params.removed,
|
|
149
|
+
* );
|
|
150
|
+
* const roster = await client.wait_for(match_roster_for(char_id));
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export const is_notification_with = (method, match) => (msg) => is_jsonrpc_notification(msg) &&
|
|
154
|
+
msg.method === method &&
|
|
155
|
+
match(msg.params);
|
|
156
|
+
/** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
|
|
157
|
+
export const is_response_for = (id) => (msg) => (is_jsonrpc_response(msg) || is_jsonrpc_error_response(msg)) && msg.id === id;
|
|
124
158
|
const DEFAULT_TIMEOUT_MS = 1000;
|
|
125
159
|
/**
|
|
126
160
|
* Build a `RequestContext` with a fresh UUID account/actor and permits
|
|
@@ -215,7 +249,7 @@ const build_simple_request_context = (role) => {
|
|
|
215
249
|
* `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
|
|
216
250
|
*/
|
|
217
251
|
export const create_ws_test_harness = (options) => {
|
|
218
|
-
const { specs, handlers, extend_context = (base) => base, transport = new BackendWebsocketTransport(), log = new Logger('[ws-test]', { level: 'off' }), } = options;
|
|
252
|
+
const { specs, handlers, extend_context = (base) => base, transport = new BackendWebsocketTransport(), log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, } = options;
|
|
219
253
|
const stub = create_stub_upgrade();
|
|
220
254
|
// Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
|
|
221
255
|
const stub_app = { get: () => stub_app };
|
|
@@ -228,9 +262,11 @@ export const create_ws_test_harness = (options) => {
|
|
|
228
262
|
extend_context,
|
|
229
263
|
transport,
|
|
230
264
|
log,
|
|
265
|
+
on_socket_open,
|
|
266
|
+
on_socket_close,
|
|
231
267
|
});
|
|
232
268
|
const events_factory = stub.get_create_events();
|
|
233
|
-
const connect = (identity = {}) => {
|
|
269
|
+
const connect = async (identity = {}) => {
|
|
234
270
|
const account_id = identity.account_id ?? create_uuid();
|
|
235
271
|
const credential_type = identity.credential_type ?? 'session';
|
|
236
272
|
const session_id = identity.session_id ?? create_uuid();
|
|
@@ -248,6 +284,9 @@ export const create_ws_test_harness = (options) => {
|
|
|
248
284
|
const received = [];
|
|
249
285
|
const waiters = [];
|
|
250
286
|
let is_closed = false;
|
|
287
|
+
// Captured in `ws.close` below; `client.close(...)` returns it so
|
|
288
|
+
// tests can await async `on_socket_close` cleanup.
|
|
289
|
+
let close_pending = null;
|
|
251
290
|
// Real WSContext backed by test-owned send/close. Parsing is done
|
|
252
291
|
// on receive so tests can assert against structured messages.
|
|
253
292
|
const ws = new WSContext({
|
|
@@ -275,54 +314,80 @@ export const create_ws_test_harness = (options) => {
|
|
|
275
314
|
reason: { value: reason ?? '', writable: false },
|
|
276
315
|
wasClean: { value: true, writable: false },
|
|
277
316
|
});
|
|
278
|
-
|
|
317
|
+
close_pending = (async () => {
|
|
318
|
+
// onClose is typed as `void` by Hono but `register_action_ws`
|
|
319
|
+
// returns a promise when `on_socket_close` does async cleanup.
|
|
320
|
+
await events.onClose?.(close_event, ws);
|
|
321
|
+
})();
|
|
279
322
|
},
|
|
280
323
|
});
|
|
281
|
-
// Resolve the (possibly async) events factory
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
324
|
+
// Resolve the (possibly async) events factory and fire onOpen
|
|
325
|
+
// before returning the client. Awaiting the hook chain here
|
|
326
|
+
// means the transport has registered the connection and any
|
|
327
|
+
// `on_socket_open` bootstrap (sending an initial snapshot,
|
|
328
|
+
// populating per-connection state) has completed by the time
|
|
329
|
+
// the caller's `await harness.connect(...)` resolves.
|
|
330
|
+
const factory_result = events_factory(fake_c);
|
|
331
|
+
const events = await Promise.resolve(factory_result);
|
|
332
|
+
// onOpen is typed as `void` by Hono but `register_action_ws`
|
|
333
|
+
// returns a promise when `on_socket_open` does async bootstrap.
|
|
334
|
+
await events.onOpen?.(new Event('open'), ws);
|
|
335
|
+
const wait_for_impl = (predicate, timeout_ms = DEFAULT_TIMEOUT_MS) => {
|
|
336
|
+
for (const msg of received) {
|
|
337
|
+
if (predicate(msg))
|
|
338
|
+
return Promise.resolve(msg);
|
|
339
|
+
}
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
const waiter = {
|
|
342
|
+
predicate,
|
|
343
|
+
resolve: (msg) => {
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
resolve(msg);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
const timer = setTimeout(() => {
|
|
349
|
+
// Drop the waiter on timeout — without this, a later `send`
|
|
350
|
+
// would still iterate it and the `waiters` array would grow
|
|
351
|
+
// across timed-out waits.
|
|
352
|
+
const i = waiters.indexOf(waiter);
|
|
353
|
+
if (i >= 0)
|
|
354
|
+
waiters.splice(i, 1);
|
|
355
|
+
reject(new Error(`wait_for timed out after ${timeout_ms}ms`));
|
|
356
|
+
}, timeout_ms);
|
|
357
|
+
waiters.push(waiter);
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
const send_impl = async (message) => {
|
|
361
|
+
if (is_closed)
|
|
362
|
+
throw new Error('send after close');
|
|
363
|
+
const message_event = createWSMessageEvent(JSON.stringify(message));
|
|
364
|
+
// `onMessage` is typed as returning void by Hono, but
|
|
365
|
+
// `register_action_ws` implements it as async — cast so
|
|
366
|
+
// tests await the full dispatch (auth, validation,
|
|
367
|
+
// handler, send).
|
|
368
|
+
await events.onMessage?.(message_event, ws);
|
|
369
|
+
};
|
|
290
370
|
return {
|
|
291
371
|
get messages() {
|
|
292
372
|
return received;
|
|
293
373
|
},
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// tests await the full dispatch (auth, validation,
|
|
302
|
-
// handler, send).
|
|
303
|
-
await resolved.onMessage?.(message_event, ws);
|
|
304
|
-
},
|
|
305
|
-
close(code, reason) {
|
|
306
|
-
if (is_closed)
|
|
307
|
-
return;
|
|
308
|
-
ws.close(code, reason);
|
|
309
|
-
},
|
|
310
|
-
wait_for(predicate, timeout_ms = DEFAULT_TIMEOUT_MS) {
|
|
311
|
-
for (const msg of received) {
|
|
312
|
-
if (predicate(msg))
|
|
313
|
-
return Promise.resolve(msg);
|
|
374
|
+
send: send_impl,
|
|
375
|
+
async request(id, method, params, timeout_ms) {
|
|
376
|
+
await send_impl(create_jsonrpc_request(method, params, id));
|
|
377
|
+
const msg = await wait_for_impl(is_response_for(id), timeout_ms);
|
|
378
|
+
if ('error' in msg) {
|
|
379
|
+
const detail = msg.error.data === undefined ? '' : ` data=${JSON.stringify(msg.error.data)}`;
|
|
380
|
+
throw new Error(`rpc #${id} failed: [${msg.error.code}] ${msg.error.message}${detail}`);
|
|
314
381
|
}
|
|
315
|
-
return
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
},
|
|
323
|
-
});
|
|
324
|
-
});
|
|
382
|
+
return msg.result;
|
|
383
|
+
},
|
|
384
|
+
async close(code, reason) {
|
|
385
|
+
if (!is_closed)
|
|
386
|
+
ws.close(code, reason);
|
|
387
|
+
if (close_pending)
|
|
388
|
+
await close_pending;
|
|
325
389
|
},
|
|
390
|
+
wait_for: wait_for_impl,
|
|
326
391
|
};
|
|
327
392
|
};
|
|
328
393
|
return { transport, connect };
|
|
@@ -332,3 +397,33 @@ export const keeper_identity = () => ({
|
|
|
332
397
|
credential_type: 'daemon_token',
|
|
333
398
|
roles: [ROLE_KEEPER],
|
|
334
399
|
});
|
|
400
|
+
// ---------------------------------------------------------------------
|
|
401
|
+
// Broadcast wiring — for tests that assert on server-initiated
|
|
402
|
+
// notification fan-out. `build_broadcast_api` mirrors how consumer
|
|
403
|
+
// `backend_actions_api.ts` composes the real stack (peer + transport
|
|
404
|
+
// registered + `create_broadcast_api`); the helper exists so each test
|
|
405
|
+
// doesn't re-spell that boilerplate.
|
|
406
|
+
// ---------------------------------------------------------------------
|
|
407
|
+
const make_peer = () => new ActionPeer({ environment: new MinimalActionEnvironment([]) });
|
|
408
|
+
/**
|
|
409
|
+
* Wire a typed broadcast API against the harness's transport, matching
|
|
410
|
+
* how a consumer's real backend composes the stack. Returns the typed
|
|
411
|
+
* API so tests can call `.tx_run_created(...)` / `.workspace_changed(...)`
|
|
412
|
+
* etc. directly.
|
|
413
|
+
*
|
|
414
|
+
* ```ts
|
|
415
|
+
* const harness = create_ws_test_harness<BaseHandlerContext>({specs, handlers});
|
|
416
|
+
* const broadcast = build_broadcast_api<MyBackendActionsApi>({
|
|
417
|
+
* harness,
|
|
418
|
+
* specs: my_broadcast_action_specs,
|
|
419
|
+
* });
|
|
420
|
+
* const client = await harness.connect(keeper_identity());
|
|
421
|
+
* await broadcast.tx_run_created({run_id: '...', ...});
|
|
422
|
+
* await client.wait_for(is_notification('tx_run_created'));
|
|
423
|
+
* ```
|
|
424
|
+
*/
|
|
425
|
+
export const build_broadcast_api = (options) => {
|
|
426
|
+
const peer = make_peer();
|
|
427
|
+
peer.transports.register_transport(options.harness.transport);
|
|
428
|
+
return create_broadcast_api({ peer, specs: options.specs });
|
|
429
|
+
};
|