@fuzdev/fuz_app 0.22.0 → 0.24.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/action_types.d.ts +57 -0
- package/dist/actions/action_types.d.ts.map +1 -0
- package/dist/actions/action_types.js +11 -0
- package/dist/actions/heartbeat.d.ts +51 -0
- package/dist/actions/heartbeat.d.ts.map +1 -0
- package/dist/actions/heartbeat.js +50 -0
- package/dist/actions/register_action_ws.d.ts +28 -30
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +53 -3
- package/dist/actions/socket.svelte.d.ts +76 -4
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +288 -6
- package/dist/actions/transports.d.ts +4 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +4 -0
- package/dist/testing/ws_round_trip.d.ts +116 -11
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +137 -56
- package/package.json +1 -1
|
@@ -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 {
|
|
252
|
+
const { actions, extend_context = (base) => base, transport = new BackendWebsocketTransport(), heartbeat = false, 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 };
|
|
@@ -223,16 +257,16 @@ export const create_ws_test_harness = (options) => {
|
|
|
223
257
|
path: '/test/ws',
|
|
224
258
|
app: stub_app,
|
|
225
259
|
upgradeWebSocket: stub.upgradeWebSocket,
|
|
226
|
-
|
|
227
|
-
handlers,
|
|
260
|
+
actions,
|
|
228
261
|
extend_context,
|
|
229
262
|
transport,
|
|
263
|
+
heartbeat,
|
|
230
264
|
log,
|
|
231
265
|
on_socket_open,
|
|
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 =
|
|
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
|
|
287
|
-
});
|
|
320
|
+
await events.onClose?.(close_event, ws);
|
|
321
|
+
})();
|
|
288
322
|
},
|
|
289
323
|
});
|
|
290
|
-
// Resolve the (possibly async) events factory
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
};
|