@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.3
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/package.json +4 -1
- package/src/transports/vault.ts +19 -1
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2012
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/tsconfig.json +0 -21
package/src/registry.test.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
import {
|
|
6
|
-
resolveChannelEntries,
|
|
7
|
-
instantiateTransport,
|
|
8
|
-
loadRegistry,
|
|
9
|
-
defaultStateDir,
|
|
10
|
-
type ChannelEntry,
|
|
11
|
-
} from "./registry.ts";
|
|
12
|
-
|
|
13
|
-
let dir: string;
|
|
14
|
-
const savedToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
dir = mkdtempSync(join(tmpdir(), "channel-registry-"));
|
|
18
|
-
delete process.env.TELEGRAM_BOT_TOKEN;
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
rmSync(dir, { recursive: true, force: true });
|
|
23
|
-
if (savedToken === undefined) delete process.env.TELEGRAM_BOT_TOKEN;
|
|
24
|
-
else process.env.TELEGRAM_BOT_TOKEN = savedToken;
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe("no env synthesis — channels are always explicit", () => {
|
|
28
|
-
test("no channels.json + TELEGRAM_BOT_TOKEN set → [] (env is NOT a token source)", () => {
|
|
29
|
-
process.env.TELEGRAM_BOT_TOKEN = "dummy-token";
|
|
30
|
-
const entries = resolveChannelEntries({ stateDir: dir, loadEnv: false });
|
|
31
|
-
expect(entries).toEqual([]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("no channels.json + token only in state-dir .env → still [] after loadEnv", () => {
|
|
35
|
-
// .env is still loaded (generic vars may live there), but a TELEGRAM_BOT_TOKEN
|
|
36
|
-
// there no longer synthesizes a channel — per-channel config is required.
|
|
37
|
-
writeFileSync(join(dir, ".env"), "TELEGRAM_BOT_TOKEN=from-env-file\n");
|
|
38
|
-
const entries = resolveChannelEntries({ stateDir: dir, loadEnv: true });
|
|
39
|
-
expect(entries).toEqual([]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("no channels.json + no token → empty list (caller decides to error)", () => {
|
|
43
|
-
const entries = resolveChannelEntries({ stateDir: dir, loadEnv: false });
|
|
44
|
-
expect(entries).toEqual([]);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("explicit channels.json parsing", () => {
|
|
49
|
-
test("parses multiple named channels", () => {
|
|
50
|
-
const file = {
|
|
51
|
-
channels: [
|
|
52
|
-
{ name: "tele-aaron", transport: "telegram", config: { token: "t1" } },
|
|
53
|
-
{ name: "ops", transport: "telegram", config: { token: "t2" } },
|
|
54
|
-
],
|
|
55
|
-
};
|
|
56
|
-
writeFileSync(join(dir, "channels.json"), JSON.stringify(file));
|
|
57
|
-
const entries = resolveChannelEntries({ stateDir: dir, loadEnv: false });
|
|
58
|
-
expect(entries).toHaveLength(2);
|
|
59
|
-
expect(entries[0]!.name).toBe("tele-aaron");
|
|
60
|
-
expect(entries[1]!.transport).toBe("telegram");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("channels.json takes precedence over the token fallback", () => {
|
|
64
|
-
process.env.TELEGRAM_BOT_TOKEN = "dummy";
|
|
65
|
-
writeFileSync(
|
|
66
|
-
join(dir, "channels.json"),
|
|
67
|
-
JSON.stringify({ channels: [{ name: "explicit", transport: "telegram", config: { token: "x" } }] }),
|
|
68
|
-
);
|
|
69
|
-
const entries = resolveChannelEntries({ stateDir: dir, loadEnv: false });
|
|
70
|
-
expect(entries).toEqual([{ name: "explicit", transport: "telegram", config: { token: "x" } }]);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("missing 'channels' array throws clearly", () => {
|
|
74
|
-
writeFileSync(join(dir, "channels.json"), JSON.stringify({ nope: true }));
|
|
75
|
-
expect(() => resolveChannelEntries({ stateDir: dir, loadEnv: false })).toThrow(/channels/);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("entry missing name or transport throws clearly", () => {
|
|
79
|
-
writeFileSync(join(dir, "channels.json"), JSON.stringify({ channels: [{ name: "x" }] }));
|
|
80
|
-
expect(() => resolveChannelEntries({ stateDir: dir, loadEnv: false })).toThrow(/transport/);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe("instantiateTransport", () => {
|
|
85
|
-
test("telegram entry yields a telegram transport", () => {
|
|
86
|
-
const entry: ChannelEntry = { name: "t", transport: "telegram", config: { token: "tok" } };
|
|
87
|
-
const t = instantiateTransport(entry);
|
|
88
|
-
expect(t.kind).toBe("telegram");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("unknown transport kind errors clearly", () => {
|
|
92
|
-
const entry: ChannelEntry = { name: "weird", transport: "carrier-pigeon" };
|
|
93
|
-
expect(() => instantiateTransport(entry)).toThrow(/unknown transport kind "carrier-pigeon"/);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("telegram entry with no token errors clearly (names the channel)", () => {
|
|
97
|
-
const entry: ChannelEntry = { name: "t", transport: "telegram" };
|
|
98
|
-
expect(() => instantiateTransport(entry)).toThrow(
|
|
99
|
-
/telegram channel t requires a per-channel bot token/,
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("loadRegistry", () => {
|
|
105
|
-
test("builds a live channel map keyed by name", () => {
|
|
106
|
-
writeFileSync(
|
|
107
|
-
join(dir, "channels.json"),
|
|
108
|
-
JSON.stringify({
|
|
109
|
-
channels: [
|
|
110
|
-
{ name: "a", transport: "telegram", config: { token: "t1", stateDir: dir } },
|
|
111
|
-
{ name: "b", transport: "telegram", config: { token: "t2", stateDir: dir } },
|
|
112
|
-
],
|
|
113
|
-
}),
|
|
114
|
-
);
|
|
115
|
-
const reg = loadRegistry({ stateDir: dir, loadEnv: false });
|
|
116
|
-
expect([...reg.keys()].sort()).toEqual(["a", "b"]);
|
|
117
|
-
expect(reg.get("a")!.transport.kind).toBe("telegram");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("duplicate channel names throw", () => {
|
|
121
|
-
writeFileSync(
|
|
122
|
-
join(dir, "channels.json"),
|
|
123
|
-
JSON.stringify({
|
|
124
|
-
channels: [
|
|
125
|
-
{ name: "dup", transport: "telegram", config: { token: "t1", stateDir: dir } },
|
|
126
|
-
{ name: "dup", transport: "telegram", config: { token: "t2", stateDir: dir } },
|
|
127
|
-
],
|
|
128
|
-
}),
|
|
129
|
-
);
|
|
130
|
-
expect(() => loadRegistry({ stateDir: dir, loadEnv: false })).toThrow(/duplicate channel name/);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("unknown transport kind surfaces from loadRegistry", () => {
|
|
134
|
-
writeFileSync(
|
|
135
|
-
join(dir, "channels.json"),
|
|
136
|
-
JSON.stringify({ channels: [{ name: "x", transport: "smoke-signal" }] }),
|
|
137
|
-
);
|
|
138
|
-
expect(() => loadRegistry({ stateDir: dir, loadEnv: false })).toThrow(/unknown transport kind/);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// ===========================================================================
|
|
143
|
-
// defaultStateDir — channel→agent rename + back-compat resolution.
|
|
144
|
-
//
|
|
145
|
-
// Resolution order (registry.ts):
|
|
146
|
-
// 1. PARACHUTE_AGENT_STATE_DIR env → legacy PARACHUTE_CHANNEL_STATE_DIR (via
|
|
147
|
-
// env-compat.agentEnv) — explicit override.
|
|
148
|
-
// 2. ~/.parachute/agent (the NEW default) if it exists.
|
|
149
|
-
// 3. Back-compat: ~/.parachute/channel (legacy) if it exists and agent does not.
|
|
150
|
-
// 4. ~/.parachute/agent (the new default) when neither exists.
|
|
151
|
-
//
|
|
152
|
-
// `defaultStateDir(home?)` takes an injectable home so we exercise the
|
|
153
|
-
// home-dir-default + legacy-fallback branches against a throwaway dir WITHOUT a
|
|
154
|
-
// process-wide `mock.module("os")` — which would leak a faked `homedir()` into
|
|
155
|
-
// other test files (it broke the live-Seatbelt path-confinement tests when an
|
|
156
|
-
// earlier version did exactly that).
|
|
157
|
-
// ===========================================================================
|
|
158
|
-
|
|
159
|
-
describe("defaultStateDir — env override (new + legacy)", () => {
|
|
160
|
-
const savedAgent = process.env.PARACHUTE_AGENT_STATE_DIR;
|
|
161
|
-
const savedChannel = process.env.PARACHUTE_CHANNEL_STATE_DIR;
|
|
162
|
-
afterEach(() => {
|
|
163
|
-
if (savedAgent === undefined) delete process.env.PARACHUTE_AGENT_STATE_DIR;
|
|
164
|
-
else process.env.PARACHUTE_AGENT_STATE_DIR = savedAgent;
|
|
165
|
-
if (savedChannel === undefined) delete process.env.PARACHUTE_CHANNEL_STATE_DIR;
|
|
166
|
-
else process.env.PARACHUTE_CHANNEL_STATE_DIR = savedChannel;
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("PARACHUTE_AGENT_STATE_DIR (the new var) wins", () => {
|
|
170
|
-
delete process.env.PARACHUTE_CHANNEL_STATE_DIR;
|
|
171
|
-
process.env.PARACHUTE_AGENT_STATE_DIR = "/tmp/explicit-agent-state";
|
|
172
|
-
expect(defaultStateDir()).toBe("/tmp/explicit-agent-state");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("back-compat: legacy PARACHUTE_CHANNEL_STATE_DIR is still honored when the new var is unset", () => {
|
|
176
|
-
delete process.env.PARACHUTE_AGENT_STATE_DIR;
|
|
177
|
-
process.env.PARACHUTE_CHANNEL_STATE_DIR = "/tmp/legacy-channel-state";
|
|
178
|
-
expect(defaultStateDir()).toBe("/tmp/legacy-channel-state");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("the new PARACHUTE_AGENT_STATE_DIR takes precedence over the legacy one", () => {
|
|
182
|
-
process.env.PARACHUTE_AGENT_STATE_DIR = "/tmp/new-wins";
|
|
183
|
-
process.env.PARACHUTE_CHANNEL_STATE_DIR = "/tmp/legacy-loses";
|
|
184
|
-
expect(defaultStateDir()).toBe("/tmp/new-wins");
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe("defaultStateDir — home-dir default + legacy-dir back-compat", () => {
|
|
189
|
-
let home: string;
|
|
190
|
-
const savedAgent = process.env.PARACHUTE_AGENT_STATE_DIR;
|
|
191
|
-
const savedChannel = process.env.PARACHUTE_CHANNEL_STATE_DIR;
|
|
192
|
-
|
|
193
|
-
beforeEach(() => {
|
|
194
|
-
// A throwaway HOME passed straight to defaultStateDir(home); no env override so
|
|
195
|
-
// the existence checks drive resolution. No os mock — see the note above.
|
|
196
|
-
home = mkdtempSync(join(tmpdir(), "agent-home-"));
|
|
197
|
-
delete process.env.PARACHUTE_AGENT_STATE_DIR;
|
|
198
|
-
delete process.env.PARACHUTE_CHANNEL_STATE_DIR;
|
|
199
|
-
});
|
|
200
|
-
afterEach(() => {
|
|
201
|
-
rmSync(home, { recursive: true, force: true });
|
|
202
|
-
if (savedAgent === undefined) delete process.env.PARACHUTE_AGENT_STATE_DIR;
|
|
203
|
-
else process.env.PARACHUTE_AGENT_STATE_DIR = savedAgent;
|
|
204
|
-
if (savedChannel === undefined) delete process.env.PARACHUTE_CHANNEL_STATE_DIR;
|
|
205
|
-
else process.env.PARACHUTE_CHANNEL_STATE_DIR = savedChannel;
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test("the new default is ~/.parachute/agent when neither dir exists", () => {
|
|
209
|
-
expect(defaultStateDir(home)).toBe(join(home, ".parachute", "agent"));
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("uses the new ~/.parachute/agent when it exists", () => {
|
|
213
|
-
mkdirSync(join(home, ".parachute", "agent"), { recursive: true });
|
|
214
|
-
expect(defaultStateDir(home)).toBe(join(home, ".parachute", "agent"));
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("back-compat: uses legacy ~/.parachute/channel when ~/.parachute/agent is absent", () => {
|
|
218
|
-
mkdirSync(join(home, ".parachute", "channel"), { recursive: true });
|
|
219
|
-
expect(defaultStateDir(home)).toBe(join(home, ".parachute", "channel"));
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("the new agent dir wins even when the legacy channel dir also exists", () => {
|
|
223
|
-
mkdirSync(join(home, ".parachute", "agent"), { recursive: true });
|
|
224
|
-
mkdirSync(join(home, ".parachute", "channel"), { recursive: true });
|
|
225
|
-
expect(defaultStateDir(home)).toBe(join(home, ".parachute", "agent"));
|
|
226
|
-
});
|
|
227
|
-
});
|
package/src/resolve-port.test.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Port-resolution tests (channel#41).
|
|
3
|
-
*
|
|
4
|
-
* The hub supervisor injects `PORT` from the module's services.json `entry.port`
|
|
5
|
-
* and PROBES that same port for readiness (and reverse-proxies `/agent/*` to
|
|
6
|
-
* it). Pre-#41 the daemon read only `PARACHUTE_AGENT_PORT` (default 1941), so
|
|
7
|
-
* it ignored the supervisor's `PORT` and could bind a different port than the
|
|
8
|
-
* supervisor probed — the supervisor then reported `started_but_unbound` and the
|
|
9
|
-
* proxy routed to a dead port. `resolvePort` now honors `PORT` first so the bound
|
|
10
|
-
* port (which is also the self-registered port) matches what the supervisor
|
|
11
|
-
* assigned.
|
|
12
|
-
*
|
|
13
|
-
* Resolution order: `PORT > PARACHUTE_AGENT_PORT > PARACHUTE_CHANNEL_PORT > 1941`.
|
|
14
|
-
* The legacy `PARACHUTE_CHANNEL_PORT` stays a recognized fallback (back-compat
|
|
15
|
-
* for pre-rename operator setups) but the new `PARACHUTE_AGENT_PORT` wins over it.
|
|
16
|
-
*/
|
|
17
|
-
import { describe, test, expect } from "bun:test";
|
|
18
|
-
import { resolvePort } from "./daemon.ts";
|
|
19
|
-
|
|
20
|
-
describe("resolvePort — PORT > PARACHUTE_AGENT_PORT > PARACHUTE_CHANNEL_PORT > 1941", () => {
|
|
21
|
-
test("honors the supervisor-injected PORT first", () => {
|
|
22
|
-
expect(resolvePort({ PORT: "19415" })).toBe(19415);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("PORT wins even when PARACHUTE_AGENT_PORT is also set", () => {
|
|
26
|
-
expect(resolvePort({ PORT: "1941", PARACHUTE_AGENT_PORT: "19415" })).toBe(1941);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("falls back to PARACHUTE_AGENT_PORT when PORT is unset", () => {
|
|
30
|
-
expect(resolvePort({ PARACHUTE_AGENT_PORT: "2025" })).toBe(2025);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("the legacy PARACHUTE_CHANNEL_PORT STILL works as a fallback (back-compat)", () => {
|
|
34
|
-
// Pre-rename operator setups exported PARACHUTE_CHANNEL_PORT; it stays a
|
|
35
|
-
// recognized tier so those installs keep binding the intended port.
|
|
36
|
-
expect(resolvePort({ PARACHUTE_CHANNEL_PORT: "2030" })).toBe(2030);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("PARACHUTE_AGENT_PORT wins over the legacy PARACHUTE_CHANNEL_PORT", () => {
|
|
40
|
-
// Both present → the NEW var takes precedence; the legacy var is only a
|
|
41
|
-
// fallback for when the new one is absent.
|
|
42
|
-
expect(resolvePort({ PARACHUTE_AGENT_PORT: "2040", PARACHUTE_CHANNEL_PORT: "2050" })).toBe(2040);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("falls back to the canonical 1941 default when none is set", () => {
|
|
46
|
-
expect(resolvePort({})).toBe(1941);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("an EMPTY PORT='' falls through to PARACHUTE_AGENT_PORT (|| not ??)", () => {
|
|
50
|
-
// With `??` an empty string is "defined" → parseInt("") = NaN → bind port 0.
|
|
51
|
-
// `||` skips the empty string to the next tier.
|
|
52
|
-
expect(resolvePort({ PORT: "", PARACHUTE_AGENT_PORT: "2000" })).toBe(2000);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("an EMPTY PARACHUTE_AGENT_PORT='' falls through to the legacy PARACHUTE_CHANNEL_PORT", () => {
|
|
56
|
-
expect(resolvePort({ PARACHUTE_AGENT_PORT: "", PARACHUTE_CHANNEL_PORT: "2060" })).toBe(2060);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("a non-numeric PORT='abc' falls through to the canonical default", () => {
|
|
60
|
-
// parseInt("abc") = NaN → falsy → falls through past PARACHUTE_AGENT_PORT +
|
|
61
|
-
// PARACHUTE_CHANNEL_PORT (also unset here) to 1941. No garbage port.
|
|
62
|
-
expect(resolvePort({ PORT: "abc" })).toBe(1941);
|
|
63
|
-
});
|
|
64
|
-
});
|
package/src/routing.test.ts
DELETED
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { ClientRegistry, sseFrame, type ClientSink } from "./routing.ts";
|
|
3
|
-
|
|
4
|
-
/** A fake sink that records every payload pushed to it. */
|
|
5
|
-
function fakeClient(channel: string): ClientSink & { received: string[] } {
|
|
6
|
-
const received: string[] = [];
|
|
7
|
-
return {
|
|
8
|
-
channel,
|
|
9
|
-
received,
|
|
10
|
-
enqueue(payload: string) {
|
|
11
|
-
received.push(payload);
|
|
12
|
-
},
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe("sseFrame", () => {
|
|
17
|
-
test("serializes an event + JSON data block", () => {
|
|
18
|
-
expect(sseFrame("message", { a: 1 })).toBe('event: message\ndata: {"a":1}\n\n');
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("ClientRegistry routing", () => {
|
|
23
|
-
test("delivers to subscribers of the channel and NOT others", () => {
|
|
24
|
-
const reg = new ClientRegistry();
|
|
25
|
-
const a1 = fakeClient("A");
|
|
26
|
-
const a2 = fakeClient("A");
|
|
27
|
-
const b1 = fakeClient("B");
|
|
28
|
-
reg.add("a1", a1);
|
|
29
|
-
reg.add("a2", a2);
|
|
30
|
-
reg.add("b1", b1);
|
|
31
|
-
|
|
32
|
-
const delivered = reg.routeToChannel("A", "message", { content: "hi" });
|
|
33
|
-
|
|
34
|
-
expect(delivered).toBe(2);
|
|
35
|
-
expect(a1.received).toHaveLength(1);
|
|
36
|
-
expect(a2.received).toHaveLength(1);
|
|
37
|
-
expect(b1.received).toHaveLength(0); // the core property: B never sees A's message
|
|
38
|
-
expect(a1.received[0]).toContain('"content":"hi"');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("emitting on B does not reach A", () => {
|
|
42
|
-
const reg = new ClientRegistry();
|
|
43
|
-
const a1 = fakeClient("A");
|
|
44
|
-
const b1 = fakeClient("B");
|
|
45
|
-
reg.add("a1", a1);
|
|
46
|
-
reg.add("b1", b1);
|
|
47
|
-
|
|
48
|
-
reg.routeToChannel("B", "message", { content: "for-b" });
|
|
49
|
-
|
|
50
|
-
expect(b1.received).toHaveLength(1);
|
|
51
|
-
expect(a1.received).toHaveLength(0);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("routing to an unsubscribed channel delivers to nobody", () => {
|
|
55
|
-
const reg = new ClientRegistry();
|
|
56
|
-
reg.add("a1", fakeClient("A"));
|
|
57
|
-
expect(reg.routeToChannel("ghost", "message", {})).toBe(0);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("countForChannel + subscribedChannels reflect subscriptions", () => {
|
|
61
|
-
const reg = new ClientRegistry();
|
|
62
|
-
reg.add("a1", fakeClient("A"));
|
|
63
|
-
reg.add("a2", fakeClient("A"));
|
|
64
|
-
reg.add("b1", fakeClient("B"));
|
|
65
|
-
expect(reg.size).toBe(3);
|
|
66
|
-
expect(reg.countForChannel("A")).toBe(2);
|
|
67
|
-
expect(reg.countForChannel("B")).toBe(1);
|
|
68
|
-
expect(reg.countForChannel("C")).toBe(0);
|
|
69
|
-
expect(reg.subscribedChannels().sort()).toEqual(["A", "B"]);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("a throwing client is dropped and does not block delivery to others", () => {
|
|
73
|
-
const reg = new ClientRegistry();
|
|
74
|
-
const good = fakeClient("A");
|
|
75
|
-
const bad: ClientSink = {
|
|
76
|
-
channel: "A",
|
|
77
|
-
enqueue() {
|
|
78
|
-
throw new Error("stream closed");
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
reg.add("good", good);
|
|
82
|
-
reg.add("bad", bad);
|
|
83
|
-
|
|
84
|
-
const delivered = reg.routeToChannel("A", "message", { content: "x" });
|
|
85
|
-
|
|
86
|
-
expect(delivered).toBe(1); // only the good client counted
|
|
87
|
-
expect(good.received).toHaveLength(1);
|
|
88
|
-
expect(reg.has("bad")).toBe(false); // bad client evicted
|
|
89
|
-
expect(reg.has("good")).toBe(true);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("remove unsubscribes a client", () => {
|
|
93
|
-
const reg = new ClientRegistry();
|
|
94
|
-
const a1 = fakeClient("A");
|
|
95
|
-
reg.add("a1", a1);
|
|
96
|
-
reg.remove("a1");
|
|
97
|
-
reg.routeToChannel("A", "message", {});
|
|
98
|
-
expect(a1.received).toHaveLength(0);
|
|
99
|
-
expect(reg.size).toBe(0);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe("ClientRegistry live SSE integration", () => {
|
|
104
|
-
test("two SSE clients on different channels each get only their channel", async () => {
|
|
105
|
-
const reg = new ClientRegistry();
|
|
106
|
-
|
|
107
|
-
const server = Bun.serve({
|
|
108
|
-
port: 0,
|
|
109
|
-
hostname: "127.0.0.1",
|
|
110
|
-
idleTimeout: 0,
|
|
111
|
-
fetch(req) {
|
|
112
|
-
const url = new URL(req.url);
|
|
113
|
-
if (url.pathname === "/events") {
|
|
114
|
-
const channel = url.searchParams.get("channel") ?? "default";
|
|
115
|
-
const id = crypto.randomUUID();
|
|
116
|
-
const stream = new ReadableStream<string>({
|
|
117
|
-
start(controller) {
|
|
118
|
-
reg.add(id, { channel, enqueue: (p) => controller.enqueue(p) });
|
|
119
|
-
controller.enqueue(": connected\n\n");
|
|
120
|
-
},
|
|
121
|
-
cancel() {
|
|
122
|
-
reg.remove(id);
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
return new Response(stream, {
|
|
126
|
-
headers: { "content-type": "text/event-stream" },
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
return new Response("not found", { status: 404 });
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
const base = `http://127.0.0.1:${server.port}`;
|
|
134
|
-
|
|
135
|
-
async function openAndRead(channel: string): Promise<{
|
|
136
|
-
read: () => Promise<string>;
|
|
137
|
-
cancel: () => void;
|
|
138
|
-
}> {
|
|
139
|
-
const res = await fetch(`${base}/events?channel=${channel}`);
|
|
140
|
-
const reader = res.body!.getReader();
|
|
141
|
-
const decoder = new TextDecoder();
|
|
142
|
-
return {
|
|
143
|
-
async read() {
|
|
144
|
-
// Read until we get a non-comment data frame.
|
|
145
|
-
while (true) {
|
|
146
|
-
const { value, done } = await reader.read();
|
|
147
|
-
if (done) return "";
|
|
148
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
149
|
-
if (chunk.includes("event:")) return chunk;
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
cancel() {
|
|
153
|
-
reader.cancel().catch(() => {});
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const a = await openAndRead("A");
|
|
159
|
-
const b = await openAndRead("B");
|
|
160
|
-
|
|
161
|
-
// Wait until both clients have registered.
|
|
162
|
-
const start = Date.now();
|
|
163
|
-
while (reg.size < 2 && Date.now() - start < 1000) {
|
|
164
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
165
|
-
}
|
|
166
|
-
expect(reg.size).toBe(2);
|
|
167
|
-
|
|
168
|
-
// Emit only on A.
|
|
169
|
-
reg.routeToChannel("A", "message", { content: "only-A" });
|
|
170
|
-
|
|
171
|
-
const aFrame = await a.read();
|
|
172
|
-
expect(aFrame).toContain("only-A");
|
|
173
|
-
|
|
174
|
-
// B should receive nothing on channel A; emit on B to prove B's stream is live.
|
|
175
|
-
reg.routeToChannel("B", "message", { content: "only-B" });
|
|
176
|
-
const bFrame = await b.read();
|
|
177
|
-
expect(bFrame).toContain("only-B");
|
|
178
|
-
expect(bFrame).not.toContain("only-A");
|
|
179
|
-
|
|
180
|
-
a.cancel();
|
|
181
|
-
b.cancel();
|
|
182
|
-
server.stop(true);
|
|
183
|
-
});
|
|
184
|
-
});
|