@fuzdev/fuz_app 0.65.0 → 0.66.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/CLAUDE.md +65 -86
- package/dist/actions/action_codegen.d.ts +1 -1
- package/dist/actions/action_codegen.js +1 -1
- package/dist/actions/action_event_data.d.ts +1 -1
- package/dist/auth/CLAUDE.md +83 -104
- package/dist/auth/audit_log_schema.js +2 -2
- package/dist/auth/daemon_token_middleware.d.ts +15 -5
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +24 -15
- package/dist/auth/invite_queries.d.ts +17 -7
- package/dist/auth/invite_queries.d.ts.map +1 -1
- package/dist/auth/invite_queries.js +19 -8
- package/dist/auth/signup_routes.d.ts +47 -1
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +103 -52
- package/dist/env/resolve.d.ts +44 -7
- package/dist/env/resolve.d.ts.map +1 -1
- package/dist/env/resolve.js +94 -27
- package/dist/http/CLAUDE.md +47 -52
- package/dist/http/jsonrpc.d.ts +23 -7
- package/dist/http/jsonrpc.d.ts.map +1 -1
- package/dist/http/jsonrpc.js +19 -3
- package/dist/http/surface.d.ts +9 -2
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/runtime/mock.d.ts +1 -1
- package/dist/runtime/mock.js +1 -1
- package/dist/testing/CLAUDE.md +659 -511
- package/dist/testing/admin_integration.d.ts +5 -5
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +95 -39
- package/dist/testing/app_server.d.ts +16 -1
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +18 -3
- package/dist/testing/audit_completeness.d.ts +7 -5
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +5 -9
- package/dist/testing/bootstrap_success.js +2 -2
- package/dist/testing/cross_backend/backend_config.d.ts +113 -0
- package/dist/testing/cross_backend/backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/backend_config.js +1 -0
- package/dist/testing/cross_backend/bench/bench_report.d.ts +46 -0
- package/dist/testing/cross_backend/bench/bench_report.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/bench_report.js +83 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts +44 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.js +38 -0
- package/dist/testing/cross_backend/bench/scenario.d.ts +57 -0
- package/dist/testing/cross_backend/bench/scenario.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/scenario.js +28 -0
- package/dist/testing/cross_backend/bootstrap_backend.d.ts +41 -0
- package/dist/testing/cross_backend/bootstrap_backend.d.ts.map +1 -0
- package/dist/testing/cross_backend/bootstrap_backend.js +34 -0
- package/dist/testing/cross_backend/build_test_backend_paths.d.ts +24 -0
- package/dist/testing/cross_backend/build_test_backend_paths.d.ts.map +1 -0
- package/dist/testing/cross_backend/build_test_backend_paths.js +33 -0
- package/dist/testing/cross_backend/capabilities.d.ts +3 -2
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.d.ts +122 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_backend_configs.js +111 -0
- package/dist/testing/cross_backend/default_secrets.d.ts +40 -0
- package/dist/testing/cross_backend/default_secrets.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_secrets.js +39 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts +64 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_spine_surface.js +121 -0
- package/dist/testing/cross_backend/setup.d.ts +270 -34
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +495 -15
- package/dist/testing/cross_backend/spawn_backend.d.ts +58 -0
- package/dist/testing/cross_backend/spawn_backend.d.ts.map +1 -0
- package/dist/testing/cross_backend/spawn_backend.js +229 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +66 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.js +49 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts +37 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -0
- package/dist/testing/cross_backend/sse_round_trip.js +137 -0
- package/dist/testing/cross_backend/standard.d.ts +96 -0
- package/dist/testing/cross_backend/standard.d.ts.map +1 -0
- package/dist/testing/cross_backend/standard.js +49 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +171 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_reset_actions.js +213 -0
- package/dist/testing/cross_backend/testing_server_bun.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_bun.js +59 -0
- package/dist/testing/cross_backend/testing_server_core.d.ts +140 -0
- package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_core.js +68 -0
- package/dist/testing/cross_backend/testing_server_deno.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_deno.js +37 -0
- package/dist/testing/cross_backend/testing_server_node.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_node.js +50 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +72 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.js +112 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +35 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_round_trip.js +113 -0
- package/dist/testing/data_exposure.d.ts +4 -6
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +1 -5
- package/dist/testing/db_entities.d.ts +18 -7
- package/dist/testing/db_entities.d.ts.map +1 -1
- package/dist/testing/db_entities.js +18 -7
- package/dist/testing/integration.d.ts +27 -6
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +93 -58
- package/dist/testing/round_trip.d.ts +4 -5
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +1 -5
- package/dist/testing/rpc_helpers.d.ts +10 -4
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +1 -1
- package/dist/testing/rpc_round_trip.d.ts +5 -5
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +1 -5
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +1 -68
- package/dist/testing/standard.d.ts +4 -5
- package/dist/testing/standard.d.ts.map +1 -1
- package/dist/testing/stubs.d.ts +10 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +9 -2
- package/dist/testing/testing_rate_limiter.d.ts +59 -0
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -0
- package/dist/testing/testing_rate_limiter.js +74 -0
- package/dist/testing/transports/bootstrap.d.ts +52 -0
- package/dist/testing/transports/bootstrap.d.ts.map +1 -0
- package/dist/testing/transports/bootstrap.js +70 -0
- package/dist/testing/transports/fetch_transport.d.ts +81 -0
- package/dist/testing/transports/fetch_transport.d.ts.map +1 -0
- package/dist/testing/transports/fetch_transport.js +74 -0
- package/dist/testing/transports/sse_frame_reader.d.ts +41 -0
- package/dist/testing/transports/sse_frame_reader.d.ts.map +1 -0
- package/dist/testing/transports/sse_frame_reader.js +84 -0
- package/dist/testing/transports/sse_transport.d.ts +54 -0
- package/dist/testing/transports/sse_transport.d.ts.map +1 -0
- package/dist/testing/transports/sse_transport.js +51 -0
- package/dist/testing/transports/ws_client.d.ts +108 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -0
- package/dist/testing/transports/ws_client.js +56 -0
- package/dist/testing/transports/ws_transport.d.ts +43 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -0
- package/dist/testing/transports/ws_transport.js +169 -0
- package/dist/testing/ws_round_trip.d.ts +21 -103
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +42 -40
- package/dist/ui/CLAUDE.md +5 -3
- package/dist/ui/MenuLink.svelte +16 -16
- package/dist/ui/MenuLink.svelte.d.ts +13 -4
- package/dist/ui/MenuLink.svelte.d.ts.map +1 -1
- package/package.json +7 -1
- package/dist/testing/transports/surface_source.d.ts +0 -51
- package/dist/testing/transports/surface_source.d.ts.map +0 -1
- package/dist/testing/transports/surface_source.js +0 -19
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* SSE frame reader over a `ReadableStreamDefaultReader<Uint8Array>`.
|
|
4
|
+
*
|
|
5
|
+
* Transport-agnostic core shared by the in-process SSE route suite
|
|
6
|
+
* (`testing/sse_round_trip.ts`, reading a Hono `Response.body`) and the
|
|
7
|
+
* cross-process `transports/sse_transport.ts` (reading a streaming `fetch`
|
|
8
|
+
* body): `\n\n`-delimited framing, a per-read timeout (so vitest can't hang
|
|
9
|
+
* on a stalled stream), and `wait_for_close` for server-initiated close
|
|
10
|
+
* detection (the auth-guard revocation seam).
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/** Default per-read / wait-for-close timeout. */
|
|
15
|
+
export declare const SSE_FRAME_READ_TIMEOUT_MS = 2000;
|
|
16
|
+
/** Frame-level reader returned by `create_sse_frame_reader`. */
|
|
17
|
+
export interface SseFrameReader {
|
|
18
|
+
/**
|
|
19
|
+
* Read one complete SSE frame (up to the next `\n\n`), without the
|
|
20
|
+
* trailing terminator. Throws if the per-read timeout elapses or the
|
|
21
|
+
* stream ends before a frame arrives.
|
|
22
|
+
*/
|
|
23
|
+
read_frame: (timeout_ms?: number) => Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Drain until the server closes the stream. Resolves `true` if the
|
|
26
|
+
* stream closes within `timeout_ms`, `false` on timeout.
|
|
27
|
+
*/
|
|
28
|
+
wait_for_close: (timeout_ms?: number) => Promise<boolean>;
|
|
29
|
+
/** Cancel the underlying reader. Safe to call when already closed. */
|
|
30
|
+
cancel: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a byte-stream reader in `\n\n`-delimited SSE frame parsing.
|
|
34
|
+
*
|
|
35
|
+
* Preserves bytes past a frame terminator in an internal buffer for the next
|
|
36
|
+
* `read_frame`. `read_frame` and `wait_for_close` both race each underlying
|
|
37
|
+
* read against `timeout_ms` so a misbehaving stream surfaces as a failure
|
|
38
|
+
* rather than a vitest hang.
|
|
39
|
+
*/
|
|
40
|
+
export declare const create_sse_frame_reader: (reader: ReadableStreamDefaultReader<Uint8Array>, default_timeout_ms?: number) => SseFrameReader;
|
|
41
|
+
//# sourceMappingURL=sse_frame_reader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse_frame_reader.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/sse_frame_reader.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;GAWG;AAEH,iDAAiD;AACjD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC9B;;;;OAIG;IACH,UAAU,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD;;;OAGG;IACH,cAAc,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,sEAAsE;IACtE,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GACnC,QAAQ,2BAA2B,CAAC,UAAU,CAAC,EAC/C,2BAA8C,KAC5C,cA8DF,CAAC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* SSE frame reader over a `ReadableStreamDefaultReader<Uint8Array>`.
|
|
4
|
+
*
|
|
5
|
+
* Transport-agnostic core shared by the in-process SSE route suite
|
|
6
|
+
* (`testing/sse_round_trip.ts`, reading a Hono `Response.body`) and the
|
|
7
|
+
* cross-process `transports/sse_transport.ts` (reading a streaming `fetch`
|
|
8
|
+
* body): `\n\n`-delimited framing, a per-read timeout (so vitest can't hang
|
|
9
|
+
* on a stalled stream), and `wait_for_close` for server-initiated close
|
|
10
|
+
* detection (the auth-guard revocation seam).
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/** Default per-read / wait-for-close timeout. */
|
|
15
|
+
export const SSE_FRAME_READ_TIMEOUT_MS = 2000;
|
|
16
|
+
/**
|
|
17
|
+
* Wrap a byte-stream reader in `\n\n`-delimited SSE frame parsing.
|
|
18
|
+
*
|
|
19
|
+
* Preserves bytes past a frame terminator in an internal buffer for the next
|
|
20
|
+
* `read_frame`. `read_frame` and `wait_for_close` both race each underlying
|
|
21
|
+
* read against `timeout_ms` so a misbehaving stream surfaces as a failure
|
|
22
|
+
* rather than a vitest hang.
|
|
23
|
+
*/
|
|
24
|
+
export const create_sse_frame_reader = (reader, default_timeout_ms = SSE_FRAME_READ_TIMEOUT_MS) => {
|
|
25
|
+
const decoder = new TextDecoder();
|
|
26
|
+
let buffer = '';
|
|
27
|
+
let closed = false;
|
|
28
|
+
// Race a single read against a timeout — vitest would otherwise hang on a
|
|
29
|
+
// stalled stream. Returns false when the stream ended.
|
|
30
|
+
const pump_once = async (timeout_ms) => {
|
|
31
|
+
const timeout = new Promise((resolve) => {
|
|
32
|
+
setTimeout(() => resolve({ timed_out: true }), timeout_ms);
|
|
33
|
+
});
|
|
34
|
+
const result = (await Promise.race([reader.read(), timeout]));
|
|
35
|
+
if ('timed_out' in result) {
|
|
36
|
+
throw new Error(`SSE read timed out after ${timeout_ms}ms`);
|
|
37
|
+
}
|
|
38
|
+
if (result.done) {
|
|
39
|
+
closed = true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
const read_frame = async (timeout_ms = default_timeout_ms) => {
|
|
46
|
+
// SSE frames end with a blank line — the canonical terminator is `\n\n`.
|
|
47
|
+
for (;;) {
|
|
48
|
+
const idx = buffer.indexOf('\n\n');
|
|
49
|
+
if (idx >= 0) {
|
|
50
|
+
const frame = buffer.slice(0, idx);
|
|
51
|
+
buffer = buffer.slice(idx + 2);
|
|
52
|
+
return frame;
|
|
53
|
+
}
|
|
54
|
+
const cont = await pump_once(timeout_ms);
|
|
55
|
+
if (!cont)
|
|
56
|
+
throw new Error('SSE stream ended before a frame was received');
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const wait_for_close = async (timeout_ms = default_timeout_ms) => {
|
|
60
|
+
const deadline = Date.now() + timeout_ms;
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (closed)
|
|
63
|
+
return true;
|
|
64
|
+
const remaining = deadline - Date.now();
|
|
65
|
+
if (remaining <= 0)
|
|
66
|
+
return false;
|
|
67
|
+
try {
|
|
68
|
+
await pump_once(Math.min(remaining, timeout_ms));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const cancel = async () => {
|
|
76
|
+
try {
|
|
77
|
+
await reader.cancel();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// already closed
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return { read_frame, wait_for_close, cancel };
|
|
84
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/** Construction options for `create_sse_transport`. */
|
|
3
|
+
export interface SseTransportOptions {
|
|
4
|
+
/** Base URL the binary is reachable at — e.g. `http://localhost:1178`. */
|
|
5
|
+
readonly base_url: string;
|
|
6
|
+
/** SSE endpoint path on the binary (e.g. `/api/admin/audit/stream`). */
|
|
7
|
+
readonly sse_path: string;
|
|
8
|
+
/**
|
|
9
|
+
* Session cookie values (full `Set-Cookie` strings as
|
|
10
|
+
* `FetchTransport.cookies()` returns them) threaded onto the request
|
|
11
|
+
* `Cookie` header. Without these the stream is anonymous and the
|
|
12
|
+
* connect is refused (the audit stream requires an admin session).
|
|
13
|
+
*/
|
|
14
|
+
readonly cookies: ReadonlyArray<string>;
|
|
15
|
+
/**
|
|
16
|
+
* `Origin` header for the request. Backends running with
|
|
17
|
+
* `ALLOWED_ORIGINS=http://localhost:*` accept `http://localhost:<port>`.
|
|
18
|
+
* Defaults to `base_url` — acceptable because cross-process tests always
|
|
19
|
+
* run against `localhost`.
|
|
20
|
+
*/
|
|
21
|
+
readonly origin?: string;
|
|
22
|
+
/** Default per-read / wait-for-close timeout. Falls back to 2000ms. */
|
|
23
|
+
readonly default_timeout_ms?: number;
|
|
24
|
+
}
|
|
25
|
+
/** A cross-process SSE client: read frames, await server close, cancel. */
|
|
26
|
+
export interface SseTransport {
|
|
27
|
+
/**
|
|
28
|
+
* Read one complete SSE frame (up to the next `\n\n`), without the
|
|
29
|
+
* trailing terminator. Throws if the per-read timeout elapses or the
|
|
30
|
+
* stream ends before a frame arrives.
|
|
31
|
+
*/
|
|
32
|
+
read_frame: (timeout_ms?: number) => Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* Drain until the server closes the stream. Resolves `true` if the
|
|
35
|
+
* stream closes within `timeout_ms`, `false` on timeout. The signal for
|
|
36
|
+
* an auth-guard revocation dropping a live stream — mirrors
|
|
37
|
+
* `WsClient.wait_for_close`.
|
|
38
|
+
*/
|
|
39
|
+
wait_for_close: (timeout_ms?: number) => Promise<boolean>;
|
|
40
|
+
/** Cancel the reader (client-initiated close). Safe to call when already closed. */
|
|
41
|
+
close: () => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Open a real-HTTP SSE stream pinned to `options.base_url` + `sse_path`.
|
|
45
|
+
*
|
|
46
|
+
* Resolves once the response headers arrive and the body is a
|
|
47
|
+
* `text/event-stream`; rejects if the connect is refused (non-2xx status,
|
|
48
|
+
* wrong content type, missing body) so the test surfaces the real cause
|
|
49
|
+
* rather than hanging.
|
|
50
|
+
*
|
|
51
|
+
* @throws Error if the connect fails (status, content type, or no body).
|
|
52
|
+
*/
|
|
53
|
+
export declare const create_sse_transport: (options: SseTransportOptions) => Promise<SseTransport>;
|
|
54
|
+
//# sourceMappingURL=sse_transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/sse_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqB9B,uDAAuD;AACvD,MAAM,WAAW,mBAAmB;IACnC,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACrC;AAED,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC5B;;;;OAIG;IACH,UAAU,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD;;;;;OAKG;IACH,cAAc,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,oFAAoF;IACpF,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,GAAU,SAAS,mBAAmB,KAAG,OAAO,CAAC,YAAY,CA2B7F,CAAC"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-process Server-Sent Events transport.
|
|
4
|
+
*
|
|
5
|
+
* Opens a real streaming `fetch` against a spawned test binary's SSE
|
|
6
|
+
* endpoint, threading the session cookie captured by the sibling
|
|
7
|
+
* `FetchTransport` so the stream authenticates as the same account, then
|
|
8
|
+
* delegates frame parsing to the shared `create_sse_frame_reader`. Uses only
|
|
9
|
+
* built-in streaming `fetch` + `TextDecoder` — no extra dep.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors how `ws_transport.ts` is the cross-process counterpart to the
|
|
12
|
+
* in-process WS harness; the in-process SSE route suite
|
|
13
|
+
* (`../sse_round_trip.ts`) shares the same `create_sse_frame_reader` over a
|
|
14
|
+
* Hono `Response.body`.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
import { create_sse_frame_reader } from './sse_frame_reader.js';
|
|
19
|
+
/**
|
|
20
|
+
* Open a real-HTTP SSE stream pinned to `options.base_url` + `sse_path`.
|
|
21
|
+
*
|
|
22
|
+
* Resolves once the response headers arrive and the body is a
|
|
23
|
+
* `text/event-stream`; rejects if the connect is refused (non-2xx status,
|
|
24
|
+
* wrong content type, missing body) so the test surfaces the real cause
|
|
25
|
+
* rather than hanging.
|
|
26
|
+
*
|
|
27
|
+
* @throws Error if the connect fails (status, content type, or no body).
|
|
28
|
+
*/
|
|
29
|
+
export const create_sse_transport = async (options) => {
|
|
30
|
+
const { base_url, sse_path, cookies, origin, default_timeout_ms } = options;
|
|
31
|
+
const url = `${base_url}${sse_path}`;
|
|
32
|
+
const headers = {
|
|
33
|
+
Accept: 'text/event-stream',
|
|
34
|
+
Origin: origin ?? base_url,
|
|
35
|
+
};
|
|
36
|
+
if (cookies.length > 0)
|
|
37
|
+
headers.Cookie = cookies.join('; ');
|
|
38
|
+
const res = await fetch(url, { method: 'GET', headers });
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
throw new Error(`SSE connect to ${url} failed: status ${res.status}`);
|
|
41
|
+
}
|
|
42
|
+
const content_type = res.headers.get('Content-Type');
|
|
43
|
+
if (!content_type?.includes('text/event-stream')) {
|
|
44
|
+
throw new Error(`SSE connect to ${url}: unexpected Content-Type ${content_type}`);
|
|
45
|
+
}
|
|
46
|
+
if (!res.body) {
|
|
47
|
+
throw new Error(`SSE connect to ${url}: response has no body`);
|
|
48
|
+
}
|
|
49
|
+
const { read_frame, wait_for_close, cancel } = create_sse_frame_reader(res.body.getReader(), default_timeout_ms);
|
|
50
|
+
return { read_frame, wait_for_close, close: cancel };
|
|
51
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import { JSONRPC_VERSION } from '../../http/jsonrpc.js';
|
|
3
|
+
export interface JsonrpcNotificationFrame<P = unknown> {
|
|
4
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
5
|
+
method: string;
|
|
6
|
+
params: P;
|
|
7
|
+
}
|
|
8
|
+
export interface JsonrpcSuccessResponseFrame<R = unknown> {
|
|
9
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
10
|
+
id: number | string;
|
|
11
|
+
result: R;
|
|
12
|
+
}
|
|
13
|
+
export interface JsonrpcErrorResponseFrame<D = unknown> {
|
|
14
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
15
|
+
id: number | string;
|
|
16
|
+
error: {
|
|
17
|
+
code: number;
|
|
18
|
+
message: string;
|
|
19
|
+
data?: D;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Predicate matching a JSON-RPC notification with the given method name. */
|
|
23
|
+
export declare const is_notification: (method: string) => (msg: unknown) => boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Type-guard combinator: match a notification whose typed `params` satisfies
|
|
26
|
+
* `match`. Collapses the common test pattern of casting `msg` to
|
|
27
|
+
* `JsonrpcNotificationFrame<P>` in every predicate body.
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const match_roster_for = (id: Uuid) =>
|
|
31
|
+
* is_notification_with<RosterChangedParams>(
|
|
32
|
+
* WORLD_METHODS.roster_changed,
|
|
33
|
+
* (params) => params.character_id === id && !params.removed,
|
|
34
|
+
* );
|
|
35
|
+
* const roster = await client.wait_for(match_roster_for(char_id));
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare const is_notification_with: <P>(method: string, match: (params: P) => boolean) => (msg: unknown) => msg is JsonrpcNotificationFrame<P>;
|
|
39
|
+
/** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
|
|
40
|
+
export declare const is_response_for: (id: number | string) => (msg: unknown) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Default wait-for timeout shared across in-process + cross-process
|
|
43
|
+
* impls. Tunable per-call via the `timeout_ms` parameter.
|
|
44
|
+
*/
|
|
45
|
+
export declare const WS_CLIENT_DEFAULT_TIMEOUT_MS = 1000;
|
|
46
|
+
/** A test WS client: send requests, inspect / await incoming messages. */
|
|
47
|
+
export interface WsClient {
|
|
48
|
+
/**
|
|
49
|
+
* Send a JSON-RPC message (request or notification) to the server.
|
|
50
|
+
*
|
|
51
|
+
* @throws Error if called after `close()` resolves — every impl
|
|
52
|
+
* rejects sends on a closed socket so post-close test bugs surface
|
|
53
|
+
* immediately rather than silently dropping.
|
|
54
|
+
*/
|
|
55
|
+
send: (message: unknown) => Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Send a JSON-RPC request and await its response. Resolves with the
|
|
58
|
+
* `result`; throws with a useful message (code, text, and any `data`
|
|
59
|
+
* payload) on an error frame — without this, asserting on
|
|
60
|
+
* `result.foo` for a failed request throws
|
|
61
|
+
* `Cannot read property 'foo' of undefined`, hiding the real cause.
|
|
62
|
+
* Use `send` + `wait_for(is_response_for(id))` directly when the test
|
|
63
|
+
* needs to assert on the error frame itself.
|
|
64
|
+
*
|
|
65
|
+
* @throws Error if the server returns a JSON-RPC error frame for `id`,
|
|
66
|
+
* or if `wait_for` times out before a matching response arrives.
|
|
67
|
+
*/
|
|
68
|
+
request: <R = unknown>(id: number | string, method: string, params: unknown, timeout_ms?: number) => Promise<R>;
|
|
69
|
+
/**
|
|
70
|
+
* Close the connection. Returns a promise that resolves once the
|
|
71
|
+
* transport's own cleanup (and any `on_socket_close` for the
|
|
72
|
+
* in-process driver) has completed — tests that assert on post-close
|
|
73
|
+
* state should await.
|
|
74
|
+
*/
|
|
75
|
+
close: (code?: number, reason?: string) => Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Wait for the server to close the connection. Resolves `true` if the
|
|
78
|
+
* socket closed within `timeout_ms`, `false` on timeout. The signal for
|
|
79
|
+
* server-initiated close — used by close-on-revoke tests that fire a
|
|
80
|
+
* revocation over a side channel and assert the live socket drops.
|
|
81
|
+
*
|
|
82
|
+
* Resolves `true` immediately when the socket is already closed.
|
|
83
|
+
* Distinct from `close()` (client-initiated): this awaits a close the
|
|
84
|
+
* test did not request. Mirrors `wait_for_close` on the SSE frame reader
|
|
85
|
+
* in `../sse_round_trip.ts`.
|
|
86
|
+
*/
|
|
87
|
+
wait_for_close: (timeout_ms?: number) => Promise<boolean>;
|
|
88
|
+
/** Every message the server has sent, in arrival order. */
|
|
89
|
+
readonly messages: ReadonlyArray<unknown>;
|
|
90
|
+
/**
|
|
91
|
+
* Wait until a message satisfies `predicate`. Matches are checked
|
|
92
|
+
* against already-received messages first, then new arrivals until
|
|
93
|
+
* the timeout (defaults to `WS_CLIENT_DEFAULT_TIMEOUT_MS`).
|
|
94
|
+
*
|
|
95
|
+
* When `predicate` is a type guard (e.g. `is_notification_with<P>`),
|
|
96
|
+
* the result is narrowed automatically and callers don't need to
|
|
97
|
+
* spell `<JsonrpcNotificationFrame<P>>` on the call site.
|
|
98
|
+
*
|
|
99
|
+
* @throws Error if `timeout_ms` elapses before a matching message
|
|
100
|
+
* arrives — the pending waiter is dropped from the internal list so
|
|
101
|
+
* later messages don't keep iterating it.
|
|
102
|
+
*/
|
|
103
|
+
wait_for: {
|
|
104
|
+
<T>(predicate: (msg: unknown) => msg is T, timeout_ms?: number): Promise<T>;
|
|
105
|
+
<T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number): Promise<T>;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=ws_client.d.ts.map
|
|
@@ -0,0 +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;AA2B9B,OAAO,EAAC,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAatD,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;AAQD,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;AAMhF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0EAA0E;AAC1E,MAAM,WAAW,QAAQ;IACxB;;;;;;OAMG;IACH,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C;;;;;;;;;;;OAWG;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;;;;;OAKG;IACH,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD;;;;;;;;;;OAUG;IACH,cAAc,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IAC1C;;;;;;;;;;;;OAYG;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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared WebSocket client surface for cross-backend tests.
|
|
4
|
+
*
|
|
5
|
+
* `WsClient` is the interface every in-process or cross-process WS test
|
|
6
|
+
* driver implements — `send` / `request` / `close` / `messages` /
|
|
7
|
+
* `wait_for`. Two impls today:
|
|
8
|
+
*
|
|
9
|
+
* - **In-process** — `create_ws_test_harness` in `../ws_round_trip.ts`.
|
|
10
|
+
* Drives `register_action_ws` against a fake Hono upgrade so the
|
|
11
|
+
* dispatcher's full path runs without the wire upgrade.
|
|
12
|
+
* - **Cross-process** — `create_ws_transport` in `./ws_transport.ts`.
|
|
13
|
+
* Wraps the native `WebSocket` upgrade against a real running binary,
|
|
14
|
+
* threading the session cookie captured by `FetchTransport`.
|
|
15
|
+
*
|
|
16
|
+
* Wire-frame types + predicates also live here so both impls (and every
|
|
17
|
+
* shared assertion helper) reference one source.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import { is_jsonrpc_error_response, is_jsonrpc_notification, is_jsonrpc_response, } from '../../http/jsonrpc_helpers.js';
|
|
22
|
+
import { JSONRPC_VERSION } from '../../http/jsonrpc.js';
|
|
23
|
+
// ---------------------------------------------------------------------
|
|
24
|
+
// Predicates — compose with `WsClient.wait_for` and
|
|
25
|
+
// `messages.filter(...)`. Both in-process and cross-process tests use
|
|
26
|
+
// the same names against the same shapes.
|
|
27
|
+
// ---------------------------------------------------------------------
|
|
28
|
+
/** Predicate matching a JSON-RPC notification with the given method name. */
|
|
29
|
+
export const is_notification = (method) => (msg) => is_jsonrpc_notification(msg) && msg.method === method;
|
|
30
|
+
/**
|
|
31
|
+
* Type-guard combinator: match a notification whose typed `params` satisfies
|
|
32
|
+
* `match`. Collapses the common test pattern of casting `msg` to
|
|
33
|
+
* `JsonrpcNotificationFrame<P>` in every predicate body.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* const match_roster_for = (id: Uuid) =>
|
|
37
|
+
* is_notification_with<RosterChangedParams>(
|
|
38
|
+
* WORLD_METHODS.roster_changed,
|
|
39
|
+
* (params) => params.character_id === id && !params.removed,
|
|
40
|
+
* );
|
|
41
|
+
* const roster = await client.wait_for(match_roster_for(char_id));
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export const is_notification_with = (method, match) => (msg) => is_jsonrpc_notification(msg) &&
|
|
45
|
+
msg.method === method &&
|
|
46
|
+
match(msg.params);
|
|
47
|
+
/** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
|
|
48
|
+
export const is_response_for = (id) => (msg) => (is_jsonrpc_response(msg) || is_jsonrpc_error_response(msg)) && msg.id === id;
|
|
49
|
+
// ---------------------------------------------------------------------
|
|
50
|
+
// WsClient — the test-driver surface every impl implements.
|
|
51
|
+
// ---------------------------------------------------------------------
|
|
52
|
+
/**
|
|
53
|
+
* Default wait-for timeout shared across in-process + cross-process
|
|
54
|
+
* impls. Tunable per-call via the `timeout_ms` parameter.
|
|
55
|
+
*/
|
|
56
|
+
export const WS_CLIENT_DEFAULT_TIMEOUT_MS = 1000;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import { type WsClient } from './ws_client.js';
|
|
3
|
+
/** Construction options for `create_ws_transport`. */
|
|
4
|
+
export interface WsTransportOptions {
|
|
5
|
+
/** Base URL the binary is reachable at — e.g. `http://localhost:8788`. Converted to `ws://` for the upgrade. */
|
|
6
|
+
readonly base_url: string;
|
|
7
|
+
/** WebSocket endpoint path on the binary (e.g. `/api/ws`). */
|
|
8
|
+
readonly ws_path: string;
|
|
9
|
+
/**
|
|
10
|
+
* Session cookie values (full `Set-Cookie` strings as
|
|
11
|
+
* `FetchTransport.cookies()` returns them) threaded onto the upgrade
|
|
12
|
+
* `Cookie` header. Without these the upgrade is anonymous and
|
|
13
|
+
* per-action auth fails on the first message.
|
|
14
|
+
*/
|
|
15
|
+
readonly cookies: ReadonlyArray<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Origin header for the upgrade. Backends running with
|
|
18
|
+
* `ALLOWED_ORIGINS=http://localhost:*` accept `http://localhost:<port>`.
|
|
19
|
+
* Defaults to `base_url` — acceptable because cross-process tests
|
|
20
|
+
* always run against `localhost`.
|
|
21
|
+
*/
|
|
22
|
+
readonly origin?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Optional per-call default for `wait_for` timeouts. Falls back to
|
|
25
|
+
* `WS_CLIENT_DEFAULT_TIMEOUT_MS` if omitted.
|
|
26
|
+
*/
|
|
27
|
+
readonly default_timeout_ms?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
|
|
31
|
+
*
|
|
32
|
+
* Resolves once the upgrade succeeds and the socket is in `OPEN` state;
|
|
33
|
+
* rejects if the upgrade is refused (401, allowlist rejection, network
|
|
34
|
+
* failure). Incoming messages are JSON-parsed and pushed onto the
|
|
35
|
+
* `messages` array; `wait_for` checks already-received messages first
|
|
36
|
+
* before waiting for new arrivals.
|
|
37
|
+
*
|
|
38
|
+
* @throws Error if the upgrade fails (status, network) — the rejection
|
|
39
|
+
* message carries the underlying error so the test surfaces the real
|
|
40
|
+
* cause rather than hanging.
|
|
41
|
+
*/
|
|
42
|
+
export declare const create_ws_transport: (options: WsTransportOptions) => Promise<WsClient>;
|
|
43
|
+
//# sourceMappingURL=ws_transport.d.ts.map
|
|
@@ -0,0 +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,EAKN,KAAK,QAAQ,EACb,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;CACrC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,GAAU,SAAS,kBAAkB,KAAG,OAAO,CAAC,QAAQ,CAsJvF,CAAC"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-process WebSocket transport.
|
|
4
|
+
*
|
|
5
|
+
* Implements the shared `WsClient` interface (see `ws_client.ts`) over a
|
|
6
|
+
* real `WebSocket` upgrade against a spawned test binary. The cookie
|
|
7
|
+
* captured by the sibling `FetchTransport` on bootstrap is threaded onto
|
|
8
|
+
* the upgrade request so the WS session authenticates as the same
|
|
9
|
+
* account.
|
|
10
|
+
*
|
|
11
|
+
* Uses the `ws` npm package — Node's native `WebSocket` (from undici)
|
|
12
|
+
* follows the WHATWG spec strictly and doesn't accept custom request
|
|
13
|
+
* headers on construction, so cookie-on-upgrade requires the `ws`
|
|
14
|
+
* package's `headers` option. fuz_app declares `ws` as an optional
|
|
15
|
+
* peerDependency — consumers wiring cross-process tests install it
|
|
16
|
+
* themselves (`npm install --save-dev ws`).
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import { WebSocket } from 'ws';
|
|
21
|
+
import { WS_CLIENT_DEFAULT_TIMEOUT_MS, is_response_for, } from './ws_client.js';
|
|
22
|
+
import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
|
|
23
|
+
/**
|
|
24
|
+
* Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
|
|
25
|
+
*
|
|
26
|
+
* Resolves once the upgrade succeeds and the socket is in `OPEN` state;
|
|
27
|
+
* rejects if the upgrade is refused (401, allowlist rejection, network
|
|
28
|
+
* failure). Incoming messages are JSON-parsed and pushed onto the
|
|
29
|
+
* `messages` array; `wait_for` checks already-received messages first
|
|
30
|
+
* before waiting for new arrivals.
|
|
31
|
+
*
|
|
32
|
+
* @throws Error if the upgrade fails (status, network) — the rejection
|
|
33
|
+
* message carries the underlying error so the test surfaces the real
|
|
34
|
+
* cause rather than hanging.
|
|
35
|
+
*/
|
|
36
|
+
export const create_ws_transport = async (options) => {
|
|
37
|
+
const { base_url, ws_path, cookies, origin, default_timeout_ms } = options;
|
|
38
|
+
const default_timeout = default_timeout_ms ?? WS_CLIENT_DEFAULT_TIMEOUT_MS;
|
|
39
|
+
const ws_url = `${base_url.replace(/^http/i, 'ws')}${ws_path}`;
|
|
40
|
+
const headers = {};
|
|
41
|
+
if (cookies.length > 0)
|
|
42
|
+
headers.Cookie = cookies.join('; ');
|
|
43
|
+
const socket = new WebSocket(ws_url, {
|
|
44
|
+
headers,
|
|
45
|
+
origin: origin ?? base_url,
|
|
46
|
+
});
|
|
47
|
+
const received = [];
|
|
48
|
+
const waiters = [];
|
|
49
|
+
let close_resolvers = [];
|
|
50
|
+
let close_error = null;
|
|
51
|
+
socket.on('message', (data) => {
|
|
52
|
+
// `ws` may deliver Buffer / ArrayBuffer / Buffer[]; normalize to string.
|
|
53
|
+
const text = Array.isArray(data)
|
|
54
|
+
? Buffer.concat(data).toString('utf-8')
|
|
55
|
+
: data instanceof ArrayBuffer
|
|
56
|
+
? Buffer.from(data).toString('utf-8')
|
|
57
|
+
: data.toString('utf-8');
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Non-JSON frame — store the raw string so tests that inspect
|
|
64
|
+
// it still see the payload.
|
|
65
|
+
parsed = text;
|
|
66
|
+
}
|
|
67
|
+
received.push(parsed);
|
|
68
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
69
|
+
const waiter = waiters[i];
|
|
70
|
+
if (waiter.predicate(parsed)) {
|
|
71
|
+
waiter.resolve(parsed);
|
|
72
|
+
waiters.splice(i, 1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
socket.on('close', () => {
|
|
77
|
+
for (const resolve of close_resolvers)
|
|
78
|
+
resolve();
|
|
79
|
+
close_resolvers = [];
|
|
80
|
+
});
|
|
81
|
+
// Wait for the upgrade to complete (or fail) before resolving the
|
|
82
|
+
// factory promise. Suite bodies expect a connected client back.
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
const on_open = () => {
|
|
85
|
+
socket.on('error', (err) => {
|
|
86
|
+
// Post-open errors stash for diagnostics; close handler
|
|
87
|
+
// resolves the close() awaiters whether or not error fired.
|
|
88
|
+
close_error = err;
|
|
89
|
+
});
|
|
90
|
+
resolve();
|
|
91
|
+
};
|
|
92
|
+
const on_error = (err) => {
|
|
93
|
+
reject(new Error(`ws upgrade to ${ws_url} failed: ${err.message}`));
|
|
94
|
+
};
|
|
95
|
+
socket.once('open', on_open);
|
|
96
|
+
socket.once('error', on_error);
|
|
97
|
+
});
|
|
98
|
+
const is_closed = () => socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED;
|
|
99
|
+
const wait_for_close = (timeout_ms = default_timeout) => {
|
|
100
|
+
if (socket.readyState === WebSocket.CLOSED)
|
|
101
|
+
return Promise.resolve(true);
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const on_close = () => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
resolve(true);
|
|
106
|
+
};
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
const i = close_resolvers.indexOf(on_close);
|
|
109
|
+
if (i >= 0)
|
|
110
|
+
close_resolvers.splice(i, 1);
|
|
111
|
+
resolve(false);
|
|
112
|
+
}, timeout_ms);
|
|
113
|
+
close_resolvers.push(on_close);
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const wait_for_impl = (predicate, timeout_ms = default_timeout) => {
|
|
117
|
+
for (const msg of received) {
|
|
118
|
+
if (predicate(msg))
|
|
119
|
+
return Promise.resolve(msg);
|
|
120
|
+
}
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const waiter = {
|
|
123
|
+
predicate,
|
|
124
|
+
resolve: (msg) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve(msg);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
const i = waiters.indexOf(waiter);
|
|
131
|
+
if (i >= 0)
|
|
132
|
+
waiters.splice(i, 1);
|
|
133
|
+
reject(new Error(`wait_for timed out after ${timeout_ms}ms`));
|
|
134
|
+
}, timeout_ms);
|
|
135
|
+
waiters.push(waiter);
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
const send_impl = async (message) => {
|
|
139
|
+
if (is_closed() || socket.readyState !== WebSocket.OPEN)
|
|
140
|
+
throw new Error('send after close');
|
|
141
|
+
socket.send(JSON.stringify(message));
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
get messages() {
|
|
145
|
+
return received;
|
|
146
|
+
},
|
|
147
|
+
send: send_impl,
|
|
148
|
+
async request(id, method, params, timeout_ms) {
|
|
149
|
+
await send_impl(create_jsonrpc_request(method, params, id));
|
|
150
|
+
const msg = await wait_for_impl(is_response_for(id), timeout_ms);
|
|
151
|
+
if ('error' in msg) {
|
|
152
|
+
const detail = msg.error.data === undefined ? '' : ` data=${JSON.stringify(msg.error.data)}`;
|
|
153
|
+
throw new Error(`rpc #${id} failed: [${msg.error.code}] ${msg.error.message}${detail}`);
|
|
154
|
+
}
|
|
155
|
+
return msg.result;
|
|
156
|
+
},
|
|
157
|
+
async close(code, reason) {
|
|
158
|
+
if (!is_closed())
|
|
159
|
+
socket.close(code, reason);
|
|
160
|
+
if (socket.readyState !== WebSocket.CLOSED) {
|
|
161
|
+
await new Promise((resolve) => close_resolvers.push(resolve));
|
|
162
|
+
}
|
|
163
|
+
if (close_error)
|
|
164
|
+
throw close_error;
|
|
165
|
+
},
|
|
166
|
+
wait_for: wait_for_impl,
|
|
167
|
+
wait_for_close,
|
|
168
|
+
};
|
|
169
|
+
};
|