@fuzdev/fuz_app 0.94.0 → 0.95.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/testing/CLAUDE.md +38 -4
- package/dist/testing/cross_backend/capabilities.d.ts +25 -4
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +9 -4
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +7 -0
- package/dist/testing/cross_backend/peer_ping_ws.d.ts +23 -0
- package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -0
- package/dist/testing/cross_backend/peer_ping_ws.js +209 -0
- package/dist/testing/transports/ws_client.d.ts +40 -1
- package/dist/testing/transports/ws_client.d.ts.map +1 -1
- package/dist/testing/transports/ws_client.js +46 -1
- package/dist/testing/transports/ws_transport.d.ts +10 -1
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +6 -10
- package/dist/testing/ws_round_trip.d.ts +11 -1
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +14 -10
- package/package.json +1 -1
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -894,7 +894,10 @@ source of truth for wire-shape conformance.
|
|
|
894
894
|
- `testing/cross_backend/capabilities.ts` — `BackendCapabilities` vocabulary
|
|
895
895
|
(the **gating** flags `ws` / `sse` / `cell_crud` / `cell_relations` /
|
|
896
896
|
`account_lifecycle` / `fact_serving` / `ready` / `account_status` /
|
|
897
|
-
`oversized_reject_closes_connection` — each has a `test_if`
|
|
897
|
+
`oversized_reject_closes_connection` / `peer_request` — each has a `test_if`
|
|
898
|
+
reader; `peer_request` (server-initiated requests — the `ActionPeer`
|
|
899
|
+
`peer/ping` round-trip) is `true` only for the Rust spine and `false` for the
|
|
900
|
+
TS family, whose server request transport is deferred),
|
|
898
901
|
`test_if(cond, name, fn)`
|
|
899
902
|
for capability-gated cases, and `in_process_capabilities` preset. The
|
|
900
903
|
non-gating wiring facts (`bearer_auth` / `trusted_proxy` / `login_rate_limit`,
|
|
@@ -1076,6 +1079,30 @@ where one exists (forge) and be offered the role; harmless on the auth-only
|
|
|
1076
1079
|
spine. Gated on `capabilities.ws`; cross-process only. fuz_app's own wiring is
|
|
1077
1080
|
`src/test/cross_backend/role_grant_offer_notification_ws.cross.test.ts`.
|
|
1078
1081
|
|
|
1082
|
+
### `cross_backend/peer_ping_ws.ts` — `describe_peer_ping_ws_tests`
|
|
1083
|
+
|
|
1084
|
+
The server→client **request/response** sibling of the notification suite above —
|
|
1085
|
+
the machinery proof for `ActionPeer`. `peer/ping` is `initiator: both`; the
|
|
1086
|
+
client→server direction already exists as `heartbeat`, this exercises the new
|
|
1087
|
+
server→client one. The observable trigger is a client invoking the `peer/ping`
|
|
1088
|
+
**action** over its own socket: the handler initiates a `peer/ping` request back
|
|
1089
|
+
to that socket (`ctx.request_client`), awaits the client's echo, validates it
|
|
1090
|
+
against `PingResponse`, and returns the validated shape — so one client RPC
|
|
1091
|
+
drives the whole round-trip and every outcome (success / `Timeout` / wrong-shape
|
|
1092
|
+
/ client-error) surfaces as that RPC's wire response. The client attaches an
|
|
1093
|
+
`on_request` responder **at construction** (the `create_ws_transport` seam) so
|
|
1094
|
+
the inbound server-initiated request is answered as soon as it arrives; the
|
|
1095
|
+
security negatives use the raw `WsClient.send` to inject unsolicited /
|
|
1096
|
+
cross-connection frames. Seven cases: the positive round-trip plus
|
|
1097
|
+
unsolicited-response rejection, per-connection id isolation, never-reply
|
|
1098
|
+
`Timeout`, wrong-shape rejection, client-error forwarding, and the HTTP
|
|
1099
|
+
no-transport path. Gated on `capabilities.peer_request` — `true` only on the
|
|
1100
|
+
Rust spine (server-initiated requests are Rust-first canonical), so it `.skip`s
|
|
1101
|
+
on the TS spines whose server request transport is the deferred convergence
|
|
1102
|
+
item; `peer/ping` is a protocol action (manifest-excluded), so parity is
|
|
1103
|
+
behavioral here, not via the manifest gate. Cross-process only; fuz_app's own
|
|
1104
|
+
wiring is `src/test/cross_backend/peer_ping_ws.cross.test.ts`.
|
|
1105
|
+
|
|
1079
1106
|
### `cross_backend/sse_round_trip.ts` — `describe_cross_process_sse_tests`
|
|
1080
1107
|
|
|
1081
1108
|
Cross-process counterpart to the in-process `sse_round_trip.ts` harness —
|
|
@@ -1177,12 +1204,19 @@ in-process legs (plain `gro test`) are `src/test/auth/cell_crud_parity.db.test.t
|
|
|
1177
1204
|
wire-frame types, and
|
|
1178
1205
|
predicates (`is_notification`, `is_response_for`, ...). Both in-process
|
|
1179
1206
|
(`ws_round_trip.ts`) and cross-process (`ws_transport.ts`) impls satisfy
|
|
1180
|
-
this interface.
|
|
1181
|
-
|
|
1207
|
+
this interface. Also owns `deliver_inbound` — the shared
|
|
1208
|
+
parse→(respond|push)→resolve receive core both impls call (so the responder
|
|
1209
|
+
branch is written once) — plus the `WsRequestResponder` / `WsResponderOutcome`
|
|
1210
|
+
types for the **`on_request`** seam: a responder answers a server-initiated
|
|
1211
|
+
request (`{result}` / `{error}` / `undefined` to swallow), the path
|
|
1212
|
+
`describe_peer_ping_ws_tests` drives. With no `on_request` the seam is inert
|
|
1213
|
+
(a server-initiated request flows to `messages` as before).
|
|
1214
|
+
- `testing/transports/ws_transport.ts` — `create_ws_transport({base_url, ws_path, cookies, origin?, on_request?})`
|
|
1182
1215
|
builds a real-upgrade WS client using the `ws` npm package (optional
|
|
1183
1216
|
peerDep; consumers wiring cross-process tests `npm install --save-dev ws`).
|
|
1184
1217
|
Threads the keeper cookie onto the upgrade so per-action auth succeeds on
|
|
1185
|
-
the first message.
|
|
1218
|
+
the first message. `on_request` (attached at construction, before the upgrade
|
|
1219
|
+
completes) answers server-initiated requests via the shared `deliver_inbound`.
|
|
1186
1220
|
- `testing/transports/sse_frame_reader.ts` — `create_sse_frame_reader(reader, default_timeout_ms?)`,
|
|
1187
1221
|
the transport-agnostic SSE framing core over a
|
|
1188
1222
|
`ReadableStreamDefaultReader<Uint8Array>`: `\n\n` framing, per-read timeout,
|
|
@@ -139,12 +139,33 @@ export interface BackendCapabilities {
|
|
|
139
139
|
* smuggle detector. See `docs/security.md` §"Body Size Limiting".
|
|
140
140
|
*/
|
|
141
141
|
readonly oversized_reject_closes_connection: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* The backend can **initiate** a JSON-RPC request to a connected client
|
|
144
|
+
* and await its typed reply (the server→client request/response direction
|
|
145
|
+
* `ActionPeer` adds). Gates `describe_peer_ping_ws_tests` — the on-demand
|
|
146
|
+
* `peer/ping` round-trip plus its security negatives (unsolicited-response
|
|
147
|
+
* rejection, per-connection id isolation, never-replying `Timeout`,
|
|
148
|
+
* wrong-shape reply rejection).
|
|
149
|
+
*
|
|
150
|
+
* `true` for the Rust spine (server-initiated requests landed Rust-first
|
|
151
|
+
* canonical); `false` for the TS family — the TS server
|
|
152
|
+
* `BackendWebsocketTransport.send()` request path is the deferred
|
|
153
|
+
* twin-impl convergence item, so a TS backend can't yet drive the
|
|
154
|
+
* round-trip. Like ws/sse/cells, `peer/ping` stays off the standard
|
|
155
|
+
* declared surface (it's a protocol action, manifest-excluded), so this
|
|
156
|
+
* flag is the only opt-in into the peer parity coverage.
|
|
157
|
+
*/
|
|
158
|
+
readonly peer_request: boolean;
|
|
142
159
|
}
|
|
143
160
|
/**
|
|
144
|
-
* Capability declarations for the in-process Hono transport.
|
|
145
|
-
* is `true` because in-process testing exercises the full backend
|
|
146
|
-
* no missing optional behaviors.
|
|
147
|
-
*
|
|
161
|
+
* Capability declarations for the in-process Hono transport. Nearly every
|
|
162
|
+
* flag is `true` because in-process testing exercises the full backend
|
|
163
|
+
* with no missing optional behaviors. The one exception is `peer_request`:
|
|
164
|
+
* the in-process driver runs the **TS** server (`register_action_ws`),
|
|
165
|
+
* whose server-initiated request transport is deferred, so it can't drive
|
|
166
|
+
* a `peer/ping` round-trip — and `describe_peer_ping_ws_tests` is
|
|
167
|
+
* cross-process-only regardless. Cross-process consumers declare each flag
|
|
168
|
+
* explicitly per backend.
|
|
148
169
|
*/
|
|
149
170
|
export declare const in_process_capabilities: BackendCapabilities;
|
|
150
171
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,kCAAkC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,kCAAkC,EAAE,OAAO,CAAC;IACrD;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAC/B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAWpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
|
|
@@ -36,10 +36,14 @@ import '../assert_dev_env.js';
|
|
|
36
36
|
*/
|
|
37
37
|
import { test } from 'vitest';
|
|
38
38
|
/**
|
|
39
|
-
* Capability declarations for the in-process Hono transport.
|
|
40
|
-
* is `true` because in-process testing exercises the full backend
|
|
41
|
-
* no missing optional behaviors.
|
|
42
|
-
*
|
|
39
|
+
* Capability declarations for the in-process Hono transport. Nearly every
|
|
40
|
+
* flag is `true` because in-process testing exercises the full backend
|
|
41
|
+
* with no missing optional behaviors. The one exception is `peer_request`:
|
|
42
|
+
* the in-process driver runs the **TS** server (`register_action_ws`),
|
|
43
|
+
* whose server-initiated request transport is deferred, so it can't drive
|
|
44
|
+
* a `peer/ping` round-trip — and `describe_peer_ping_ws_tests` is
|
|
45
|
+
* cross-process-only regardless. Cross-process consumers declare each flag
|
|
46
|
+
* explicitly per backend.
|
|
43
47
|
*/
|
|
44
48
|
export const in_process_capabilities = Object.freeze({
|
|
45
49
|
ws: true,
|
|
@@ -51,6 +55,7 @@ export const in_process_capabilities = Object.freeze({
|
|
|
51
55
|
ready: true,
|
|
52
56
|
account_status: true,
|
|
53
57
|
oversized_reject_closes_connection: true,
|
|
58
|
+
peer_request: false,
|
|
54
59
|
});
|
|
55
60
|
/**
|
|
56
61
|
* Shape notes for the in-process Hono transport — every wiring fact present
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,
|
|
1
|
+
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAqBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAmBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
|
|
@@ -35,6 +35,10 @@ export const ts_default_capabilities = Object.freeze({
|
|
|
35
35
|
// posture). A Bun-served consumer overrides to `false` (see the bun spine
|
|
36
36
|
// config) — fail-loud rather than silently skipping the smuggle detector.
|
|
37
37
|
oversized_reject_closes_connection: true,
|
|
38
|
+
// The TS server can't yet initiate server→client requests (the
|
|
39
|
+
// `BackendWebsocketTransport.send()` request path is the deferred
|
|
40
|
+
// twin-impl convergence), so the TS family skips the peer/ping suite.
|
|
41
|
+
peer_request: false,
|
|
38
42
|
});
|
|
39
43
|
/**
|
|
40
44
|
* Capabilities for the Rust family. The non-gating wiring facts live in
|
|
@@ -57,6 +61,9 @@ export const rust_default_capabilities = Object.freeze({
|
|
|
57
61
|
// hyper sends an RST on the oversized-body reject — the connection closes
|
|
58
62
|
// and the pipelined request is never reached.
|
|
59
63
|
oversized_reject_closes_connection: true,
|
|
64
|
+
// Server-initiated requests landed Rust-first canonical — the Rust spine
|
|
65
|
+
// drives the `peer/ping` round-trip, so the family opts into the suite.
|
|
66
|
+
peer_request: true,
|
|
60
67
|
});
|
|
61
68
|
/**
|
|
62
69
|
* Shape notes for TS-family backends — wiring facts, not gating flags.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import { type BackendCapabilities } from './capabilities.js';
|
|
3
|
+
import type { SetupTest } from './setup.js';
|
|
4
|
+
/** Configuration for {@link describe_peer_ping_ws_tests}. */
|
|
5
|
+
export interface PeerPingWsTestOptions {
|
|
6
|
+
/** Per-test fixture producer (`default_cross_process_setup(handle, ...)`). */
|
|
7
|
+
readonly setup_test: SetupTest;
|
|
8
|
+
/** Backend capability flags; every case gates on `capabilities.peer_request`. */
|
|
9
|
+
readonly capabilities: BackendCapabilities;
|
|
10
|
+
/** Base URL the backend is reachable at (e.g. `http://localhost:1177`). */
|
|
11
|
+
readonly base_url: string;
|
|
12
|
+
/** WebSocket endpoint path on the backend (e.g. `/api/ws`). */
|
|
13
|
+
readonly ws_path: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Register the server-initiated `peer/ping` suite — a positive round-trip
|
|
17
|
+
* plus the security negatives the design doc's §Security surface mandates
|
|
18
|
+
* (unsolicited-response rejection, per-connection id isolation, never-reply
|
|
19
|
+
* `Timeout`, wrong-shape reply rejection, plus client-error forwarding and
|
|
20
|
+
* the HTTP no-transport path). Gated on `capabilities.peer_request`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const describe_peer_ping_ws_tests: (options: PeerPingWsTestOptions) => void;
|
|
23
|
+
//# sourceMappingURL=peer_ping_ws.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer_ping_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/peer_ping_ws.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkD9B,OAAO,EAAC,KAAK,mBAAmB,EAAU,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAkB1C,6DAA6D;AAC7D,MAAM,WAAW,qBAAqB;IACrC,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GAAI,SAAS,qBAAqB,KAAG,IAmN5E,CAAC"}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-process **server-initiated `peer/ping`** suite — the machinery
|
|
4
|
+
* proof for `ActionPeer` (a backend initiating a JSON-RPC request to a
|
|
5
|
+
* connected client and awaiting its typed reply). The sibling of the
|
|
6
|
+
* one-way notification suite (`role_grant_offer_notification_ws.ts`),
|
|
7
|
+
* extended from server→client *notifications* to server→client
|
|
8
|
+
* *request/response*.
|
|
9
|
+
*
|
|
10
|
+
* `peer/ping` is `initiator: both`: the client→server direction already
|
|
11
|
+
* exists as `heartbeat`; the new server→client direction is what this
|
|
12
|
+
* exercises. The observable trigger is the client invoking the `peer/ping`
|
|
13
|
+
* **action** over its own socket — the handler turns around and *initiates*
|
|
14
|
+
* a `peer/ping` request back to that socket, awaits the client's echo,
|
|
15
|
+
* validates it against `PingResponse`, and returns the validated shape. So
|
|
16
|
+
* one client RPC drives the whole round-trip, and every outcome
|
|
17
|
+
* (success / `Timeout` / wrong-shape / client-error) surfaces as that RPC's
|
|
18
|
+
* wire response — directly assertable.
|
|
19
|
+
*
|
|
20
|
+
* The client side attaches an `on_request` responder **at construction**
|
|
21
|
+
* (via `create_ws_transport`'s seam) so the server-initiated request is
|
|
22
|
+
* answered as soon as it arrives. Security negatives use the raw
|
|
23
|
+
* `WsClient.send` to inject unsolicited / cross-connection frames.
|
|
24
|
+
*
|
|
25
|
+
* **Per-spec auth.** `peer/ping` is `auth: public` (a liveness echo is
|
|
26
|
+
* non-sensitive — see the design doc); the WS upgrade itself still
|
|
27
|
+
* authenticates, so the suite drives it over the keeper's session.
|
|
28
|
+
*
|
|
29
|
+
* Gated on `capabilities.peer_request` — `true` only for the Rust spine
|
|
30
|
+
* (server-initiated requests landed Rust-first canonical); the TS family
|
|
31
|
+
* skips until its server transport's request path lands (deferred twin-impl
|
|
32
|
+
* convergence). Cross-process only: `create_ws_transport` needs a real bound
|
|
33
|
+
* socket, so wire it from a `*.cross.test.ts`.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
import { assert, describe } from 'vitest';
|
|
38
|
+
import { rpc_call } from '../rpc_helpers.js';
|
|
39
|
+
import { create_ws_transport } from '../transports/ws_transport.js';
|
|
40
|
+
import { is_response_for, } from '../transports/ws_client.js';
|
|
41
|
+
import { create_jsonrpc_request, is_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
|
|
42
|
+
import { test_if } from './capabilities.js';
|
|
43
|
+
/** JSON-RPC endpoint path — matches the spine's `/api/rpc` (and the forge's). */
|
|
44
|
+
const RPC_PATH = '/api/rpc';
|
|
45
|
+
/** Wire method (both directions). Mirrors the Rust `PEER_PING_METHOD`. */
|
|
46
|
+
const PEER_PING_METHOD = 'peer/ping';
|
|
47
|
+
// Stable `data.reason` discriminators — the cross-impl wire contract, kept
|
|
48
|
+
// as an independent TS-side copy of the Rust `REASON_PEER_*` constants
|
|
49
|
+
// (parity by test, not codegen).
|
|
50
|
+
const REASON_PEER_TIMEOUT = 'peer_timeout';
|
|
51
|
+
const REASON_PEER_INVALID_REPLY = 'peer_ping_invalid_reply';
|
|
52
|
+
const REASON_PEER_NO_TRANSPORT = 'peer_no_transport';
|
|
53
|
+
/** A reply payload that satisfies the Rust `PingResponse` shape. */
|
|
54
|
+
const valid_reply = (nonce) => ({ nonce, protocol_version: 1 });
|
|
55
|
+
/**
|
|
56
|
+
* Register the server-initiated `peer/ping` suite — a positive round-trip
|
|
57
|
+
* plus the security negatives the design doc's §Security surface mandates
|
|
58
|
+
* (unsolicited-response rejection, per-connection id isolation, never-reply
|
|
59
|
+
* `Timeout`, wrong-shape reply rejection, plus client-error forwarding and
|
|
60
|
+
* the HTTP no-transport path). Gated on `capabilities.peer_request`.
|
|
61
|
+
*/
|
|
62
|
+
export const describe_peer_ping_ws_tests = (options) => {
|
|
63
|
+
const { setup_test, capabilities, base_url, ws_path } = options;
|
|
64
|
+
// -- shared helpers -------------------------------------------------------
|
|
65
|
+
/** Open a WS transport for a session cookie, with an optional responder. */
|
|
66
|
+
const open_ws = (cookie, on_request) => {
|
|
67
|
+
assert.ok(cookie, 'expected a session cookie for the WS upgrade');
|
|
68
|
+
return create_ws_transport({
|
|
69
|
+
base_url,
|
|
70
|
+
ws_path,
|
|
71
|
+
cookies: [cookie],
|
|
72
|
+
origin: base_url,
|
|
73
|
+
on_request,
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
/** A responder that echoes a valid `PingResponse` for every `peer/ping`. */
|
|
77
|
+
const echo_responder = (req) => {
|
|
78
|
+
const nonce = req.params?.nonce ?? 0;
|
|
79
|
+
return { result: valid_reply(nonce) };
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Invoke `peer/ping` over the socket and return the **raw** response
|
|
83
|
+
* frame (success or error) so the negatives can assert on
|
|
84
|
+
* `error.data.reason`. The client wait is generous (longer than any
|
|
85
|
+
* server-side `timeout_ms`) so the server's own outcome is what we read.
|
|
86
|
+
*/
|
|
87
|
+
const ping_raw = (ws, id, params) => ws
|
|
88
|
+
.send(create_jsonrpc_request(PEER_PING_METHOD, params, id))
|
|
89
|
+
.then(() => ws.wait_for(is_response_for(id), 8000));
|
|
90
|
+
const error_reason = (frame) => frame.error.data?.reason;
|
|
91
|
+
// -- tests ----------------------------------------------------------------
|
|
92
|
+
describe('peer/ping server-initiated request (cross-process)', () => {
|
|
93
|
+
test_if(capabilities.peer_request, 'a client peer/ping round-trips: server pings back, validates the echo, returns it', async () => {
|
|
94
|
+
const fixture = await setup_test();
|
|
95
|
+
const ws = await open_ws(fixture.create_session_headers().cookie, echo_responder);
|
|
96
|
+
try {
|
|
97
|
+
const result = await ws.request(1, PEER_PING_METHOD, { nonce: 42 }, 8000);
|
|
98
|
+
assert.strictEqual(result.nonce, 42, 'the server echoes the issued nonce back');
|
|
99
|
+
assert.strictEqual(typeof result.protocol_version, 'number');
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await ws.close();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
test_if(capabilities.peer_request, 'a never-replying client yields a server-side Timeout (the liveness signal)', async () => {
|
|
106
|
+
const fixture = await setup_test();
|
|
107
|
+
// Responder swallows the ping (returns nothing) — the half-open peer.
|
|
108
|
+
const ws = await open_ws(fixture.create_session_headers().cookie, () => undefined);
|
|
109
|
+
try {
|
|
110
|
+
// Short server-side deadline so the negative runs fast.
|
|
111
|
+
const frame = await ping_raw(ws, 2, { nonce: 1, timeout_ms: 300 });
|
|
112
|
+
assert.ok('error' in frame, 'a swallowed ping must error, not succeed');
|
|
113
|
+
assert.strictEqual(error_reason(frame), REASON_PEER_TIMEOUT);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await ws.close();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test_if(capabilities.peer_request, 'a wrong-shape reply is rejected at the validation boundary, never handed to the caller', async () => {
|
|
120
|
+
const fixture = await setup_test();
|
|
121
|
+
// Reply that fails `PingResponse` validation (nonce wrong type,
|
|
122
|
+
// protocol_version missing).
|
|
123
|
+
const ws = await open_ws(fixture.create_session_headers().cookie, () => ({
|
|
124
|
+
result: { nonce: 'not-a-number' },
|
|
125
|
+
}));
|
|
126
|
+
try {
|
|
127
|
+
const frame = await ping_raw(ws, 3, { nonce: 1, timeout_ms: 2000 });
|
|
128
|
+
assert.ok('error' in frame, 'a malformed reply must error, not return garbage');
|
|
129
|
+
assert.strictEqual(error_reason(frame), REASON_PEER_INVALID_REPLY);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await ws.close();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
test_if(capabilities.peer_request, 'a client JSON-RPC error reply surfaces as the action error (ClientError forwarded)', async () => {
|
|
136
|
+
const fixture = await setup_test();
|
|
137
|
+
const ws = await open_ws(fixture.create_session_headers().cookie, () => ({
|
|
138
|
+
error: { code: -32603, message: 'client refused', data: { reason: 'client_says_no' } },
|
|
139
|
+
}));
|
|
140
|
+
try {
|
|
141
|
+
const frame = await ping_raw(ws, 4, { nonce: 1, timeout_ms: 2000 });
|
|
142
|
+
assert.ok('error' in frame, 'a client error reply must surface as an error');
|
|
143
|
+
assert.strictEqual(frame.error.code, -32603);
|
|
144
|
+
assert.strictEqual(error_reason(frame), 'client_says_no');
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await ws.close();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
test_if(capabilities.peer_request, 'an unsolicited response is dropped: it resolves nothing and the socket survives', async () => {
|
|
151
|
+
const fixture = await setup_test();
|
|
152
|
+
const ws = await open_ws(fixture.create_session_headers().cookie, echo_responder);
|
|
153
|
+
try {
|
|
154
|
+
// Inject a reply for an id the server never issued on this socket.
|
|
155
|
+
await ws.send({ jsonrpc: '2.0', id: 's999999', result: valid_reply(7) });
|
|
156
|
+
// A legitimate round-trip still works → the junk frame neither
|
|
157
|
+
// crashed the read loop nor corrupted the pending registry.
|
|
158
|
+
const result = await ws.request(5, PEER_PING_METHOD, { nonce: 99 }, 8000);
|
|
159
|
+
assert.strictEqual(result.nonce, 99);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await ws.close();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
test_if(capabilities.peer_request, 'a reply on socket B never resolves a request issued on socket A (per-connection isolation)', async () => {
|
|
166
|
+
const fixture = await setup_test();
|
|
167
|
+
// A has NO responder, so the inbound server-initiated request is
|
|
168
|
+
// surfaced as a message A can observe (to learn its server id);
|
|
169
|
+
// A never answers it.
|
|
170
|
+
const ws_a = await open_ws(fixture.create_session_headers().cookie);
|
|
171
|
+
const ws_b = await open_ws(fixture.create_session_headers().cookie, echo_responder);
|
|
172
|
+
try {
|
|
173
|
+
// A invokes peer/ping with a short server-side deadline; don't
|
|
174
|
+
// await yet — observe the server→client request first.
|
|
175
|
+
const ping_promise = ping_raw(ws_a, 6, { nonce: 1, timeout_ms: 1200 });
|
|
176
|
+
// The server-initiated request A received (id "sN", nonce 1).
|
|
177
|
+
const inbound = await ws_a.wait_for((m) => is_jsonrpc_request(m) && m.method === PEER_PING_METHOD, 5000);
|
|
178
|
+
// B replies for A's id — a cross-connection echo. Correct
|
|
179
|
+
// isolation drops it (not pending on B), so A still times out.
|
|
180
|
+
await ws_b.send({
|
|
181
|
+
jsonrpc: '2.0',
|
|
182
|
+
id: inbound.id,
|
|
183
|
+
result: valid_reply(inbound.params.nonce),
|
|
184
|
+
});
|
|
185
|
+
const frame = await ping_promise;
|
|
186
|
+
assert.ok('error' in frame, "B's cross-connection reply must not resolve A's request");
|
|
187
|
+
assert.strictEqual(error_reason(frame), REASON_PEER_TIMEOUT);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await ws_a.close();
|
|
191
|
+
await ws_b.close();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
test_if(capabilities.peer_request, 'peer/ping over HTTP (no return socket) is rejected as no-transport', async () => {
|
|
195
|
+
const fixture = await setup_test();
|
|
196
|
+
const res = await rpc_call({
|
|
197
|
+
app: fixture.transport,
|
|
198
|
+
path: RPC_PATH,
|
|
199
|
+
method: PEER_PING_METHOD,
|
|
200
|
+
params: { nonce: 1 },
|
|
201
|
+
headers: fixture.create_session_headers(),
|
|
202
|
+
});
|
|
203
|
+
assert.isFalse(res.ok, 'HTTP peer/ping has no socket to ping');
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
assert.strictEqual(res.error.data?.reason, REASON_PEER_NO_TRANSPORT);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '../assert_dev_env.js';
|
|
2
|
-
import { JSONRPC_VERSION } from '../../http/jsonrpc.js';
|
|
2
|
+
import { JSONRPC_VERSION, type JsonrpcRequest } from '../../http/jsonrpc.js';
|
|
3
3
|
export interface JsonrpcNotificationFrame<P = unknown> {
|
|
4
4
|
jsonrpc: typeof JSONRPC_VERSION;
|
|
5
5
|
method: string;
|
|
@@ -38,6 +38,45 @@ export declare const is_notification: (method: string) => (msg: unknown) => bool
|
|
|
38
38
|
export declare const is_notification_with: <P>(method: string, match: (params: P) => boolean) => (msg: unknown) => msg is JsonrpcNotificationFrame<P>;
|
|
39
39
|
/** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
|
|
40
40
|
export declare const is_response_for: (id: number | string) => (msg: unknown) => boolean;
|
|
41
|
+
/** A pending `wait_for` entry — predicate + its resolver. */
|
|
42
|
+
export interface WsWaiter {
|
|
43
|
+
predicate: (msg: unknown) => boolean;
|
|
44
|
+
resolve: (msg: unknown) => void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What a `WsRequestResponder` returns for a server-initiated
|
|
48
|
+
* request:
|
|
49
|
+
* - `{result}` → reply with a JSON-RPC success
|
|
50
|
+
* - `{error}` → reply with a JSON-RPC error envelope
|
|
51
|
+
* - `undefined` → send nothing (models a never-replying peer — exercises
|
|
52
|
+
* the server-side `Timeout`)
|
|
53
|
+
*/
|
|
54
|
+
export type WsResponderOutcome = {
|
|
55
|
+
result: unknown;
|
|
56
|
+
} | {
|
|
57
|
+
error: JsonrpcErrorResponseFrame['error'];
|
|
58
|
+
} | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Answers a **server-initiated** request (the server→client direction
|
|
61
|
+
* `ActionPeer` adds). Passed at client construction so it's attached
|
|
62
|
+
* before the upgrade completes — otherwise it races a connect-time ping.
|
|
63
|
+
* Receives the parsed request frame and returns the reply outcome (or a
|
|
64
|
+
* promise of it).
|
|
65
|
+
*/
|
|
66
|
+
export type WsRequestResponder = (request: JsonrpcRequest) => WsResponderOutcome | Promise<WsResponderOutcome>;
|
|
67
|
+
/**
|
|
68
|
+
* Deliver one parsed inbound frame to a client's receive sink — the
|
|
69
|
+
* shared parse→(respond|push)→resolve core.
|
|
70
|
+
*
|
|
71
|
+
* A server-initiated **request** (`{method, id}`) is answered by
|
|
72
|
+
* `on_request` when supplied (replying via `reply`) and is **not**
|
|
73
|
+
* surfaced as a normal message; everything else — notifications, replies
|
|
74
|
+
* to the client's own requests, and non-JSON frames — is pushed onto
|
|
75
|
+
* `received` and resolves any matching `waiters`, exactly as before. With
|
|
76
|
+
* no `on_request`, every frame (including a server-initiated request)
|
|
77
|
+
* flows to the sink unchanged, so the seam is purely additive.
|
|
78
|
+
*/
|
|
79
|
+
export declare const deliver_inbound: (parsed: unknown, received: Array<unknown>, waiters: Array<WsWaiter>, on_request: WsRequestResponder | undefined, reply: (message: unknown) => void) => void;
|
|
41
80
|
/**
|
|
42
81
|
* Default wait-for timeout shared across in-process + cross-process
|
|
43
82
|
* impls. Tunable per-call via the `timeout_ms` parameter.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_client.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_client.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"ws_client.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_client.ts"],"names":[],"mappings":"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"}
|
|
@@ -18,7 +18,7 @@ import '../assert_dev_env.js';
|
|
|
18
18
|
*
|
|
19
19
|
* @module
|
|
20
20
|
*/
|
|
21
|
-
import { is_jsonrpc_error_response, is_jsonrpc_notification, is_jsonrpc_response, } from '../../http/jsonrpc_helpers.js';
|
|
21
|
+
import { create_jsonrpc_error_response, create_jsonrpc_response, is_jsonrpc_error_response, is_jsonrpc_notification, is_jsonrpc_request, is_jsonrpc_response, } from '../../http/jsonrpc_helpers.js';
|
|
22
22
|
import { JSONRPC_VERSION } from '../../http/jsonrpc.js';
|
|
23
23
|
// ---------------------------------------------------------------------
|
|
24
24
|
// Predicates — compose with `WsClient.wait_for` and
|
|
@@ -46,6 +46,51 @@ export const is_notification_with = (method, match) => (msg) => is_jsonrpc_notif
|
|
|
46
46
|
match(msg.params);
|
|
47
47
|
/** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
|
|
48
48
|
export const is_response_for = (id) => (msg) => (is_jsonrpc_response(msg) || is_jsonrpc_error_response(msg)) && msg.id === id;
|
|
49
|
+
/**
|
|
50
|
+
* Deliver one parsed inbound frame to a client's receive sink — the
|
|
51
|
+
* shared parse→(respond|push)→resolve core.
|
|
52
|
+
*
|
|
53
|
+
* A server-initiated **request** (`{method, id}`) is answered by
|
|
54
|
+
* `on_request` when supplied (replying via `reply`) and is **not**
|
|
55
|
+
* surfaced as a normal message; everything else — notifications, replies
|
|
56
|
+
* to the client's own requests, and non-JSON frames — is pushed onto
|
|
57
|
+
* `received` and resolves any matching `waiters`, exactly as before. With
|
|
58
|
+
* no `on_request`, every frame (including a server-initiated request)
|
|
59
|
+
* flows to the sink unchanged, so the seam is purely additive.
|
|
60
|
+
*/
|
|
61
|
+
export const deliver_inbound = (parsed, received, waiters, on_request, reply) => {
|
|
62
|
+
if (on_request && is_jsonrpc_request(parsed)) {
|
|
63
|
+
void answer_request(parsed, on_request, reply);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
received.push(parsed);
|
|
67
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
68
|
+
const waiter = waiters[i];
|
|
69
|
+
if (waiter.predicate(parsed)) {
|
|
70
|
+
waiter.resolve(parsed);
|
|
71
|
+
waiters.splice(i, 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const answer_request = async (request, on_request, reply) => {
|
|
76
|
+
let outcome;
|
|
77
|
+
try {
|
|
78
|
+
outcome = await on_request(request);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A throwing responder models a peer that fails to answer — swallow so
|
|
82
|
+
// the test client surfaces no unhandled rejection; the server then sees
|
|
83
|
+
// a never-reply (→ `Timeout`).
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (outcome === undefined)
|
|
87
|
+
return; // swallow — never-replying peer
|
|
88
|
+
reply('error' in outcome
|
|
89
|
+
? // Test wire-frames keep `code` a plain `number` (not the branded
|
|
90
|
+
// Zod `JsonrpcServerErrorCode`), so cast at the builder boundary.
|
|
91
|
+
create_jsonrpc_error_response(request.id, outcome.error)
|
|
92
|
+
: create_jsonrpc_response(request.id, outcome.result));
|
|
93
|
+
};
|
|
49
94
|
// ---------------------------------------------------------------------
|
|
50
95
|
// WsClient — the test-driver surface every impl implements.
|
|
51
96
|
// ---------------------------------------------------------------------
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '../assert_dev_env.js';
|
|
2
|
-
import { type WsClient } from './ws_client.js';
|
|
2
|
+
import { type WsClient, type WsRequestResponder } from './ws_client.js';
|
|
3
3
|
/** Construction options for `create_ws_transport`. */
|
|
4
4
|
export interface WsTransportOptions {
|
|
5
5
|
/** Base URL the binary is reachable at — e.g. `http://localhost:8788`. Converted to `ws://` for the upgrade. */
|
|
@@ -25,6 +25,15 @@ export interface WsTransportOptions {
|
|
|
25
25
|
* `WS_CLIENT_DEFAULT_TIMEOUT_MS` if omitted.
|
|
26
26
|
*/
|
|
27
27
|
readonly default_timeout_ms?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Optional responder for **server-initiated** requests (the
|
|
30
|
+
* server→client direction `ActionPeer` adds). Attached before the
|
|
31
|
+
* upgrade so a connect-time or on-demand `peer/ping` is answered as
|
|
32
|
+
* soon as it arrives. Omit for the default observe-only client — a
|
|
33
|
+
* server-initiated request is then surfaced as a normal message (today's
|
|
34
|
+
* behavior).
|
|
35
|
+
*/
|
|
36
|
+
readonly on_request?: WsRequestResponder;
|
|
28
37
|
}
|
|
29
38
|
/**
|
|
30
39
|
* Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,
|
|
1
|
+
{"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,gBAAgB,CAAC;AAGxB,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IAClC,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAC;CACzC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,GAAU,SAAS,kBAAkB,KAAG,OAAO,CAAC,QAAQ,CA8IvF,CAAC"}
|
|
@@ -18,7 +18,7 @@ import '../assert_dev_env.js';
|
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
20
|
import { WebSocket } from 'ws';
|
|
21
|
-
import { WS_CLIENT_DEFAULT_TIMEOUT_MS, is_response_for, } from './ws_client.js';
|
|
21
|
+
import { WS_CLIENT_DEFAULT_TIMEOUT_MS, deliver_inbound, is_response_for, } from './ws_client.js';
|
|
22
22
|
import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
|
|
23
23
|
/**
|
|
24
24
|
* Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
|
|
@@ -34,7 +34,7 @@ import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
|
|
|
34
34
|
* cause rather than hanging.
|
|
35
35
|
*/
|
|
36
36
|
export const create_ws_transport = async (options) => {
|
|
37
|
-
const { base_url, ws_path, cookies, origin, default_timeout_ms } = options;
|
|
37
|
+
const { base_url, ws_path, cookies, origin, default_timeout_ms, on_request } = options;
|
|
38
38
|
const default_timeout = default_timeout_ms ?? WS_CLIENT_DEFAULT_TIMEOUT_MS;
|
|
39
39
|
const ws_url = `${base_url.replace(/^http/i, 'ws')}${ws_path}`;
|
|
40
40
|
const headers = {};
|
|
@@ -64,14 +64,10 @@ export const create_ws_transport = async (options) => {
|
|
|
64
64
|
// it still see the payload.
|
|
65
65
|
parsed = text;
|
|
66
66
|
}
|
|
67
|
-
received
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
waiter.resolve(parsed);
|
|
72
|
-
waiters.splice(i, 1);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
67
|
+
deliver_inbound(parsed, received, waiters, on_request, (message) => {
|
|
68
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
69
|
+
socket.send(JSON.stringify(message));
|
|
70
|
+
});
|
|
75
71
|
});
|
|
76
72
|
socket.on('close', () => {
|
|
77
73
|
for (const resolve of close_resolvers)
|
|
@@ -48,7 +48,7 @@ import { type RegisterActionWsOptions } from '../actions/register_action_ws.js';
|
|
|
48
48
|
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
|
|
49
49
|
import { type RequestContext } from '../auth/request_context.js';
|
|
50
50
|
import { type CredentialType } from '../hono_context.js';
|
|
51
|
-
import { type WsClient } from './transports/ws_client.js';
|
|
51
|
+
import { type WsClient, type WsRequestResponder } from './transports/ws_client.js';
|
|
52
52
|
/**
|
|
53
53
|
* A `WSContext` paired with capture arrays. Use `sends` to assert on
|
|
54
54
|
* outgoing frames; use `closes` to assert on revocation / close.
|
|
@@ -151,6 +151,16 @@ export interface CreateWsTestHarnessOptions {
|
|
|
151
151
|
on_socket_open?: RegisterActionWsOptions['on_socket_open'];
|
|
152
152
|
/** Threaded straight through to `register_action_ws`. */
|
|
153
153
|
on_socket_close?: RegisterActionWsOptions['on_socket_close'];
|
|
154
|
+
/**
|
|
155
|
+
* Optional responder for **server-initiated** requests, applied to every
|
|
156
|
+
* `connect()`. Purely additive (and unexercised until the TS server
|
|
157
|
+
* transport gains server-initiated requests — the deferred
|
|
158
|
+
* `BackendWebsocketTransport.send()` convergence): with it set, an
|
|
159
|
+
* inbound server→client request is answered rather than surfaced as a
|
|
160
|
+
* message; without it, behavior is unchanged. Mirrors the cross-process
|
|
161
|
+
* `create_ws_transport` seam so suite bodies share one shape.
|
|
162
|
+
*/
|
|
163
|
+
on_request?: WsRequestResponder;
|
|
154
164
|
}
|
|
155
165
|
/** A harness instance — transport handle + connection factory. */
|
|
156
166
|
export interface WsTestHarness {
|
|
@@ -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,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;AAC/C,OAAO,EAAc,KAAK,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAE9D,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,2BAA2B,CAAC;AAC/D,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,4BAA4B,CAAC;AAEvD,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,kCAAkC,CAAC;AAE7E,OAAO,EAAqB,KAAK,uBAAuB,EAAC,MAAM,kCAAkC,CAAC;AAElG,OAAO,EAAC,yBAAyB,EAAC,MAAM,qCAAqC,CAAC;AAC9E,OAAO,EAAsB,KAAK,cAAc,EAAC,MAAM,4BAA4B,CAAC;AAEpF,OAAO,EAKN,KAAK,cAAc,EACnB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,
|
|
1
|
+
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;AAC/C,OAAO,EAAc,KAAK,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAE9D,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,2BAA2B,CAAC;AAC/D,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,4BAA4B,CAAC;AAEvD,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,kCAAkC,CAAC;AAE7E,OAAO,EAAqB,KAAK,uBAAuB,EAAC,MAAM,kCAAkC,CAAC;AAElG,OAAO,EAAC,yBAAyB,EAAC,MAAM,qCAAqC,CAAC;AAC9E,OAAO,EAAsB,KAAK,cAAc,EAAC,MAAM,4BAA4B,CAAC;AAEpF,OAAO,EAKN,KAAK,cAAc,EACnB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,2BAA2B,CAAC;AAMnC;;;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,OAavE,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,sFAAsF;IACtF,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,4CAA4C;AAC5C,MAAM,WAAW,0BAA0B;IAC1C;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B,kEAAkE;IAClE,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;OAIG;IACH,SAAS,CAAC,EAAE,uBAAuB,CAAC,WAAW,CAAC,CAAC;IACjD,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,cAAc,CAAC,EAAE,uBAAuB,CAAC,gBAAgB,CAAC,CAAC;IAC3D,yDAAyD;IACzD,eAAe,CAAC,EAAE,uBAAuB,CAAC,iBAAiB,CAAC,CAAC;IAC7D;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,kBAAkB,CAAC;CAChC;AAED,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC7B,SAAS,EAAE,yBAAyB,CAAC;IACrC;;;;;;;;;;OAUG;IACH,OAAO,EAAE,CAAC,QAAQ,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC7D;AAiED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,GAAI,SAAS,0BAA0B,KAAG,aA6M5E,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,eAAe,QAAO,iBAGjC,CAAC;AAYH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,mBAAmB,GAAI,IAAI,SAAS,MAAM,EAAE,SAAS;IACjE,OAAO,EAAE,aAAa,CAAC;IACvB,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;CACtC,KAAG,IAIH,CAAC"}
|
|
@@ -12,7 +12,7 @@ import { ROLE_KEEPER } from '../auth/role_schema.js';
|
|
|
12
12
|
import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY, } from '../hono_context.js';
|
|
13
13
|
import { create_jsonrpc_request } from '../http/jsonrpc_helpers.js';
|
|
14
14
|
import { create_test_account, create_test_actor, create_test_role_grant } from './entities.js';
|
|
15
|
-
import { WS_CLIENT_DEFAULT_TIMEOUT_MS, is_response_for, } from './transports/ws_client.js';
|
|
15
|
+
import { WS_CLIENT_DEFAULT_TIMEOUT_MS, deliver_inbound, is_response_for, } from './transports/ws_client.js';
|
|
16
16
|
/**
|
|
17
17
|
* Build a real `WSContext` backed by in-memory `send`/`close` capture.
|
|
18
18
|
* Parsing of outgoing frames is left to the caller — `sends` holds the
|
|
@@ -169,7 +169,7 @@ const build_simple_request_context = (role) => ({
|
|
|
169
169
|
* `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
|
|
170
170
|
*/
|
|
171
171
|
export const create_ws_test_harness = (options) => {
|
|
172
|
-
const { actions, transport = new BackendWebsocketTransport(), heartbeat = false, log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, } = options;
|
|
172
|
+
const { actions, transport = new BackendWebsocketTransport(), heartbeat = false, log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, on_request, } = options;
|
|
173
173
|
const stub = create_stub_upgrade();
|
|
174
174
|
// Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
|
|
175
175
|
const stub_app = { get: () => stub_app };
|
|
@@ -211,6 +211,12 @@ export const create_ws_test_harness = (options) => {
|
|
|
211
211
|
};
|
|
212
212
|
const received = [];
|
|
213
213
|
const waiters = [];
|
|
214
|
+
// Reply path for a server-initiated request — assigned to the
|
|
215
|
+
// client→server send below once `send_impl` exists. A `let` (not a
|
|
216
|
+
// direct `send_impl` reference) keeps the `WSContext.send` closure
|
|
217
|
+
// TDZ-safe; it stays a no-op until a responder + server-initiated
|
|
218
|
+
// request are both in play (deferred for the in-process TS server).
|
|
219
|
+
let reply_to_server = () => { };
|
|
214
220
|
// Resolvers awaiting a server-initiated close — fired by the
|
|
215
221
|
// `WSContext.close` callback below so `wait_for_close` resolves
|
|
216
222
|
// whether the dispatcher (e.g. an auth-guard revocation) or the
|
|
@@ -228,14 +234,7 @@ export const create_ws_test_harness = (options) => {
|
|
|
228
234
|
if (is_closed)
|
|
229
235
|
return;
|
|
230
236
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
231
|
-
|
|
232
|
-
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
233
|
-
const waiter = waiters[i];
|
|
234
|
-
if (waiter.predicate(parsed)) {
|
|
235
|
-
waiter.resolve(parsed);
|
|
236
|
-
waiters.splice(i, 1);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
237
|
+
deliver_inbound(parsed, received, waiters, on_request, reply_to_server);
|
|
239
238
|
},
|
|
240
239
|
close: (code, reason) => {
|
|
241
240
|
if (is_closed)
|
|
@@ -302,6 +301,11 @@ export const create_ws_test_harness = (options) => {
|
|
|
302
301
|
// handler, send).
|
|
303
302
|
await events.onMessage?.(message_event, ws);
|
|
304
303
|
};
|
|
304
|
+
// A responder's reply to a server-initiated request goes back to the
|
|
305
|
+
// server over the same client→server path as any other send.
|
|
306
|
+
reply_to_server = (message) => {
|
|
307
|
+
void send_impl(message);
|
|
308
|
+
};
|
|
305
309
|
return {
|
|
306
310
|
get messages() {
|
|
307
311
|
return received;
|