@fuzdev/fuz_app 0.22.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.
@@ -8,16 +8,25 @@
8
8
  * token id), and exposes a `connect()` factory returning a
9
9
  * `MockWsClient` per connection.
10
10
  *
11
- * Two layers are exported:
11
+ * Three layers are exported:
12
12
  *
13
13
  * - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
14
- * `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
15
- * fuz_app's own dispatcher tests and by consumers wiring tight
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()}`. Use this unless you
20
- * need bare primitives.
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,6 +130,16 @@ 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>;
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>;
123
143
  /**
124
144
  * Close the connection, firing `onClose`. Returns a promise that
125
145
  * resolves once `on_socket_close` (and the transport's own cleanup)
@@ -132,9 +152,54 @@ export interface MockWsClient {
132
152
  * Wait until a message satisfies `predicate`. Matches are checked
133
153
  * against already-received messages first, then new arrivals until
134
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.
135
159
  */
136
- wait_for: <T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number) => Promise<T>;
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
+ };
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;
137
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;
138
203
  /** Options for `create_ws_test_harness`. */
139
204
  export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
140
205
  specs: ReadonlyArray<ActionSpecUnion>;
@@ -152,7 +217,14 @@ export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
152
217
  /** A harness instance — transport handle + connection factory. */
153
218
  export interface WsTestHarness {
154
219
  transport: BackendWebsocketTransport;
155
- connect: (identity?: WsConnectIdentity) => MockWsClient;
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>;
156
228
  }
157
229
  /**
158
230
  * Create a WebSocket test harness for the given specs + handlers.
@@ -166,4 +238,25 @@ export interface WsTestHarness {
166
238
  export declare const create_ws_test_harness: <TCtx extends BaseHandlerContext>(options: CreateWsTestHarnessOptions<TCtx>) => WsTestHarness;
167
239
  /** Convenience: default identity for keeper-authenticated connections. */
168
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;
169
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":"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"}
1
+ {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
@@ -8,16 +8,25 @@
8
8
  * token id), and exposes a `connect()` factory returning a
9
9
  * `MockWsClient` per connection.
10
10
  *
11
- * Two layers are exported:
11
+ * Three layers are exported:
12
12
  *
13
13
  * - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
14
- * `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
15
- * fuz_app's own dispatcher tests and by consumers wiring tight
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()}`. Use this unless you
20
- * need bare primitives.
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
@@ -232,7 +266,7 @@ export const create_ws_test_harness = (options) => {
232
266
  on_socket_close,
233
267
  });
234
268
  const events_factory = stub.get_create_events();
235
- const connect = (identity = {}) => {
269
+ const connect = async (identity = {}) => {
236
270
  const account_id = identity.account_id ?? create_uuid();
237
271
  const credential_type = identity.credential_type ?? 'session';
238
272
  const session_id = identity.session_id ?? create_uuid();
@@ -280,63 +314,80 @@ export const create_ws_test_harness = (options) => {
280
314
  reason: { value: reason ?? '', writable: false },
281
315
  wasClean: { value: true, writable: false },
282
316
  });
283
- close_pending = Promise.resolve(events).then(async (e) => {
317
+ close_pending = (async () => {
284
318
  // onClose is typed as `void` by Hono but `register_action_ws`
285
319
  // returns a promise when `on_socket_close` does async cleanup.
286
- await e.onClose?.(close_event, ws);
287
- });
320
+ await events.onClose?.(close_event, ws);
321
+ })();
288
322
  },
289
323
  });
290
- // Resolve the (possibly async) events factory synchronously via
291
- // a microtask chain. Tests always await `connect().send(...)`
292
- // which sequences after.
293
- let events = events_factory(fake_c);
294
- const events_ready = Promise.resolve(events).then(async (resolved) => {
295
- events = resolved;
296
- // onOpen is typed as `void` by Hono but `register_action_ws`
297
- // returns a promise when `on_socket_open` does async bootstrap.
298
- // Tests have to see the fully-bootstrapped socket before
299
- // `send()`, so wait on the hook chain here.
300
- await resolved.onOpen?.(new Event('open'), ws);
301
- return resolved;
302
- });
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
+ };
303
370
  return {
304
371
  get messages() {
305
372
  return received;
306
373
  },
307
- async send(message) {
308
- const resolved = await events_ready;
309
- if (is_closed)
310
- throw new Error('send after close');
311
- const message_event = createWSMessageEvent(JSON.stringify(message));
312
- // `onMessage` is typed as returning void by Hono, but
313
- // `register_action_ws` implements it as async — cast so
314
- // tests await the full dispatch (auth, validation,
315
- // handler, send).
316
- await resolved.onMessage?.(message_event, ws);
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}`);
381
+ }
382
+ return msg.result;
317
383
  },
318
384
  async close(code, reason) {
319
- if (is_closed)
320
- return close_pending ?? undefined;
321
- ws.close(code, reason);
322
- return close_pending ?? undefined;
323
- },
324
- wait_for(predicate, timeout_ms = DEFAULT_TIMEOUT_MS) {
325
- for (const msg of received) {
326
- if (predicate(msg))
327
- return Promise.resolve(msg);
328
- }
329
- return new Promise((resolve, reject) => {
330
- const timer = setTimeout(() => reject(new Error(`wait_for timed out after ${timeout_ms}ms`)), timeout_ms);
331
- waiters.push({
332
- predicate,
333
- resolve: (msg) => {
334
- clearTimeout(timer);
335
- resolve(msg);
336
- },
337
- });
338
- });
385
+ if (!is_closed)
386
+ ws.close(code, reason);
387
+ if (close_pending)
388
+ await close_pending;
339
389
  },
390
+ wait_for: wait_for_impl,
340
391
  };
341
392
  };
342
393
  return { transport, connect };
@@ -346,3 +397,33 @@ export const keeper_identity = () => ({
346
397
  credential_type: 'daemon_token',
347
398
  roles: [ROLE_KEEPER],
348
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",