@moltzap/client 2026.511.1 → 2026.519.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/auth.d.ts +12 -6
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +67 -29
- package/dist/auth.js.map +1 -1
- package/dist/auth.test.js +113 -76
- package/dist/auth.test.js.map +1 -1
- package/dist/channel-base/format-cross-conv.d.ts +43 -0
- package/dist/channel-base/format-cross-conv.d.ts.map +1 -0
- package/dist/channel-base/format-cross-conv.js +69 -0
- package/dist/channel-base/format-cross-conv.js.map +1 -0
- package/dist/channel-base/format-group-block.d.ts +43 -0
- package/dist/channel-base/format-group-block.d.ts.map +1 -0
- package/dist/channel-base/format-group-block.js +59 -0
- package/dist/channel-base/format-group-block.js.map +1 -0
- package/dist/channel-base/index.d.ts +21 -0
- package/dist/channel-base/index.d.ts.map +1 -0
- package/dist/channel-base/index.js +24 -0
- package/dist/channel-base/index.js.map +1 -0
- package/dist/channel-base/lease-guard.d.ts +30 -0
- package/dist/channel-base/lease-guard.d.ts.map +1 -0
- package/dist/channel-base/lease-guard.js +40 -0
- package/dist/channel-base/lease-guard.js.map +1 -0
- package/dist/channel-base/lease-store.d.ts +30 -0
- package/dist/channel-base/lease-store.d.ts.map +1 -0
- package/dist/channel-base/lease-store.js +58 -0
- package/dist/channel-base/lease-store.js.map +1 -0
- package/dist/channel-base/lease.d.ts +94 -0
- package/dist/channel-base/lease.d.ts.map +1 -0
- package/dist/channel-base/lease.js +110 -0
- package/dist/channel-base/lease.js.map +1 -0
- package/dist/channel-core-context.test.d.ts +2 -0
- package/dist/channel-core-context.test.d.ts.map +1 -0
- package/dist/channel-core-context.test.js +334 -0
- package/dist/channel-core-context.test.js.map +1 -0
- package/dist/channel-core-dispatch.test.d.ts +2 -0
- package/dist/channel-core-dispatch.test.d.ts.map +1 -0
- package/dist/channel-core-dispatch.test.js +550 -0
- package/dist/channel-core-dispatch.test.js.map +1 -0
- package/dist/channel-core-enrichment.d.ts +8 -0
- package/dist/channel-core-enrichment.d.ts.map +1 -0
- package/dist/channel-core-enrichment.js +134 -0
- package/dist/channel-core-enrichment.js.map +1 -0
- package/dist/channel-core-errors.d.ts +20 -0
- package/dist/channel-core-errors.d.ts.map +1 -0
- package/dist/channel-core-errors.js +12 -0
- package/dist/channel-core-errors.js.map +1 -0
- package/dist/channel-core-test-support.d.ts +48 -0
- package/dist/channel-core-test-support.d.ts.map +1 -0
- package/dist/channel-core-test-support.js +111 -0
- package/dist/channel-core-test-support.js.map +1 -0
- package/dist/channel-core.d.ts +30 -7
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +214 -237
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +216 -1082
- package/dist/channel-core.test.js.map +1 -1
- package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
- package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
- package/dist/cli/commands/conversations-v2.test.js +64 -54
- package/dist/cli/commands/conversations-v2.test.js.map +1 -1
- package/dist/cli/commands/conversations.d.ts +5 -5
- package/dist/cli/commands/conversations.d.ts.map +1 -1
- package/dist/cli/commands/conversations.js +43 -36
- package/dist/cli/commands/conversations.js.map +1 -1
- package/dist/cli/commands/messages.d.ts +4 -4
- package/dist/cli/commands/messages.d.ts.map +1 -1
- package/dist/cli/commands/messages.js +4 -4
- package/dist/cli/commands/messages.js.map +1 -1
- package/dist/cli/commands/messages.test.js +61 -49
- package/dist/cli/commands/messages.test.js.map +1 -1
- package/dist/cli/commands/ping.d.ts +0 -9
- package/dist/cli/commands/ping.d.ts.map +1 -1
- package/dist/cli/commands/ping.js +1 -1
- package/dist/cli/commands/ping.js.map +1 -1
- package/dist/cli/commands/presence.d.ts +1 -1
- package/dist/cli/commands/presence.js +1 -1
- package/dist/cli/commands/register.d.ts +2 -2
- package/dist/cli/commands/register.d.ts.map +1 -1
- package/dist/cli/commands/register.js +98 -77
- package/dist/cli/commands/register.js.map +1 -1
- package/dist/cli/commands/register.test.js +63 -39
- package/dist/cli/commands/register.test.js.map +1 -1
- package/dist/cli/commands/send.d.ts +6 -6
- package/dist/cli/commands/send.js +6 -6
- package/dist/cli/commands/send.test.js +32 -28
- package/dist/cli/commands/send.test.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +3 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/config.d.ts +0 -2
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +86 -76
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/config.test.js +80 -42
- package/dist/cli/config.test.js.map +1 -1
- package/dist/cli/index.d.ts +1 -16
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +50 -113
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/process-argv.d.ts +2 -0
- package/dist/cli/process-argv.d.ts.map +1 -0
- package/dist/cli/process-argv.js +2 -0
- package/dist/cli/process-argv.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -10
- package/dist/cli/profile.d.ts.map +1 -1
- package/dist/cli/profile.js +105 -104
- package/dist/cli/profile.js.map +1 -1
- package/dist/cli/profile.test.js +338 -232
- package/dist/cli/profile.test.js.map +1 -1
- package/dist/cli/runtime.d.ts +5 -17
- package/dist/cli/runtime.d.ts.map +1 -1
- package/dist/cli/runtime.js +6 -59
- package/dist/cli/runtime.js.map +1 -1
- package/dist/cli/socket-client.d.ts +10 -15
- package/dist/cli/socket-client.d.ts.map +1 -1
- package/dist/cli/socket-client.js +74 -89
- package/dist/cli/socket-client.js.map +1 -1
- package/dist/cli/transport.d.ts +9 -5
- package/dist/cli/transport.d.ts.map +1 -1
- package/dist/cli/transport.js +45 -42
- package/dist/cli/transport.js.map +1 -1
- package/dist/cli/transport.test.js +227 -133
- package/dist/cli/transport.test.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/local-daemon-rpc.d.ts +13 -0
- package/dist/local-daemon-rpc.d.ts.map +1 -0
- package/dist/local-daemon-rpc.js +17 -0
- package/dist/local-daemon-rpc.js.map +1 -0
- package/dist/local-paths.d.ts +7 -0
- package/dist/local-paths.d.ts.map +1 -0
- package/dist/local-paths.js +20 -0
- package/dist/local-paths.js.map +1 -0
- package/dist/runtime/close-info.d.ts +3 -12
- package/dist/runtime/close-info.d.ts.map +1 -1
- package/dist/runtime/close-info.js +28 -39
- package/dist/runtime/close-info.js.map +1 -1
- package/dist/runtime/close-info.test.js +77 -70
- package/dist/runtime/close-info.test.js.map +1 -1
- package/dist/runtime/errors.test.js +42 -28
- package/dist/runtime/errors.test.js.map +1 -1
- package/dist/runtime/frame.d.ts +4 -31
- package/dist/runtime/frame.d.ts.map +1 -1
- package/dist/runtime/frame.js +7 -116
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/frame.test.js +41 -54
- package/dist/runtime/frame.test.js.map +1 -1
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +7 -11
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/local-history.d.ts +67 -0
- package/dist/runtime/local-history.d.ts.map +1 -0
- package/dist/runtime/local-history.js +77 -0
- package/dist/runtime/local-history.js.map +1 -0
- package/dist/runtime/local-service-commands.d.ts +19 -0
- package/dist/runtime/local-service-commands.d.ts.map +1 -1
- package/dist/runtime/local-service-commands.js +23 -0
- package/dist/runtime/local-service-commands.js.map +1 -1
- package/dist/runtime/local-socket-server.d.ts +20 -0
- package/dist/runtime/local-socket-server.d.ts.map +1 -0
- package/dist/runtime/local-socket-server.js +94 -0
- package/dist/runtime/local-socket-server.js.map +1 -0
- package/dist/runtime/refs.d.ts +1 -1
- package/dist/runtime/refs.js +1 -1
- package/dist/runtime/runtime.test.js +29 -21
- package/dist/runtime/runtime.test.js.map +1 -1
- package/dist/runtime/service-helpers.d.ts +32 -0
- package/dist/runtime/service-helpers.d.ts.map +1 -0
- package/dist/runtime/service-helpers.js +85 -0
- package/dist/runtime/service-helpers.js.map +1 -0
- package/dist/runtime/subscribers.d.ts +13 -17
- package/dist/runtime/subscribers.d.ts.map +1 -1
- package/dist/runtime/subscribers.js +37 -32
- package/dist/runtime/subscribers.js.map +1 -1
- package/dist/runtime/subscribers.test.js +139 -131
- package/dist/runtime/subscribers.test.js.map +1 -1
- package/dist/service-socket-path.test.d.ts +2 -0
- package/dist/service-socket-path.test.d.ts.map +1 -0
- package/dist/service-socket-path.test.js +87 -0
- package/dist/service-socket-path.test.js.map +1 -0
- package/dist/service.d.ts +23 -20
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +192 -340
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +652 -653
- package/dist/service.test.js.map +1 -1
- package/dist/test/index.d.ts +30 -14
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +25 -21
- package/dist/test/index.js.map +1 -1
- package/dist/test-utils/channel-service-fixture.d.ts +1 -0
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +139 -98
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
- package/dist/test-utils/conformance-adapter.js +145 -124
- package/dist/test-utils/conformance-adapter.js.map +1 -1
- package/dist/test-utils/fake-service.d.ts.map +1 -1
- package/dist/test-utils/fake-service.js +3 -3
- package/dist/test-utils/fake-service.js.map +1 -1
- package/dist/test-utils/index.d.ts +25 -0
- package/dist/test-utils/index.d.ts.map +1 -1
- package/dist/test-utils/index.js +25 -8
- package/dist/test-utils/index.js.map +1 -1
- package/dist/ws-client-test-support.d.ts +180 -0
- package/dist/ws-client-test-support.d.ts.map +1 -0
- package/dist/ws-client-test-support.js +399 -0
- package/dist/ws-client-test-support.js.map +1 -0
- package/dist/ws-client.d.ts +59 -32
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +272 -321
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.js +388 -800
- package/dist/ws-client.test.js.map +1 -1
- package/package.json +18 -10
package/dist/ws-client.test.js
CHANGED
|
@@ -1,226 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for `ws-client.ts`
|
|
3
|
-
* `@effect/platform` WebSocket server instead of a `vi.mock("ws")` fake.
|
|
4
|
-
*
|
|
5
|
-
* Setup: each test spins up a fresh `NodeSocketServer.makeWebSocket` bound to
|
|
6
|
-
* `127.0.0.1:0` (OS-assigned port). An explicit host is required — omitting
|
|
7
|
-
* it binds `::` which `server.address()` returns verbatim, yielding a
|
|
8
|
-
* non-dialable `ws://:::PORT` URL on Linux/macOS (gotcha §4.11).
|
|
9
|
-
*
|
|
10
|
-
* Coverage matches the §5 invariants + the typed-manifest + malformed-frame
|
|
11
|
-
* cadence tests from the legacy suite. Reconnect-backoff uses real wall-clock
|
|
12
|
-
* timing because the reconnect loop runs on the client's internal
|
|
13
|
-
* `ManagedRuntime`, whose default Clock is out of reach of a test-fiber's
|
|
14
|
-
* `TestClock` (see the `describe("reconnect backoff")` block for details).
|
|
2
|
+
* Tests for `ws-client.ts` against a real in-process `@effect/platform` WebSocket server.
|
|
15
3
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { Cause, Duration, Effect, Exit, Fiber, Option, Scope, TestClock, } from "effect";
|
|
21
|
-
import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer";
|
|
22
|
-
import * as Socket from "@effect/platform/Socket";
|
|
23
|
-
import { MoltZapWsClient, RPC_TIMEOUT_MS, } from "./ws-client.js";
|
|
24
|
-
import { RpcServerError, RpcTimeoutError, } from "@moltzap/protocol";
|
|
25
|
-
import { DispatchAuthorize, Connect, ConversationsList, MessagesSend, MessageReceivedNotificationDefinition, PROTOCOL_VERSION, } from "@moltzap/protocol";
|
|
26
|
-
import { agentId, conversationId, messageId, JSON_RPC_VERSION, validateRequestFrame, validateResponseFrame, } from "@moltzap/protocol/testing";
|
|
27
|
-
// Test fixtures for dispatch/authorize round-trip tests below. The
|
|
28
|
-
// previous partitioned-dispatcher harness provided these via
|
|
29
|
-
// `app-callback-test-requests.ts`; that file was deleted in the
|
|
30
|
-
// cutover. The simpler global-queue topology only needs valid
|
|
31
|
-
// `dispatch/authorize` params shaped to the descriptor.
|
|
32
|
-
const DISPATCH_TASK_A_UUID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
33
|
-
const DISPATCH_TASK_B_UUID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
34
|
-
const DISPATCH_CONV_UUID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
|
35
|
-
const DISPATCH_RECIPIENT_UUID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
|
36
|
-
const DISPATCH_SENDER_UUID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
|
37
|
-
const DISPATCH_MESSAGE_UUID = "11111111-1111-4111-8111-111111111111";
|
|
38
|
-
const SESSION_A = DISPATCH_TASK_A_UUID;
|
|
39
|
-
const SESSION_B = DISPATCH_TASK_B_UUID;
|
|
40
|
-
const dispatchRequestParams = (taskId) => ({
|
|
41
|
-
taskId,
|
|
42
|
-
appId: "app-test",
|
|
43
|
-
conversationId: DISPATCH_CONV_UUID,
|
|
44
|
-
recipient: { agentId: DISPATCH_RECIPIENT_UUID, ownerId: "owner-test" },
|
|
45
|
-
message: {
|
|
46
|
-
id: DISPATCH_MESSAGE_UUID,
|
|
47
|
-
senderAgentId: DISPATCH_SENDER_UUID,
|
|
48
|
-
parts: [{ type: "text", text: "hello" }],
|
|
49
|
-
},
|
|
50
|
-
attempt: 0,
|
|
51
|
-
});
|
|
52
|
-
// ── Test server helpers ────────────────────────────────────────────────
|
|
53
|
-
const LOCALHOST_HOST = "127.0.0.1";
|
|
54
|
-
const TEST_AGENT_ID = agentId("11111111-1111-4111-8111-111111111111");
|
|
55
|
-
const RECONNECT_AGENT_ID = agentId("22222222-2222-4222-8222-222222222222");
|
|
56
|
-
const TEST_MESSAGE_ID = messageId("44444444-4444-4444-8444-444444444444");
|
|
57
|
-
const TEST_CONVERSATION_ID = conversationId("33333333-3333-4333-8333-333333333333");
|
|
58
|
-
const TEST_POLICY = {
|
|
59
|
-
maxMessageBytes: 1_000_000,
|
|
60
|
-
maxPartsPerMessage: 10,
|
|
61
|
-
maxTextLength: 32_768,
|
|
62
|
-
maxGroupParticipants: 100,
|
|
63
|
-
heartbeatIntervalMs: 30_000,
|
|
64
|
-
rateLimits: {
|
|
65
|
-
messagesPerMinute: 60,
|
|
66
|
-
requestsPerMinute: 120,
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
const helloOk = (agentId = TEST_AGENT_ID) => ({
|
|
70
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
71
|
-
agentId,
|
|
72
|
-
policy: TEST_POLICY,
|
|
73
|
-
});
|
|
74
|
-
const TEST_MESSAGE = {
|
|
75
|
-
id: TEST_MESSAGE_ID,
|
|
76
|
-
conversationId: TEST_CONVERSATION_ID,
|
|
77
|
-
senderId: TEST_AGENT_ID,
|
|
78
|
-
parts: [{ type: "text", text: "hello" }],
|
|
79
|
-
createdAt: "2026-05-03T00:00:00.000Z",
|
|
80
|
-
};
|
|
81
|
-
const messageReceivedFrame = () => MessageReceivedNotificationDefinition.encode({
|
|
82
|
-
message: TEST_MESSAGE,
|
|
83
|
-
});
|
|
84
|
-
/**
|
|
85
|
-
* Spin up an in-process `@effect/platform` WS server on `127.0.0.1:0`.
|
|
86
|
-
* Caller owns the provided scope; when it closes, the server shuts down.
|
|
87
|
-
*/
|
|
88
|
-
const startTestServer = (handler) => Effect.gen(function* () {
|
|
89
|
-
const server = yield* NodeSocketServer.makeWebSocket({
|
|
90
|
-
port: 0,
|
|
91
|
-
host: LOCALHOST_HOST,
|
|
92
|
-
});
|
|
93
|
-
const addr = server.address;
|
|
94
|
-
if (addr._tag !== "TcpAddress") {
|
|
95
|
-
return yield* Effect.die("expected TcpAddress");
|
|
96
|
-
}
|
|
97
|
-
const connections = [];
|
|
98
|
-
yield* Effect.forkScoped(server
|
|
99
|
-
.run((serverSock) => Effect.gen(function* () {
|
|
100
|
-
const write = yield* serverSock.writer;
|
|
101
|
-
const receivedList = [];
|
|
102
|
-
const conn = {
|
|
103
|
-
send: (raw) => write(raw).pipe(Effect.ignore),
|
|
104
|
-
close: (code = 1000, reason = "test close") => write(new Socket.CloseEvent(code, reason)).pipe(Effect.ignore),
|
|
105
|
-
get received() {
|
|
106
|
-
return receivedList;
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
connections.push(conn);
|
|
110
|
-
yield* serverSock.runRaw((data) => Effect.gen(function* () {
|
|
111
|
-
const raw = typeof data === "string"
|
|
112
|
-
? data
|
|
113
|
-
: new TextDecoder("utf-8").decode(data);
|
|
114
|
-
receivedList.push(raw);
|
|
115
|
-
yield* handler(conn, raw);
|
|
116
|
-
}));
|
|
117
|
-
}))
|
|
118
|
-
.pipe(Effect.ignore));
|
|
119
|
-
return {
|
|
120
|
-
url: `http://${LOCALHOST_HOST}:${addr.port}`,
|
|
121
|
-
get connections() {
|
|
122
|
-
return connections;
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
});
|
|
126
|
-
const findClosedLocalPort = () => new Promise((resolve, reject) => {
|
|
127
|
-
const server = createServer();
|
|
128
|
-
server.once("error", reject);
|
|
129
|
-
server.listen(0, LOCALHOST_HOST, () => {
|
|
130
|
-
const address = server.address();
|
|
131
|
-
if (address === null || typeof address === "string") {
|
|
132
|
-
server.close();
|
|
133
|
-
reject(new Error("expected TCP server address"));
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
server.close((err) => {
|
|
137
|
-
if (err) {
|
|
138
|
-
reject(err);
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
resolve(address.port);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
// ── Logger helper ──────────────────────────────────────────────────────
|
|
146
|
-
function makeLogger() {
|
|
147
|
-
return {
|
|
148
|
-
info: vi.fn(),
|
|
149
|
-
warn: vi.fn(),
|
|
150
|
-
error: vi.fn(),
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
const connectP = (client) => Effect.runPromise(client.connect().pipe(Effect.catchTag("RpcTimeoutError", (err) => Effect.fail(new Error(`RPC timeout: ${err.method}`))), Effect.catchAll((err) => Effect.fail(new Error(err.message)))));
|
|
154
|
-
const sendRpcP = (client, definition, params) => Effect.runPromise(client.sendRpc(definition, params).pipe(Effect.catchTag("RpcTimeoutError", (err) => Effect.fail(new Error(`RPC timeout: ${err.method}`))), Effect.catchAll((err) => Effect.fail(new Error(err.message)))));
|
|
155
|
-
const closeClient = (client) => client.close();
|
|
156
|
-
/**
|
|
157
|
-
* Start a server whose handler auto-responds to `network/connect` and forwards
|
|
158
|
-
* everything else to the test's `handler`. Useful for tests that only care
|
|
159
|
-
* about post-handshake behaviour.
|
|
160
|
-
*/
|
|
161
|
-
const startHandshakingServer = (handler) => startTestServer((conn, raw) => Effect.gen(function* () {
|
|
162
|
-
const parsed = JSON.parse(raw);
|
|
163
|
-
if (!validateRequestFrame(parsed)) {
|
|
164
|
-
return yield* Effect.die("expected JSON-RPC request frame");
|
|
165
|
-
}
|
|
166
|
-
const frame = parsed;
|
|
167
|
-
if (frame.method === Connect.name) {
|
|
168
|
-
yield* conn.send(JSON.stringify(Connect.encodeResponse(frame.id, helloOk())));
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
yield* handler(conn, raw, frame);
|
|
172
|
-
}));
|
|
173
|
-
/**
|
|
174
|
-
* Promise-returning wrapper that both runs a test Effect and keeps the scope
|
|
175
|
-
* alive until the Effect completes. Tests that need a server scope should
|
|
176
|
-
* use `withTestServer` rather than mutating a Scope manually.
|
|
177
|
-
*/
|
|
178
|
-
const withTestServer = async (effect) => {
|
|
179
|
-
const scope = Effect.runSync(Scope.make());
|
|
180
|
-
try {
|
|
181
|
-
const typed = Scope.extend(effect, scope);
|
|
182
|
-
return await Effect.runPromise(typed);
|
|
183
|
-
}
|
|
184
|
-
finally {
|
|
185
|
-
await Effect.runPromise(Scope.close(scope, Exit.void));
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
/**
|
|
189
|
-
* Poll until `pred()` is true or `maxMs` elapses. The legacy test file used a
|
|
190
|
-
* microtask-based loop; we keep that cadence to match the legacy behaviour
|
|
191
|
-
* when synchronisation is driven by wall-clock timing in the test harness.
|
|
192
|
-
*/
|
|
193
|
-
async function waitFor(pred, { maxMs = 2000 } = {}) {
|
|
194
|
-
const deadline = Date.now() + maxMs;
|
|
195
|
-
while (!pred()) {
|
|
196
|
-
if (Date.now() > deadline) {
|
|
197
|
-
throw new Error("waitFor: condition not satisfied in time");
|
|
198
|
-
}
|
|
199
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Build a `MoltZapWsClient`. Spec #222 OQ-4 deletion: `onNotification` is no
|
|
204
|
-
* longer a constructor option; tests that previously stashed an
|
|
205
|
-
* `onNotification` callback now register a `subscribe({}, …)` subscription
|
|
206
|
-
* post-construction. The helper accepts the same `onNotification` callback as
|
|
207
|
-
* a convenience and wires it through the new subscription registry,
|
|
208
|
-
* keeping migration noise local to the helper.
|
|
209
|
-
*/
|
|
210
|
-
function makeClient(url, overrides) {
|
|
211
|
-
const client = new MoltZapWsClient({
|
|
212
|
-
serverUrl: url,
|
|
213
|
-
agentKey: "test-key",
|
|
214
|
-
onDisconnect: overrides?.onDisconnect ?? ((_close) => { }),
|
|
215
|
-
onReconnect: overrides?.onReconnect ?? (() => { }),
|
|
216
|
-
logger: overrides?.logger ?? makeLogger(),
|
|
217
|
-
});
|
|
218
|
-
if (overrides?.onNotification !== undefined) {
|
|
219
|
-
const cb = overrides.onNotification;
|
|
220
|
-
Effect.runSync(client.subscribe({}, (frame) => Effect.sync(() => cb(frame))));
|
|
221
|
-
}
|
|
222
|
-
return client;
|
|
223
|
-
}
|
|
4
|
+
import { afterEach, beforeEach, expect, it } from "vitest";
|
|
5
|
+
import { Cause, Effect, Exit, Option } from "effect";
|
|
6
|
+
import { AgentsLookupByName, ConversationsList, DispatchAuthorize, Duration, Fiber, ForbiddenError, MessageReceivedNotificationDefinition, MessagesSend, MoltZapWsClient, RpcTimeoutError, TestClock, closeClient, closeWaitSocketOutput, connectClient, connectClientForServer, dispatchRequestParams, effectTest, expectEffectFailure, findClosedLocalPort, findResponseRaw, grantDispatchAuthorizeHandler, makeClient, messageReceivedFrame, realSleep, sendMalformedFrameBurst, sendMalformedFramesAndResponse, sendPaddedNotificationAndResponse, sendRpcEffect, setRef, scopedEffectTest, startAgentsLookupByNameServer, startDispatchAuthorizeServer, startHandshakingServer, startReconnectServer, startTestServer, validateResponseFrame, waitFor, waitForErrorResponse, waitForResponseRaw, withTestServer, CLIENT_DRAIN_COUNT, CLOSE_INFO_WAIT_MS, CLOSE_PROPAGATION_TIMEOUT_MS, CONNECT_FAILURE_MAX_MS, DOMAIN_REJECTED_MESSAGE, DOMAIN_REJECTED_REASON, DUPLICATE_HANDLER_ERROR_TAG, GRANT_DECISION, HANDLER_REJECTION_CODE, JSON_RPC_VERSION, LOCALHOST_HOST, MALFORMED_FRAME_FLUSH_MS, NORMAL_CLOSE_CODE, NORMAL_CLOSE_REASON, POST_TIMEOUT_SETTLE_MS, REALTIME_POLL_TIMEOUT_MS, RECONNECT_AGENT_ID, REFUSED_CONNECT_MAX_MS, REFUSED_CONNECT_TEST_TIMEOUT_MS, RPC_TIMEOUT_MS, SESSION_A, SESSION_B, SERVER_ERROR_CLOSE_CODE, SERVER_ERROR_REASON, SERVER_ERROR_REQUEST_ID, SERVER_TEST_REQUEST_ID, STALE_PORT_TEST_TIMEOUT_MS, TEST_AGENT_ID, TEST_CONVERSATION_ID, } from "./ws-client-test-support.js";
|
|
7
|
+
import { shouldLogMalformedFrame } from "./ws-client.js";
|
|
224
8
|
// ── Tests ──────────────────────────────────────────────────────────────
|
|
225
9
|
beforeEach(() => {
|
|
226
10
|
// Nothing global to reset; each test owns its server scope.
|
|
@@ -228,82 +12,69 @@ beforeEach(() => {
|
|
|
228
12
|
afterEach(() => {
|
|
229
13
|
// Nothing to clean up — test scopes close their own servers.
|
|
230
14
|
});
|
|
15
|
+
it("encodes the notification fixture with the protocol method name", () => {
|
|
16
|
+
expect(messageReceivedFrame().method).toBe(MessageReceivedNotificationDefinition.name);
|
|
17
|
+
});
|
|
231
18
|
// ─────────────────────────────────────────────────────────────────────
|
|
232
19
|
// §5.1 — connect() must not hang on pre-open close/error
|
|
233
20
|
// ─────────────────────────────────────────────────────────────────────
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}, 20_000);
|
|
271
|
-
it("resolves with HelloOk on the happy open → network/connect path", async () => {
|
|
272
|
-
await withTestServer(Effect.gen(function* () {
|
|
273
|
-
const server = yield* startHandshakingServer(() => Effect.void);
|
|
274
|
-
const client = makeClient(server.url);
|
|
275
|
-
const hello = (yield* Effect.promise(() => connectP(client)));
|
|
276
|
-
expect(hello.agentId).toBe(TEST_AGENT_ID);
|
|
277
|
-
expect(client.helloOk).toEqual(hello);
|
|
278
|
-
yield* closeClient(client);
|
|
279
|
-
}));
|
|
280
|
-
});
|
|
281
|
-
});
|
|
21
|
+
effectTest("rejects immediately when the server closes the connection before handshake", () => withTestServer(Effect.gen(function* () {
|
|
22
|
+
// Handler closes on the very first inbound frame — i.e. before the
|
|
23
|
+
// client sees any network/connect response.
|
|
24
|
+
const server = yield* startTestServer((conn) => conn.close(NORMAL_CLOSE_CODE).pipe(Effect.ignore));
|
|
25
|
+
const client = makeClient(server.url);
|
|
26
|
+
const t0 = Date.now();
|
|
27
|
+
yield* expectEffectFailure(connectClient(client), /WebSocket not connected/);
|
|
28
|
+
const elapsed = Date.now() - t0;
|
|
29
|
+
expect(elapsed).toBeLessThan(CONNECT_FAILURE_MAX_MS);
|
|
30
|
+
yield* closeClient(client);
|
|
31
|
+
})));
|
|
32
|
+
effectTest("rejects when the server refuses the connection (well within RPC timeout)", () => Effect.gen(function* () {
|
|
33
|
+
// Point the client at a port that's not accepting connections. The
|
|
34
|
+
// `ws` lib emits 'error' on TCP connect failure which maps to
|
|
35
|
+
// `SocketGenericError{reason: "Open"}`. Our reader fiber's `onExit`
|
|
36
|
+
// catches and fails the handshake deferred -> NotConnectedError.
|
|
37
|
+
//
|
|
38
|
+
// Observed: ECONNREFUSED fires in single-digit ms locally; give
|
|
39
|
+
// generous CI headroom via the 15s assertion but keep test timeout at
|
|
40
|
+
// 20s to avoid flakes on slow runners.
|
|
41
|
+
const refusedPort = yield* findClosedLocalPort();
|
|
42
|
+
const client = makeClient(`http://${LOCALHOST_HOST}:${refusedPort}`);
|
|
43
|
+
const t0 = Date.now();
|
|
44
|
+
yield* expectEffectFailure(connectClient(client), /WebSocket not connected/);
|
|
45
|
+
const elapsed = Date.now() - t0;
|
|
46
|
+
expect(elapsed).toBeLessThan(REFUSED_CONNECT_MAX_MS);
|
|
47
|
+
yield* closeClient(client);
|
|
48
|
+
}), REFUSED_CONNECT_TEST_TIMEOUT_MS);
|
|
49
|
+
effectTest("resolves with HelloOk on the happy open -> network/connect path", () => withTestServer(Effect.gen(function* () {
|
|
50
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
51
|
+
const client = makeClient(server.url);
|
|
52
|
+
const hello = (yield* connectClient(client));
|
|
53
|
+
expect(hello.agentId).toBe(TEST_AGENT_ID);
|
|
54
|
+
expect(client.helloOk).toEqual(hello);
|
|
55
|
+
yield* closeClient(client);
|
|
56
|
+
})));
|
|
282
57
|
// ─────────────────────────────────────────────────────────────────────
|
|
283
58
|
// §5.2 — pending RPCs fail fast on disconnect
|
|
284
59
|
// ─────────────────────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
yield* closeClient(client);
|
|
304
|
-
}));
|
|
305
|
-
});
|
|
306
|
-
});
|
|
60
|
+
effectTest("rejects pending sendRpc calls when disconnect() is called", () => withTestServer(Effect.gen(function* () {
|
|
61
|
+
// Handler responds to network/connect but drops everything else, so the
|
|
62
|
+
// RPC stays pending until we trigger disconnect.
|
|
63
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
64
|
+
const client = makeClient(server.url);
|
|
65
|
+
yield* connectClient(client);
|
|
66
|
+
const rpcFiber = yield* Effect.fork(sendRpcEffect(client, MessagesSend, {
|
|
67
|
+
conversationId: TEST_CONVERSATION_ID,
|
|
68
|
+
parts: [{ type: "text", text: "hi" }],
|
|
69
|
+
}));
|
|
70
|
+
// Wait for the RPC frame to land on the server.
|
|
71
|
+
yield* waitFor(() => server.connections[0].received.length >= 2);
|
|
72
|
+
// Trigger disconnect — the reader-fiber `onExit` path drains pendings
|
|
73
|
+
// with NotConnectedError.
|
|
74
|
+
yield* client.disconnect();
|
|
75
|
+
yield* expectEffectFailure(Fiber.join(rpcFiber), /WebSocket not connected/);
|
|
76
|
+
yield* closeClient(client);
|
|
77
|
+
})));
|
|
307
78
|
// ─────────────────────────────────────────────────────────────────────
|
|
308
79
|
// §5.3 — no automatic retry on timeout (TestClock-driven)
|
|
309
80
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -318,67 +89,48 @@ describe("§5.2 pending RPCs fail on disconnect", () => {
|
|
|
318
89
|
// `Effect.sleep`, so it completes against wall-clock. We poll server-side
|
|
319
90
|
// frame arrival via real setTimeout (sidesteps TestClock) so the test can
|
|
320
91
|
// wait for the RPC frame to land before advancing virtual time.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
// Virtual time: advance past RPC_TIMEOUT_MS → timeoutFail fires.
|
|
355
|
-
yield* TestClock.adjust(Duration.millis(RPC_TIMEOUT_MS));
|
|
356
|
-
const exit = yield* Fiber.await(rpcFiber);
|
|
357
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
358
|
-
if (Exit.isFailure(exit)) {
|
|
359
|
-
// Cause.failureOption narrows the cause to its typed error without
|
|
360
|
-
// touching `_tag` manually. instanceof pins the class nominally
|
|
361
|
-
// in case a defect slipped through with a matching shape.
|
|
362
|
-
const failed = Cause.failureOption(exit.cause);
|
|
363
|
-
expect(Option.isSome(failed)).toBe(true);
|
|
364
|
-
if (Option.isSome(failed)) {
|
|
365
|
-
const err = failed.value;
|
|
366
|
-
expect(err).toBeInstanceOf(RpcTimeoutError);
|
|
367
|
-
if (err instanceof RpcTimeoutError) {
|
|
368
|
-
expect(err.method).toBe(MessagesSend.name);
|
|
369
|
-
expect(err.timeoutMs).toBe(RPC_TIMEOUT_MS);
|
|
370
|
-
}
|
|
92
|
+
scopedEffectTest("fails with RpcTimeoutError after virtual 30s, no retry frame", () => withTestServer(Effect.gen(function* () {
|
|
93
|
+
// Server answers network/connect, then silently drops messages/send.
|
|
94
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
95
|
+
const client = makeClient(server.url);
|
|
96
|
+
// Handshake: not wrapped in Effect.sleep, so TestClock doesn't
|
|
97
|
+
// interfere.
|
|
98
|
+
yield* connectClient(client);
|
|
99
|
+
const serverConn = server.connections[0];
|
|
100
|
+
const beforeCount = serverConn.received.length;
|
|
101
|
+
const rpcFiber = yield* Effect.fork(client.sendRpc(MessagesSend, {
|
|
102
|
+
conversationId: TEST_CONVERSATION_ID,
|
|
103
|
+
parts: [{ type: "text", text: "payload" }],
|
|
104
|
+
}));
|
|
105
|
+
yield* waitFor(() => serverConn.received.length > beforeCount, {
|
|
106
|
+
maxMs: REALTIME_POLL_TIMEOUT_MS,
|
|
107
|
+
});
|
|
108
|
+
expect(serverConn.received.length).toBe(beforeCount + 1);
|
|
109
|
+
// Virtual time: advance past RPC_TIMEOUT_MS → timeoutFail fires.
|
|
110
|
+
yield* TestClock.adjust(Duration.millis(RPC_TIMEOUT_MS));
|
|
111
|
+
const exit = yield* Fiber.await(rpcFiber);
|
|
112
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
113
|
+
if (Exit.isFailure(exit)) {
|
|
114
|
+
// Cause.failureOption narrows the cause to its typed error without
|
|
115
|
+
// touching `_tag` manually. instanceof pins the class nominally
|
|
116
|
+
// in case a defect slipped through with a matching shape.
|
|
117
|
+
const failed = Cause.failureOption(exit.cause);
|
|
118
|
+
expect(Option.isSome(failed)).toBe(true);
|
|
119
|
+
if (Option.isSome(failed)) {
|
|
120
|
+
const err = failed.value;
|
|
121
|
+
expect(err).toBeInstanceOf(RpcTimeoutError);
|
|
122
|
+
if (err instanceof RpcTimeoutError) {
|
|
123
|
+
expect(err.method).toBe(MessagesSend.name);
|
|
124
|
+
expect(err.timeoutMs).toBe(RPC_TIMEOUT_MS);
|
|
371
125
|
}
|
|
372
126
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}));
|
|
381
|
-
});
|
|
127
|
+
}
|
|
128
|
+
// No retry frame may have been enqueued — timeout is terminal. Bounce
|
|
129
|
+
// once through the real event loop for any stragglers.
|
|
130
|
+
yield* realSleep(POST_TIMEOUT_SETTLE_MS);
|
|
131
|
+
expect(serverConn.received.length).toBe(beforeCount + 1);
|
|
132
|
+
yield* client.close();
|
|
133
|
+
})));
|
|
382
134
|
// ─────────────────────────────────────────────────────────────────────
|
|
383
135
|
// Reconnect backoff — real wall-clock
|
|
384
136
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -389,469 +141,311 @@ describe("§5.3 sendRpc does NOT retry on timeout (TestClock)", () => {
|
|
|
389
141
|
// default Clock, so `TestClock.adjust` from a test fiber doesn't release
|
|
390
142
|
// `Effect.sleep`s scheduled inside it. We assert on real wall-clock timing
|
|
391
143
|
// instead; the jittered base delay is [0, 1s] so 2.5s is a safe ceiling.
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const parsed = JSON.parse(raw);
|
|
399
|
-
if (!validateRequestFrame(parsed)) {
|
|
400
|
-
return yield* Effect.die("expected JSON-RPC request frame");
|
|
401
|
-
}
|
|
402
|
-
const frame = parsed;
|
|
403
|
-
if (frame.method === Connect.name) {
|
|
404
|
-
authResponsesSent++;
|
|
405
|
-
yield* conn.send(JSON.stringify(Connect.encodeResponse(frame.id, helloOk(RECONNECT_AGENT_ID))));
|
|
406
|
-
}
|
|
407
|
-
}));
|
|
408
|
-
const client = makeClient(server.url, {
|
|
409
|
-
onReconnect: (hello) => {
|
|
410
|
-
reconnectHello = hello;
|
|
411
|
-
},
|
|
412
|
-
});
|
|
413
|
-
yield* Effect.promise(() => connectP(client));
|
|
414
|
-
expect(authResponsesSent).toBe(1);
|
|
415
|
-
expect(server.connections.length).toBe(1);
|
|
416
|
-
// Kill the server-side connection. The client's reader sees a close,
|
|
417
|
-
// fails pendings, invokes onDisconnect, and schedules a reconnect
|
|
418
|
-
// via `Effect.sleep` + `Schedule.jittered`.
|
|
419
|
-
yield* server.connections[0].close(1000);
|
|
420
|
-
yield* Effect.promise(() => waitFor(() => server.connections.length >= 2 && authResponsesSent >= 2, { maxMs: 2500 }));
|
|
421
|
-
expect(server.connections.length).toBeGreaterThanOrEqual(2);
|
|
422
|
-
expect(authResponsesSent).toBeGreaterThanOrEqual(2);
|
|
423
|
-
yield* Effect.promise(() => waitFor(() => reconnectHello !== null, { maxMs: 500 }));
|
|
424
|
-
expect(reconnectHello.agentId).toBe(RECONNECT_AGENT_ID);
|
|
425
|
-
yield* closeClient(client);
|
|
426
|
-
}));
|
|
144
|
+
effectTest("reconnects with exponential-jittered backoff after unsolicited server close", () => withTestServer(Effect.gen(function* () {
|
|
145
|
+
const authResponsesSent = { current: 0 };
|
|
146
|
+
const reconnectHello = { current: null };
|
|
147
|
+
const server = yield* startReconnectServer(authResponsesSent);
|
|
148
|
+
const client = makeClient(server.url, {
|
|
149
|
+
onReconnect: setRef(reconnectHello),
|
|
427
150
|
});
|
|
428
|
-
|
|
151
|
+
yield* connectClient(client);
|
|
152
|
+
expect(authResponsesSent.current).toBe(1);
|
|
153
|
+
expect(server.connections.length).toBe(1);
|
|
154
|
+
// Kill the server-side connection. The client's reader sees a close,
|
|
155
|
+
// fails pendings, invokes onDisconnect, and schedules a reconnect
|
|
156
|
+
// via `Effect.sleep` + `Schedule.jittered`.
|
|
157
|
+
yield* server.connections[0].close(NORMAL_CLOSE_CODE);
|
|
158
|
+
yield* waitFor(() => server.connections.length >= 2 && authResponsesSent.current >= 2, { maxMs: 2500 });
|
|
159
|
+
expect(server.connections.length).toBeGreaterThanOrEqual(2);
|
|
160
|
+
expect(authResponsesSent.current).toBeGreaterThanOrEqual(2);
|
|
161
|
+
yield* waitFor(() => reconnectHello.current !== null, { maxMs: 500 });
|
|
162
|
+
expect(reconnectHello.current.agentId).toBe(RECONNECT_AGENT_ID);
|
|
163
|
+
yield* closeClient(client);
|
|
164
|
+
})));
|
|
429
165
|
// ─────────────────────────────────────────────────────────────────────
|
|
430
166
|
// §5.4 — malformed inbound frames logged + ignored, not crashing
|
|
431
167
|
// ─────────────────────────────────────────────────────────────────────
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
yield* conn.send(JSON.stringify(ConversationsList.encodeResponse(frame.id, {
|
|
448
|
-
conversations: [],
|
|
449
|
-
})));
|
|
450
|
-
}));
|
|
451
|
-
const client = makeClient(server.url, { logger });
|
|
452
|
-
yield* Effect.promise(() => connectP(client));
|
|
453
|
-
const result = (yield* Effect.promise(() => sendRpcP(client, ConversationsList, {})));
|
|
454
|
-
expect(result.conversations).toEqual([]);
|
|
455
|
-
// Logger saw at least one malformed-frame warning.
|
|
456
|
-
expect(logger.warn).toHaveBeenCalled();
|
|
457
|
-
yield* closeClient(client);
|
|
458
|
-
}));
|
|
168
|
+
effectTest("ignores non-JSON frames while a pending RPC is outstanding, then resolves on the real response", () => withTestServer(Effect.gen(function* () {
|
|
169
|
+
// Server auto-handshakes. On `conversations/list`, injects a few
|
|
170
|
+
// malformed inbound frames, then the real response.
|
|
171
|
+
const server = yield* startHandshakingServer(sendMalformedFramesAndResponse);
|
|
172
|
+
const client = makeClient(server.url);
|
|
173
|
+
yield* connectClient(client);
|
|
174
|
+
const result = (yield* sendRpcEffect(client, ConversationsList, {}));
|
|
175
|
+
expect(result.conversations).toEqual([]);
|
|
176
|
+
yield* closeClient(client);
|
|
177
|
+
})));
|
|
178
|
+
effectTest("logs a combined notification and response text frame as malformed", () => withTestServer(Effect.gen(function* () {
|
|
179
|
+
const events = [];
|
|
180
|
+
const server = yield* startHandshakingServer(sendPaddedNotificationAndResponse);
|
|
181
|
+
const client = makeClient(server.url, {
|
|
182
|
+
onNotification: (event) => events.push(event),
|
|
459
183
|
});
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}));
|
|
473
|
-
const client = makeClient(server.url, {
|
|
474
|
-
logger,
|
|
475
|
-
onNotification: (event) => events.push(event),
|
|
476
|
-
});
|
|
477
|
-
yield* Effect.promise(() => connectP(client));
|
|
478
|
-
const result = (yield* Effect.promise(() => sendRpcP(client, ConversationsList, {})));
|
|
479
|
-
expect(result.conversations).toEqual([]);
|
|
480
|
-
expect(events).toHaveLength(1);
|
|
481
|
-
expect(events[0]).toMatchObject({
|
|
482
|
-
method: MessageReceivedNotificationDefinition.name,
|
|
483
|
-
});
|
|
484
|
-
expect(logger.warn).not.toHaveBeenCalled();
|
|
485
|
-
yield* closeClient(client);
|
|
486
|
-
}));
|
|
184
|
+
yield* connectClient(client);
|
|
185
|
+
const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
|
|
186
|
+
yield* realSleep(POST_TIMEOUT_SETTLE_MS);
|
|
187
|
+
expect(events).toHaveLength(0);
|
|
188
|
+
yield* Fiber.interrupt(rpcFiber);
|
|
189
|
+
yield* closeClient(client);
|
|
190
|
+
})));
|
|
191
|
+
effectTest("routes a well-formed notification frame to onNotification", () => withTestServer(Effect.gen(function* () {
|
|
192
|
+
const events = [];
|
|
193
|
+
const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify(messageReceivedFrame())));
|
|
194
|
+
const client = makeClient(server.url, {
|
|
195
|
+
onNotification: (e) => events.push(e),
|
|
487
196
|
});
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
yield* Effect.promise(() => connectP(client));
|
|
496
|
-
// Fire-and-forget: the server responds with an out-of-band notification
|
|
497
|
-
// rather than an RPC response, so the noop Deferred never resolves.
|
|
498
|
-
// Awaiting it would wedge the test for the full RPC_TIMEOUT_MS.
|
|
499
|
-
void sendRpcP(client, ConversationsList, {}).catch(() => { });
|
|
500
|
-
yield* Effect.promise(() => waitFor(() => events.length > 0, { maxMs: 2000 }));
|
|
501
|
-
expect(events[0]).toMatchObject({
|
|
502
|
-
method: MessageReceivedNotificationDefinition.name,
|
|
503
|
-
});
|
|
504
|
-
yield* closeClient(client);
|
|
505
|
-
}));
|
|
197
|
+
yield* connectClient(client);
|
|
198
|
+
// Fire-and-forget: the server responds with an out-of-band notification
|
|
199
|
+
// rather than an RPC response, so the noop Deferred never resolves.
|
|
200
|
+
// Awaiting it would wedge the test for the full RPC_TIMEOUT_MS.
|
|
201
|
+
const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
|
|
202
|
+
yield* waitFor(() => events.length > 0, {
|
|
203
|
+
maxMs: CLOSE_INFO_WAIT_MS,
|
|
506
204
|
});
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const events = [];
|
|
510
|
-
const logger = makeLogger();
|
|
511
|
-
// Send a malformed notification on the first post-handshake frame.
|
|
512
|
-
const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify({ jsonrpc: JSON_RPC_VERSION, params: {} })));
|
|
513
|
-
const client = makeClient(server.url, {
|
|
514
|
-
onNotification: (e) => events.push(e),
|
|
515
|
-
logger,
|
|
516
|
-
});
|
|
517
|
-
yield* Effect.promise(() => connectP(client));
|
|
518
|
-
// Fire-and-forget: see the well-formed-event test above for rationale.
|
|
519
|
-
void sendRpcP(client, ConversationsList, {}).catch(() => { });
|
|
520
|
-
yield* Effect.promise(() => new Promise((r) => setTimeout(r, 50)));
|
|
521
|
-
expect(events).toHaveLength(0);
|
|
522
|
-
expect(logger.warn).toHaveBeenCalled();
|
|
523
|
-
yield* closeClient(client);
|
|
524
|
-
}));
|
|
205
|
+
expect(events[0]).toMatchObject({
|
|
206
|
+
method: MessageReceivedNotificationDefinition.name,
|
|
525
207
|
});
|
|
526
|
-
|
|
208
|
+
yield* Fiber.interrupt(rpcFiber);
|
|
209
|
+
yield* closeClient(client);
|
|
210
|
+
})));
|
|
211
|
+
effectTest("does NOT route a notification frame missing the method field", () => withTestServer(Effect.gen(function* () {
|
|
212
|
+
const events = [];
|
|
213
|
+
// Send a malformed notification on the first post-handshake frame.
|
|
214
|
+
const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify({ jsonrpc: JSON_RPC_VERSION, params: {} })));
|
|
215
|
+
const client = makeClient(server.url, {
|
|
216
|
+
onNotification: (e) => events.push(e),
|
|
217
|
+
});
|
|
218
|
+
yield* connectClient(client);
|
|
219
|
+
// Fire-and-forget: see the well-formed-event test above for rationale.
|
|
220
|
+
const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
|
|
221
|
+
yield* Effect.sleep(Duration.millis(POST_TIMEOUT_SETTLE_MS));
|
|
222
|
+
expect(events).toHaveLength(0);
|
|
223
|
+
yield* Fiber.interrupt(rpcFiber);
|
|
224
|
+
yield* closeClient(client);
|
|
225
|
+
})));
|
|
527
226
|
// ─────────────────────────────────────────────────────────────────────
|
|
528
227
|
// Malformed-frame log cadence
|
|
529
228
|
// ─────────────────────────────────────────────────────────────────────
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
void sendRpcP(client, ConversationsList, {}).catch(() => { });
|
|
547
|
-
// Wait for the malformed frames to flush through the reader fiber.
|
|
548
|
-
yield* Effect.promise(() => new Promise((r) => setTimeout(r, 300)));
|
|
549
|
-
expect(logger.warn).toHaveBeenCalledTimes(3);
|
|
550
|
-
const warnMessages = logger.warn.mock.calls.map((c) => c[0]);
|
|
551
|
-
expect(warnMessages[0]).toMatch(/^Malformed frame \(#1\):/);
|
|
552
|
-
expect(warnMessages[1]).toMatch(/^Malformed frame \(#50\):/);
|
|
553
|
-
expect(warnMessages[2]).toMatch(/^Malformed frame \(#100\):/);
|
|
554
|
-
yield* closeClient(client);
|
|
555
|
-
}));
|
|
556
|
-
});
|
|
557
|
-
});
|
|
229
|
+
effectTest("logs at frames #1, #50, #100 and suppresses everything in between", () => withTestServer(Effect.gen(function* () {
|
|
230
|
+
// Server auto-handshakes. On ANY post-handshake frame, fires 101
|
|
231
|
+
// malformed frames back at the client.
|
|
232
|
+
const server = yield* startHandshakingServer(sendMalformedFrameBurst);
|
|
233
|
+
const client = makeClient(server.url);
|
|
234
|
+
yield* connectClient(client);
|
|
235
|
+
// Fire-and-forget: server responds with 101 malformed frames, no
|
|
236
|
+
// actual RPC response, so awaiting the noop would wedge the test.
|
|
237
|
+
const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
|
|
238
|
+
// Wait for the malformed frames to flush through the reader fiber.
|
|
239
|
+
yield* Effect.sleep(Duration.millis(MALFORMED_FRAME_FLUSH_MS));
|
|
240
|
+
const loggedFrameNumbers = Array.from({ length: 101 }, (_, index) => index + 1).filter(shouldLogMalformedFrame);
|
|
241
|
+
expect(loggedFrameNumbers).toEqual([1, 50, 100]);
|
|
242
|
+
yield* Fiber.interrupt(rpcFiber);
|
|
243
|
+
yield* closeClient(client);
|
|
244
|
+
})));
|
|
558
245
|
// ─────────────────────────────────────────────────────────────────────
|
|
559
246
|
// close() vs pending-RPC interleave
|
|
560
247
|
// ─────────────────────────────────────────────────────────────────────
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
expect(Date.now() - beforeMs).toBeLessThan(1000);
|
|
574
|
-
}));
|
|
575
|
-
});
|
|
576
|
-
});
|
|
248
|
+
effectTest("rejects the pending RPC with NotConnectedError before any timeout", () => withTestServer(Effect.gen(function* () {
|
|
249
|
+
// Server handshakes then drops everything.
|
|
250
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
251
|
+
const client = makeClient(server.url);
|
|
252
|
+
yield* connectClient(client);
|
|
253
|
+
const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}));
|
|
254
|
+
yield* waitFor(() => server.connections[0].received.length >= 2);
|
|
255
|
+
const beforeMs = Date.now();
|
|
256
|
+
yield* closeClient(client);
|
|
257
|
+
yield* expectEffectFailure(Fiber.join(rpcFiber), /WebSocket not connected/);
|
|
258
|
+
expect(Date.now() - beforeMs).toBeLessThan(CLOSE_PROPAGATION_TIMEOUT_MS);
|
|
259
|
+
})));
|
|
577
260
|
// ─────────────────────────────────────────────────────────────────────
|
|
578
261
|
// ws.on("error") → close propagation
|
|
579
262
|
// ─────────────────────────────────────────────────────────────────────
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const rpcP = sendRpcP(client, ConversationsList, {});
|
|
589
|
-
yield* Effect.promise(() => expect(rpcP).rejects.toThrow(/WebSocket not connected/));
|
|
590
|
-
// Logger captured the WebSocket error (warn level).
|
|
591
|
-
expect(logger.warn).toHaveBeenCalled();
|
|
592
|
-
yield* closeClient(client);
|
|
593
|
-
}));
|
|
594
|
-
});
|
|
595
|
-
});
|
|
263
|
+
effectTest("pending RPCs reject with NotConnectedError after the server closes", () => withTestServer(Effect.gen(function* () {
|
|
264
|
+
// Handshakes, then on any follow-up RPC, closes abruptly (code 1011).
|
|
265
|
+
const server = yield* startHandshakingServer((conn) => conn.close(SERVER_ERROR_CLOSE_CODE));
|
|
266
|
+
const client = makeClient(server.url);
|
|
267
|
+
yield* connectClient(client);
|
|
268
|
+
yield* expectEffectFailure(sendRpcEffect(client, ConversationsList, {}), /WebSocket not connected/);
|
|
269
|
+
yield* closeClient(client);
|
|
270
|
+
})));
|
|
596
271
|
// ─────────────────────────────────────────────────────────────────────
|
|
597
272
|
// Typed-manifest sendRpc overload
|
|
598
273
|
// ─────────────────────────────────────────────────────────────────────
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
yield* conn.send(JSON.stringify(AgentsLookupByName.encodeResponse(frame.id, { agents: [] })));
|
|
607
|
-
}));
|
|
608
|
-
const client = makeClient(server.url);
|
|
609
|
-
yield* Effect.promise(() => connectP(client));
|
|
610
|
-
const result = yield* Effect.promise(() => Effect.runPromise(client.sendRpc(AgentsLookupByName, { names: ["alice"] })));
|
|
611
|
-
expect(result.agents).toEqual([]);
|
|
612
|
-
expect(captured.current?.method).toBe(AgentsLookupByName.name);
|
|
613
|
-
expect(captured.current?.params).toEqual({ names: ["alice"] });
|
|
614
|
-
yield* closeClient(client);
|
|
615
|
-
}));
|
|
274
|
+
effectTest("uses definition.name as the wire-level method string", () => withTestServer(Effect.gen(function* () {
|
|
275
|
+
const captured = { current: null };
|
|
276
|
+
const server = yield* startAgentsLookupByNameServer(captured);
|
|
277
|
+
const client = makeClient(server.url);
|
|
278
|
+
yield* connectClient(client);
|
|
279
|
+
const result = yield* client.sendRpc(AgentsLookupByName, {
|
|
280
|
+
names: ["alice"],
|
|
616
281
|
});
|
|
617
|
-
|
|
282
|
+
expect(result.agents).toEqual([]);
|
|
283
|
+
expect(captured.current?.method).toBe(AgentsLookupByName.name);
|
|
284
|
+
expect(captured.current?.params).toEqual({ names: ["alice"] });
|
|
285
|
+
yield* closeClient(client);
|
|
286
|
+
})));
|
|
618
287
|
// ─────────────────────────────────────────────────────────────────────
|
|
619
288
|
// AC2 — graceful close drains ESTABLISHED sockets (socket-count evidence)
|
|
620
289
|
// ─────────────────────────────────────────────────────────────────────
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
encoding: "utf-8",
|
|
639
|
-
}));
|
|
640
|
-
const stale = ssOut
|
|
641
|
-
.split("\n")
|
|
642
|
-
.filter((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
|
|
643
|
-
expect(stale).toHaveLength(0);
|
|
644
|
-
}));
|
|
645
|
-
}, 15_000);
|
|
646
|
-
});
|
|
290
|
+
effectTest("10 clients close cleanly: no CLOSE_WAIT sockets on the server port", () => withTestServer(Effect.gen(function* () {
|
|
291
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
292
|
+
const port = new URL(server.url).port;
|
|
293
|
+
// Connect 10 clients concurrently.
|
|
294
|
+
const clients = yield* Effect.all(Array.from({ length: CLIENT_DRAIN_COUNT }, () => connectClientForServer(server.url)), { concurrency: CLIENT_DRAIN_COUNT });
|
|
295
|
+
expect(server.connections.length).toBe(CLIENT_DRAIN_COUNT);
|
|
296
|
+
// Close all 10 via the graceful Effect-based close().
|
|
297
|
+
yield* Effect.all(clients.map((c) => c.close()), { concurrency: CLIENT_DRAIN_COUNT });
|
|
298
|
+
// Brief pause for TCP close handshake to complete.
|
|
299
|
+
yield* Effect.sleep("150 millis");
|
|
300
|
+
// Assert no CLOSE_WAIT connections remain on the server port.
|
|
301
|
+
const ssOut = yield* closeWaitSocketOutput;
|
|
302
|
+
const stale = ssOut
|
|
303
|
+
.split("\n")
|
|
304
|
+
.filter((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
|
|
305
|
+
expect(stale).toHaveLength(0);
|
|
306
|
+
})), STALE_PORT_TEST_TIMEOUT_MS);
|
|
647
307
|
// ─────────────────────────────────────────────────────────────────────
|
|
648
308
|
// Spec #222 §5.4 (V7) — `onDisconnect` receives a typed `CloseInfo`
|
|
649
309
|
// ─────────────────────────────────────────────────────────────────────
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
closes.push(close);
|
|
676
|
-
},
|
|
677
|
-
});
|
|
678
|
-
yield* Effect.promise(() => connectP(client));
|
|
679
|
-
yield* server.connections[0].close(1000, "bye");
|
|
680
|
-
yield* Effect.promise(() => waitFor(() => closes.length > 0, { maxMs: 2000 }));
|
|
681
|
-
yield* closeClient(client);
|
|
682
|
-
expect(closes.length).toBeGreaterThanOrEqual(1);
|
|
683
|
-
const first = closes[0];
|
|
684
|
-
expect(first.code).toBe(1000);
|
|
685
|
-
expect(first.reason).toBe("normal"); // OQ-5 default, not "bye"
|
|
686
|
-
}));
|
|
310
|
+
// Two distinguishable closes through the live reader-fiber path:
|
|
311
|
+
// 1. Server-initiated 1000/"bye" → CloseInfo{1000, NORMAL_CLOSE_REASON}
|
|
312
|
+
// (`@effect/platform/Socket` treats 1000 as `Exit.Success` per
|
|
313
|
+
// its default `closeCodeIsError`; `extractCloseInfo` synthesizes
|
|
314
|
+
// the OQ-5 graceful default — the close-frame reason is dropped
|
|
315
|
+
// because there's no `SocketCloseError` to round-trip.)
|
|
316
|
+
// 2. Server-initiated 1011/SERVER_ERROR_REASON → CloseInfo{1011, SERVER_ERROR_REASON}
|
|
317
|
+
// (non-1000 codes raise `SocketCloseError`; the real code +
|
|
318
|
+
// `closeReason` round-trip through.)
|
|
319
|
+
//
|
|
320
|
+
// The pre-deletion adapter hardcoded `{1000, "disconnect"}` regardless
|
|
321
|
+
// of the actual close. A V7 mutation that re-introduces the hardcode
|
|
322
|
+
// flips the second test's `code === 1011` assertion pass → fail,
|
|
323
|
+
// satisfying AC 5.4-4 (distinguishable payloads). The OQ-5
|
|
324
|
+
// default-fallback paths (HandshakeFailure / TransportFailure /
|
|
325
|
+
// Unknown → DEFAULT_ABNORMAL_CLOSE) are covered by the direct unit
|
|
326
|
+
// tests in `runtime/close-info.test.ts`, where synthetic `Exit`
|
|
327
|
+
// values exercise each branch without a real transport.
|
|
328
|
+
effectTest("synthesizes the graceful default when the transport treats 1000 as Exit.Success", () => withTestServer(Effect.gen(function* () {
|
|
329
|
+
const closes = [];
|
|
330
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
331
|
+
const client = makeClient(server.url, {
|
|
332
|
+
onDisconnect: (close) => {
|
|
333
|
+
closes.push(close);
|
|
334
|
+
},
|
|
687
335
|
});
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const client = makeClient(server.url, {
|
|
693
|
-
onDisconnect: (close) => {
|
|
694
|
-
closes.push(close);
|
|
695
|
-
},
|
|
696
|
-
});
|
|
697
|
-
yield* Effect.promise(() => connectP(client));
|
|
698
|
-
yield* server.connections[0].close(1011, "boom");
|
|
699
|
-
yield* Effect.promise(() => waitFor(() => closes.length > 0, { maxMs: 2000 }));
|
|
700
|
-
yield* closeClient(client);
|
|
701
|
-
expect(closes.length).toBeGreaterThanOrEqual(1);
|
|
702
|
-
const first = closes[0];
|
|
703
|
-
// Distinguishable from the 1000 test above (AC 5.4-4):
|
|
704
|
-
// a hardcoded `{1000, "disconnect"}` would fail this assertion.
|
|
705
|
-
expect(first.code).toBe(1011);
|
|
706
|
-
expect(first.reason).toBe("boom");
|
|
707
|
-
}));
|
|
336
|
+
yield* connectClient(client);
|
|
337
|
+
yield* server.connections[0].close(NORMAL_CLOSE_CODE, "bye");
|
|
338
|
+
yield* waitFor(() => closes.length > 0, {
|
|
339
|
+
maxMs: CLOSE_INFO_WAIT_MS,
|
|
708
340
|
});
|
|
709
|
-
|
|
341
|
+
yield* closeClient(client);
|
|
342
|
+
expect(closes.length).toBeGreaterThanOrEqual(1);
|
|
343
|
+
const first = closes[0];
|
|
344
|
+
expect(first.code).toBe(NORMAL_CLOSE_CODE);
|
|
345
|
+
expect(first.reason).toBe(NORMAL_CLOSE_REASON); // OQ-5 default, not "bye"
|
|
346
|
+
})));
|
|
347
|
+
effectTest("round-trips the server's close code + reason for a 1011 (server error) close", () => withTestServer(Effect.gen(function* () {
|
|
348
|
+
const closes = [];
|
|
349
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
350
|
+
const client = makeClient(server.url, {
|
|
351
|
+
onDisconnect: (close) => {
|
|
352
|
+
closes.push(close);
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
yield* connectClient(client);
|
|
356
|
+
yield* server.connections[0].close(SERVER_ERROR_CLOSE_CODE, SERVER_ERROR_REASON);
|
|
357
|
+
yield* waitFor(() => closes.length > 0, {
|
|
358
|
+
maxMs: CLOSE_INFO_WAIT_MS,
|
|
359
|
+
});
|
|
360
|
+
yield* closeClient(client);
|
|
361
|
+
expect(closes.length).toBeGreaterThanOrEqual(1);
|
|
362
|
+
const first = closes[0];
|
|
363
|
+
// Distinguishable from the 1000 test above (AC 5.4-4):
|
|
364
|
+
// a hardcoded `{1000, "disconnect"}` would fail this assertion.
|
|
365
|
+
expect(first.code).toBe(SERVER_ERROR_CLOSE_CODE);
|
|
366
|
+
expect(first.reason).toBe(SERVER_ERROR_REASON);
|
|
367
|
+
})));
|
|
710
368
|
// ─────────────────────────────────────────────────────────────────────
|
|
711
369
|
// Phase 1.0 (B.1) gating tests — client-side server-initiated RPC
|
|
712
370
|
// (handleServerRpc + dispatcher fiber + appCallback response write-back)
|
|
713
371
|
// ─────────────────────────────────────────────────────────────────────
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
expect(parsedResponse.id).toBe("srv-test-1");
|
|
774
|
-
expect("result" in parsedResponse).toBe(true);
|
|
775
|
-
if (!("result" in parsedResponse))
|
|
776
|
-
return;
|
|
777
|
-
const result = parsedResponse.result;
|
|
778
|
-
expect(result.admission.decision).toBe("grant");
|
|
779
|
-
expect(observedTaskId).toBe(SESSION_A);
|
|
780
|
-
yield* closeClient(client);
|
|
781
|
-
}));
|
|
782
|
-
});
|
|
783
|
-
it("encodes a typed RpcServerError from the handler as a `response` frame with `error`", async () => {
|
|
784
|
-
await withTestServer(Effect.gen(function* () {
|
|
785
|
-
const server = yield* startTestServer((conn, raw) => Effect.gen(function* () {
|
|
786
|
-
const parsed = JSON.parse(raw);
|
|
787
|
-
if (!validateRequestFrame(parsed)) {
|
|
788
|
-
return yield* Effect.die("expected JSON-RPC request frame");
|
|
789
|
-
}
|
|
790
|
-
const frame = parsed;
|
|
791
|
-
if (frame.method === Connect.name) {
|
|
792
|
-
yield* conn.send(JSON.stringify(Connect.encodeResponse(frame.id, helloOk())));
|
|
793
|
-
yield* conn.send(JSON.stringify(DispatchAuthorize.encodeRequest("srv-err-1", dispatchRequestParams(SESSION_B))));
|
|
794
|
-
}
|
|
795
|
-
}));
|
|
796
|
-
const client = makeClient(server.url);
|
|
797
|
-
yield* client.handleServerRpc(DispatchAuthorize, () => Effect.fail(new RpcServerError({
|
|
798
|
-
code: -32099,
|
|
799
|
-
message: "domain-rejected",
|
|
800
|
-
data: { reason: "test" },
|
|
801
|
-
})));
|
|
802
|
-
yield* Effect.promise(() => connectP(client));
|
|
803
|
-
yield* Effect.promise(() => waitFor(() => server.connections[0].received.some((r) => {
|
|
804
|
-
try {
|
|
805
|
-
const parsed = JSON.parse(r);
|
|
806
|
-
return (validateResponseFrame(parsed) &&
|
|
807
|
-
parsed.id === "srv-err-1" &&
|
|
808
|
-
"error" in parsed);
|
|
809
|
-
}
|
|
810
|
-
catch {
|
|
811
|
-
return false;
|
|
812
|
-
}
|
|
813
|
-
}), { maxMs: 2000 }));
|
|
814
|
-
const found = server.connections[0].received.find((r) => {
|
|
815
|
-
try {
|
|
816
|
-
const parsed = JSON.parse(r);
|
|
817
|
-
return validateResponseFrame(parsed) && parsed.id === "srv-err-1";
|
|
818
|
-
}
|
|
819
|
-
catch {
|
|
820
|
-
return false;
|
|
821
|
-
}
|
|
822
|
-
});
|
|
823
|
-
const parsed = JSON.parse(found);
|
|
824
|
-
expect(validateResponseFrame(parsed)).toBe(true);
|
|
825
|
-
if (!validateResponseFrame(parsed))
|
|
826
|
-
return;
|
|
827
|
-
expect("error" in parsed).toBe(true);
|
|
828
|
-
if (!("error" in parsed))
|
|
829
|
-
return;
|
|
830
|
-
expect(parsed.error.code).toBe(-32099);
|
|
831
|
-
expect(parsed.error.message).toBe("domain-rejected");
|
|
832
|
-
expect(parsed.error.data.reason).toBe("test");
|
|
833
|
-
yield* closeClient(client);
|
|
834
|
-
}));
|
|
372
|
+
effectTest("dispatches an inbound appCallback request to the registered handler and writes the response back", () => withTestServer(Effect.gen(function* () {
|
|
373
|
+
// Server: auto-handshake; immediately AFTER replying to
|
|
374
|
+
// network/connect, send an appCallback request to the client; capture every
|
|
375
|
+
// subsequent inbound frame the client writes back. We're testing
|
|
376
|
+
// the client's dispatcher fiber + handler registry + response
|
|
377
|
+
// encoding.
|
|
378
|
+
const server = yield* startDispatchAuthorizeServer(SERVER_TEST_REQUEST_ID, dispatchRequestParams(SESSION_A));
|
|
379
|
+
const client = makeClient(server.url);
|
|
380
|
+
// Register a handler BEFORE connect so the dispatcher fiber sees
|
|
381
|
+
// it on the very first inbound task-callback request. The
|
|
382
|
+
// handler captures the `taskId` it was invoked with so the
|
|
383
|
+
// assertion below can verify the request reached the right
|
|
384
|
+
// descriptor's handler.
|
|
385
|
+
const observedTaskId = { current: null };
|
|
386
|
+
yield* client.handleServerRpc(DispatchAuthorize, grantDispatchAuthorizeHandler(observedTaskId));
|
|
387
|
+
yield* connectClient(client);
|
|
388
|
+
// Wait for the response frame the dispatcher writes back. The
|
|
389
|
+
// server records every inbound frame in `received` — the appCallback
|
|
390
|
+
// response should appear after the client's network/connect.
|
|
391
|
+
const responseRaw = yield* waitForResponseRaw(server, SERVER_TEST_REQUEST_ID);
|
|
392
|
+
const parsedResponse = JSON.parse(responseRaw);
|
|
393
|
+
expect(validateResponseFrame(parsedResponse)).toBe(true);
|
|
394
|
+
if (!validateResponseFrame(parsedResponse))
|
|
395
|
+
return;
|
|
396
|
+
expect(parsedResponse.id).toBe(SERVER_TEST_REQUEST_ID);
|
|
397
|
+
expect("result" in parsedResponse).toBe(true);
|
|
398
|
+
if (!("result" in parsedResponse))
|
|
399
|
+
return;
|
|
400
|
+
const result = parsedResponse.result;
|
|
401
|
+
expect(result.admission.decision).toBe(GRANT_DECISION);
|
|
402
|
+
expect(observedTaskId.current).toBe(SESSION_A);
|
|
403
|
+
yield* closeClient(client);
|
|
404
|
+
})));
|
|
405
|
+
effectTest("encodes a registered handler error as a `response` frame with `error`", () => withTestServer(Effect.gen(function* () {
|
|
406
|
+
const server = yield* startDispatchAuthorizeServer(SERVER_ERROR_REQUEST_ID, dispatchRequestParams(SESSION_B));
|
|
407
|
+
const client = makeClient(server.url);
|
|
408
|
+
yield* client.handleServerRpc(DispatchAuthorize, () => Effect.fail(new ForbiddenError({
|
|
409
|
+
message: DOMAIN_REJECTED_MESSAGE,
|
|
410
|
+
data: { reason: DOMAIN_REJECTED_REASON },
|
|
411
|
+
})));
|
|
412
|
+
yield* connectClient(client);
|
|
413
|
+
yield* waitForErrorResponse(server, SERVER_ERROR_REQUEST_ID);
|
|
414
|
+
const found = findResponseRaw(server, SERVER_ERROR_REQUEST_ID);
|
|
415
|
+
const parsed = JSON.parse(found);
|
|
416
|
+
expect(validateResponseFrame(parsed)).toBe(true);
|
|
417
|
+
if (!validateResponseFrame(parsed))
|
|
418
|
+
return;
|
|
419
|
+
expect("error" in parsed).toBe(true);
|
|
420
|
+
if (!("error" in parsed))
|
|
421
|
+
return;
|
|
422
|
+
expect(parsed.error.code).toBe(HANDLER_REJECTION_CODE);
|
|
423
|
+
expect(parsed.error.message).toBe(DOMAIN_REJECTED_MESSAGE);
|
|
424
|
+
expect(parsed.error.data.reason).toBe(DOMAIN_REJECTED_REASON);
|
|
425
|
+
yield* closeClient(client);
|
|
426
|
+
})));
|
|
427
|
+
effectTest("rejects a duplicate handleServerRpc registration with DuplicateServerRpcHandlerError", () => Effect.gen(function* () {
|
|
428
|
+
const client = new MoltZapWsClient({
|
|
429
|
+
serverUrl: "http://127.0.0.1:1",
|
|
430
|
+
agentKey: "test",
|
|
835
431
|
});
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
expect(err.value._tag).toBe("DuplicateServerRpcHandlerError");
|
|
850
|
-
}
|
|
432
|
+
const first = yield* Effect.exit(client.handleServerRpc(DispatchAuthorize, () => Effect.succeed({
|
|
433
|
+
admission: { decision: GRANT_DECISION },
|
|
434
|
+
})));
|
|
435
|
+
expect(Exit.isSuccess(first)).toBe(true);
|
|
436
|
+
const second = yield* Effect.exit(client.handleServerRpc(DispatchAuthorize, () => Effect.succeed({
|
|
437
|
+
admission: { decision: GRANT_DECISION },
|
|
438
|
+
})));
|
|
439
|
+
expect(Exit.isFailure(second)).toBe(true);
|
|
440
|
+
if (Exit.isFailure(second)) {
|
|
441
|
+
const err = Cause.failureOption(second.cause);
|
|
442
|
+
expect(Option.isSome(err)).toBe(true);
|
|
443
|
+
if (Option.isSome(err)) {
|
|
444
|
+
expect(err.value._tag).toBe(DUPLICATE_HANDLER_ERROR_TAG);
|
|
851
445
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
});
|
|
446
|
+
}
|
|
447
|
+
yield* client.close();
|
|
448
|
+
}));
|
|
855
449
|
// ─────────────────────────────────────────────────────────────────────
|
|
856
450
|
// Regression gate (review-295): runSync(client.close()) and
|
|
857
451
|
// runSync(client.disconnect()) must not throw AsyncFiberException after
|
|
@@ -866,26 +460,20 @@ describe("Phase 1.0 (B.1) — handleServerRpc round-trip", () => {
|
|
|
866
460
|
// packages/openclaw-channel/src/__tests__/reconnection.integration.test.ts:14
|
|
867
461
|
// (`closeWs = (c) => Effect.runSync(c.close())`).
|
|
868
462
|
// ─────────────────────────────────────────────────────────────────────
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
expect(() => Effect.runSync(client.disconnect())).not.toThrow();
|
|
886
|
-
// Drain reconnect-fiber + runtime so vitest doesn't flag dangling work.
|
|
887
|
-
yield* closeClient(client);
|
|
888
|
-
}));
|
|
889
|
-
});
|
|
890
|
-
});
|
|
463
|
+
effectTest("client.close() runs synchronously after a successful connect", () => withTestServer(Effect.gen(function* () {
|
|
464
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
465
|
+
const client = makeClient(server.url);
|
|
466
|
+
yield* connectClient(client);
|
|
467
|
+
// Critical: this MUST be runSync, not runPromise. Throwing
|
|
468
|
+
// AsyncFiberException here is the regression we're guarding.
|
|
469
|
+
expect(() => Effect.runSync(client.close())).not.toThrow();
|
|
470
|
+
})));
|
|
471
|
+
effectTest("client.disconnect() runs synchronously after a successful connect", () => withTestServer(Effect.gen(function* () {
|
|
472
|
+
const server = yield* startHandshakingServer(() => Effect.void);
|
|
473
|
+
const client = makeClient(server.url);
|
|
474
|
+
yield* connectClient(client);
|
|
475
|
+
expect(() => Effect.runSync(client.disconnect())).not.toThrow();
|
|
476
|
+
// Drain reconnect-fiber + runtime so vitest doesn't flag dangling work.
|
|
477
|
+
yield* closeClient(client);
|
|
478
|
+
})));
|
|
891
479
|
//# sourceMappingURL=ws-client.test.js.map
|