@animalabs/connectome-host 0.3.5 → 0.3.8
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/.env.example +6 -0
- package/.github/workflows/ci.yml +52 -0
- package/.github/workflows/publish.yml +2 -2
- package/README.md +5 -0
- package/bun.lock +14 -6
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +6 -4
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/framework-agent-config.ts +51 -0
- package/src/framework-strategy.ts +70 -0
- package/src/index.ts +130 -105
- package/src/logging-adapter.ts +105 -12
- package/src/mcpl-config.ts +1 -0
- package/src/modules/channel-mode-module.ts +14 -16
- package/src/modules/fleet-module.ts +17 -7
- package/src/modules/mcpl-admin-module.ts +6 -15
- package/src/modules/settings-module.ts +83 -59
- package/src/modules/subscription-gc-module.ts +17 -20
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +88 -10
- package/src/modules/web-ui-observers.ts +35 -9
- package/src/recipe.ts +133 -6
- package/src/state/agent-tree-reducer.ts +17 -3
- package/src/strategies/frontdesk-strategy.ts +15 -9
- package/src/web/protocol.ts +89 -0
- package/test/agent-tree-reducer.test.ts +35 -3
- package/test/autobio-progress-snapshot.test.ts +27 -12
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/framework-fkm-composition.test.ts +160 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter-refusal.test.ts +57 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-compression-fallback.test.ts +36 -0
- package/test/recipe-primary-summary-fallback.test.ts +40 -0
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-think-policy.test.ts +39 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/subscription-gc-module.test.ts +7 -5
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-observers.test.ts +70 -2
- package/web/package-lock.json +425 -302
- package/web/src/App.tsx +9 -0
- package/web/src/ObserverGate.tsx +21 -11
- package/web/src/Usage.tsx +116 -2
- package/web/src/wire.ts +5 -2
- package/web/bun.lock +0 -357
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
|
|
4
|
+
function recipe(strategy: Record<string, unknown>) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'compression-fallback-test',
|
|
7
|
+
agent: { systemPrompt: 'sys', strategy: { type: 'autobiographical', ...strategy } },
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('compression recall-curve recipe settings', () => {
|
|
12
|
+
test('preserves valid fallback count and complete-request budget', () => {
|
|
13
|
+
const parsed = validateRecipe(recipe({
|
|
14
|
+
compressionRefusalCurveFallbacks: 3,
|
|
15
|
+
compressionContextBudgetTokens: 200_000,
|
|
16
|
+
}));
|
|
17
|
+
expect(parsed.agent.strategy?.compressionRefusalCurveFallbacks).toBe(3);
|
|
18
|
+
expect(parsed.agent.strategy?.compressionContextBudgetTokens).toBe(200_000);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('accepts zero as an explicit fallback disable', () => {
|
|
22
|
+
expect(validateRecipe(recipe({ compressionRefusalCurveFallbacks: 0 }))
|
|
23
|
+
.agent.strategy?.compressionRefusalCurveFallbacks).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('rejects malformed fallback settings', () => {
|
|
27
|
+
expect(() => validateRecipe(recipe({ compressionRefusalCurveFallbacks: -1 })))
|
|
28
|
+
.toThrow(/compressionRefusalCurveFallbacks/);
|
|
29
|
+
expect(() => validateRecipe(recipe({ compressionRefusalCurveFallbacks: 1.5 })))
|
|
30
|
+
.toThrow(/compressionRefusalCurveFallbacks/);
|
|
31
|
+
expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: 0 })))
|
|
32
|
+
.toThrow(/compressionContextBudgetTokens/);
|
|
33
|
+
expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: '200000' })))
|
|
34
|
+
.toThrow(/compressionContextBudgetTokens/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
|
|
4
|
+
function recipe(primarySummaryFallback?: Record<string, unknown>) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'primary-summary-fallback-test',
|
|
7
|
+
agent: {
|
|
8
|
+
systemPrompt: 'sys',
|
|
9
|
+
refusalHandling: primarySummaryFallback
|
|
10
|
+
? { primarySummaryFallback }
|
|
11
|
+
: undefined,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('recipe primary summary fallback validation', () => {
|
|
17
|
+
test('leaves the fallback disabled by default when omitted', () => {
|
|
18
|
+
const parsed = validateRecipe(recipe());
|
|
19
|
+
expect(parsed.agent.refusalHandling?.primarySummaryFallback).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('preserves valid fallback settings', () => {
|
|
23
|
+
const parsed = validateRecipe(recipe({
|
|
24
|
+
enabled: true,
|
|
25
|
+
maxNewSummaries: 4,
|
|
26
|
+
requestBudgetTokens: 216_000,
|
|
27
|
+
}));
|
|
28
|
+
expect(parsed.agent.refusalHandling?.primarySummaryFallback).toEqual({
|
|
29
|
+
enabled: true,
|
|
30
|
+
maxNewSummaries: 4,
|
|
31
|
+
requestBudgetTokens: 216_000,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('rejects malformed fallback settings', () => {
|
|
36
|
+
expect(() => validateRecipe(recipe({ enabled: 'yes' }))).toThrow(/primarySummaryFallback\.enabled/);
|
|
37
|
+
expect(() => validateRecipe(recipe({ maxNewSummaries: -1 }))).toThrow(/maxNewSummaries/);
|
|
38
|
+
expect(() => validateRecipe(recipe({ requestBudgetTokens: 0 }))).toThrow(/requestBudgetTokens/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
|
|
4
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
5
|
+
return { name: 'provider-test', agent: { systemPrompt: 'sys', ...agent } };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('recipe provider validation', () => {
|
|
9
|
+
test('preserves Anthropic as the omitted provider and accepts Responses', () => {
|
|
10
|
+
expect(validateRecipe(recipe()).agent.provider).toBeUndefined();
|
|
11
|
+
expect(validateRecipe(recipe({ provider: 'openai-responses' })).agent.provider)
|
|
12
|
+
.toBe('openai-responses');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('accepts Responses reasoning and compaction settings', () => {
|
|
16
|
+
expect(validateRecipe(recipe({
|
|
17
|
+
provider: 'openai-responses',
|
|
18
|
+
responses: {
|
|
19
|
+
reasoningEffort: 'xhigh',
|
|
20
|
+
reasoningContext: 'all_turns',
|
|
21
|
+
compactThreshold: 100_000,
|
|
22
|
+
},
|
|
23
|
+
})).agent.responses).toEqual({
|
|
24
|
+
reasoningEffort: 'xhigh',
|
|
25
|
+
reasoningContext: 'all_turns',
|
|
26
|
+
compactThreshold: 100_000,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('rejects unknown providers and malformed Responses settings', () => {
|
|
31
|
+
expect(() => validateRecipe(recipe({ provider: 'openai-chat' }))).toThrow(/agent.provider/);
|
|
32
|
+
expect(() => validateRecipe(recipe({ responses: { reasoningEffort: 'ultra' } }))).toThrow(/reasoningEffort/);
|
|
33
|
+
expect(() => validateRecipe(recipe({ responses: { reasoningContext: 'previous_turn' } }))).toThrow(/reasoningContext/);
|
|
34
|
+
expect(() => validateRecipe(recipe({ responses: { compactThreshold: 0 } }))).toThrow(/compactThreshold/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
import { buildFrameworkAgentConfig } from '../src/framework-agent-config.js';
|
|
4
|
+
|
|
5
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
6
|
+
return { name: 'think-policy-test', agent: { systemPrompt: 'sys', ...agent } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('recipe sameRoundThinkTextPolicy', () => {
|
|
10
|
+
test('valid public/private values are preserved and passed through to Agent Framework config', () => {
|
|
11
|
+
const publicRecipe = validateRecipe(recipe({ sameRoundThinkTextPolicy: 'public' }));
|
|
12
|
+
expect(publicRecipe.agent.sameRoundThinkTextPolicy).toBe('public');
|
|
13
|
+
expect(
|
|
14
|
+
buildFrameworkAgentConfig(publicRecipe, 'agent', 'model', undefined).sameRoundThinkTextPolicy,
|
|
15
|
+
).toBe('public');
|
|
16
|
+
|
|
17
|
+
const privateRecipe = validateRecipe(recipe({ sameRoundThinkTextPolicy: 'private' }));
|
|
18
|
+
expect(privateRecipe.agent.sameRoundThinkTextPolicy).toBe('private');
|
|
19
|
+
expect(
|
|
20
|
+
buildFrameworkAgentConfig(privateRecipe, 'agent', 'model', undefined).sameRoundThinkTextPolicy,
|
|
21
|
+
).toBe('private');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('omitted value stays omitted so Agent Framework can report the compatibility source', () => {
|
|
25
|
+
const parsed = validateRecipe(recipe());
|
|
26
|
+
expect(parsed.agent.sameRoundThinkTextPolicy).toBeUndefined();
|
|
27
|
+
const config = buildFrameworkAgentConfig(parsed, 'agent', 'model', undefined);
|
|
28
|
+
expect(Object.prototype.hasOwnProperty.call(config, 'sameRoundThinkTextPolicy')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('invalid strings and types are rejected', () => {
|
|
32
|
+
expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: 'secret' })))
|
|
33
|
+
.toThrow(/sameRoundThinkTextPolicy/);
|
|
34
|
+
expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: true })))
|
|
35
|
+
.toThrow(/sameRoundThinkTextPolicy/);
|
|
36
|
+
expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: { mode: 'public' } })))
|
|
37
|
+
.toThrow(/sameRoundThinkTextPolicy/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
|
|
4
|
+
function recipe(timezone?: unknown) {
|
|
5
|
+
return { name: 'tz', agent: { systemPrompt: 'test', ...(timezone === undefined ? {} : { timezone }) } };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('recipe agent.timezone validation', () => {
|
|
9
|
+
test('accepts an IANA timezone', () => {
|
|
10
|
+
expect(validateRecipe(recipe('America/Los_Angeles')).agent.timezone).toBe('America/Los_Angeles');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('allows the setting to be omitted', () => {
|
|
14
|
+
expect(validateRecipe(recipe()).agent.timezone).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('rejects invalid timezone names', () => {
|
|
18
|
+
expect(() => validateRecipe(recipe('Pacific/Definitely_Not'))).toThrow(/valid IANA time zone/);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -37,6 +37,7 @@ function ambient(
|
|
|
37
37
|
origin: {
|
|
38
38
|
source: 'discord',
|
|
39
39
|
channelId,
|
|
40
|
+
mcplChannelId: `discord:g1:${channelId}`,
|
|
40
41
|
isMention: !!opts.isMention,
|
|
41
42
|
isDM: !!opts.isDM,
|
|
42
43
|
},
|
|
@@ -59,8 +60,9 @@ describe('SubscriptionGcModule', () => {
|
|
|
59
60
|
|
|
60
61
|
r = await m.onProcess(ambient('c1', 'ghijkl'), PS); // +6 = 12 > 10 → unsub
|
|
61
62
|
expect(toolCalls.length).toBe(1);
|
|
62
|
-
expect(toolCalls[0].name).toBe('
|
|
63
|
-
expect(toolCalls[0].input.channelId).toBe('c1');
|
|
63
|
+
expect(toolCalls[0].name).toBe('channel_close');
|
|
64
|
+
expect(toolCalls[0].input.channelId).toBe('discord:g1:c1');
|
|
65
|
+
expect(toolCalls[0].input.serverId).toBe('discord');
|
|
64
66
|
expect(r.addMessages?.length).toBe(1);
|
|
65
67
|
|
|
66
68
|
await m.stop();
|
|
@@ -100,7 +102,7 @@ describe('SubscriptionGcModule', () => {
|
|
|
100
102
|
await m.handleToolCall({
|
|
101
103
|
id: 't1',
|
|
102
104
|
name: 'set_channel_idle_limit',
|
|
103
|
-
input: { channelId: 'c1', limit: 'off' },
|
|
105
|
+
input: { channelId: 'discord:g1:c1', limit: 'off' },
|
|
104
106
|
});
|
|
105
107
|
await m.onProcess(ambient('c1', 'waytoolongambient'), PS);
|
|
106
108
|
expect(toolCalls.length).toBe(0); // pinned → never unsubscribed
|
|
@@ -108,7 +110,7 @@ describe('SubscriptionGcModule', () => {
|
|
|
108
110
|
// A different channel still accrues, and state is persisted.
|
|
109
111
|
await m.onProcess(ambient('c2', 'abc'), PS);
|
|
110
112
|
const persisted = getState() as { overrides: Record<string, unknown>; counters: Record<string, number> };
|
|
111
|
-
expect(persisted.overrides
|
|
113
|
+
expect(persisted.overrides['discord:g1:c1']).toBe('off');
|
|
112
114
|
|
|
113
115
|
// Simulate restart: a new module loads the persisted state (counters carry
|
|
114
116
|
// across — a restart is not an activation).
|
|
@@ -128,7 +130,7 @@ describe('SubscriptionGcModule', () => {
|
|
|
128
130
|
// c2 was at 3; +3 = 6 > 5 → unsubscribe (counter survived the "restart")
|
|
129
131
|
await m2.onProcess(ambient('c2', 'def'), PS);
|
|
130
132
|
expect(restart.toolCalls.length).toBe(1);
|
|
131
|
-
expect(restart.toolCalls[0].input.channelId).toBe('c2');
|
|
133
|
+
expect(restart.toolCalls[0].input.channelId).toBe('discord:g1:c2');
|
|
132
134
|
|
|
133
135
|
await m.stop();
|
|
134
136
|
await m2.stop();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { formatNow } from '../src/modules/time-module.js';
|
|
3
|
+
|
|
4
|
+
describe('TimeModule presentation timezone', () => {
|
|
5
|
+
test('formats current time independently of the host timezone', () => {
|
|
6
|
+
expect(formatNow(new Date('2026-07-15T12:34:56.789Z'), 'America/Los_Angeles')).toEqual({
|
|
7
|
+
iso: '2026-07-15T05:34:56.789-07:00',
|
|
8
|
+
local: '2026-07-15T05:34:56.789-07:00 [America/Los_Angeles]',
|
|
9
|
+
timezone: 'America/Los_Angeles',
|
|
10
|
+
unixMs: 1_784_118_896_789,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -174,17 +174,65 @@ describe('scope filters', () => {
|
|
|
174
174
|
expect(w.history.startIndex).toBe(240);
|
|
175
175
|
expect(w.usage.input).toBe(1); // structure/usage survive
|
|
176
176
|
});
|
|
177
|
+
|
|
178
|
+
// Health-gated telemetry must be masked exactly like its live frames:
|
|
179
|
+
// `usage` and `call-ledger` pushes require the 'health' scope, so the
|
|
180
|
+
// welcome cannot hand the same data to a messages-only observer.
|
|
181
|
+
test('scopeWelcome without health strips callLedger/perAgentCost and zeroes usage', () => {
|
|
182
|
+
const welcome = {
|
|
183
|
+
type: 'welcome', protocolVersion: 1,
|
|
184
|
+
messages: [entry],
|
|
185
|
+
history: { startIndex: 40, totalCount: 240 },
|
|
186
|
+
localTree: { asOfTs: 1, nodes: [], callIdIndex: {} },
|
|
187
|
+
childTrees: [],
|
|
188
|
+
usage: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, cost: { total: 5, currency: 'USD' } },
|
|
189
|
+
perAgentCost: [{ name: 'main', usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }],
|
|
190
|
+
callLedger: {
|
|
191
|
+
calls: [{ model: 'secret-model', error: 'raw provider error', costUsd: 0.42 }],
|
|
192
|
+
},
|
|
193
|
+
} as unknown as WelcomeMessage;
|
|
194
|
+
|
|
195
|
+
const w = scopeWelcome(welcome, new Set<ObserverScope>(['messages']));
|
|
196
|
+
expect(w.callLedger).toBeUndefined();
|
|
197
|
+
expect(w.perAgentCost).toBeUndefined();
|
|
198
|
+
expect(w.usage).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
199
|
+
expect(w.messages.length).toBe(1); // messages scope still gets content
|
|
200
|
+
// No trace of the ledger anywhere in the serialized frame.
|
|
201
|
+
expect(JSON.stringify(w)).not.toContain('secret-model');
|
|
202
|
+
expect(JSON.stringify(w)).not.toContain('raw provider error');
|
|
203
|
+
|
|
204
|
+
// Same masking on the no-messages branch (e.g. ops-only observer).
|
|
205
|
+
const opsOnly = scopeWelcome(welcome, new Set<ObserverScope>(['ops']));
|
|
206
|
+
expect(opsOnly.callLedger).toBeUndefined();
|
|
207
|
+
expect(opsOnly.perAgentCost).toBeUndefined();
|
|
208
|
+
expect(opsOnly.usage).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
209
|
+
|
|
210
|
+
// A health-scoped observer keeps the telemetry verbatim.
|
|
211
|
+
const h = scopeWelcome(welcome, new Set<ObserverScope>(['health']));
|
|
212
|
+
expect(h.callLedger).toEqual(welcome.callLedger);
|
|
213
|
+
expect(h.perAgentCost).toEqual(welcome.perAgentCost);
|
|
214
|
+
expect(h.usage).toEqual(welcome.usage);
|
|
215
|
+
|
|
216
|
+
// Input welcome is never mutated (shared across clients in sendWelcome).
|
|
217
|
+
expect(welcome.callLedger).toBeDefined();
|
|
218
|
+
expect(welcome.usage.input).toBe(1);
|
|
219
|
+
});
|
|
177
220
|
});
|
|
178
221
|
|
|
179
222
|
describe('ObserverSessions', () => {
|
|
180
|
-
test('mint/lookup round-trip; bad token null', () => {
|
|
223
|
+
test('mint/lookup round-trip; bad token null; full flag carried', () => {
|
|
181
224
|
const s = new ObserverSessions();
|
|
182
225
|
const scopes = new Set<ObserverScope>(['health']);
|
|
183
226
|
const token = s.mint(scopes);
|
|
184
227
|
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
|
185
|
-
expect(s.lookup(token)).toBe(scopes);
|
|
228
|
+
expect(s.lookup(token)!.scopes).toBe(scopes);
|
|
229
|
+
expect(s.lookup(token)!.full).toBe(false);
|
|
186
230
|
expect(s.lookup('0'.repeat(64))).toBeNull();
|
|
187
231
|
expect(s.lookup(null)).toBeNull();
|
|
232
|
+
|
|
233
|
+
// Full sessions (password sign-in path) carry operator authority.
|
|
234
|
+
const fullToken = s.mint(new Set(), { full: true });
|
|
235
|
+
expect(s.lookup(fullToken)!.full).toBe(true);
|
|
188
236
|
});
|
|
189
237
|
});
|
|
190
238
|
|
|
@@ -289,6 +337,26 @@ describe('WebUiModule observer flow (e2e)', () => {
|
|
|
289
337
|
ws.close();
|
|
290
338
|
}, 15_000);
|
|
291
339
|
|
|
340
|
+
test('password sign-in mints a full-session cookie the WS upgrade honors', async () => {
|
|
341
|
+
// /auth/basic with valid credentials → 302 + full-session cookie.
|
|
342
|
+
const res = await fetch(`${base()}/auth/basic`, { headers: { authorization: BASIC }, redirect: 'manual' });
|
|
343
|
+
expect(res.status).toBe(302);
|
|
344
|
+
const m = /fkm_obs=([a-f0-9]{64})/.exec(res.headers.get('set-cookie') ?? '');
|
|
345
|
+
expect(m).not.toBeNull();
|
|
346
|
+
|
|
347
|
+
// A WS upgrade carrying ONLY the cookie (no Authorization — the Chrome
|
|
348
|
+
// situation) must be treated as a FULL client: no observer-auth-required
|
|
349
|
+
// demand. (Full clients are parked silently in this app-less harness;
|
|
350
|
+
// the observer path would send auth-required immediately.)
|
|
351
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers: { cookie: `fkm_obs=${m![1]}` } } as never);
|
|
352
|
+
const firstFrame = await Promise.race([
|
|
353
|
+
new Promise<string>((r) => ws.addEventListener('message', (ev) => r(String((ev as MessageEvent).data)))),
|
|
354
|
+
new Promise<string>((r) => setTimeout(() => r('SILENCE'), 1500)),
|
|
355
|
+
]);
|
|
356
|
+
ws.close();
|
|
357
|
+
expect(firstFrame).toBe('SILENCE'); // not observer-auth-required
|
|
358
|
+
}, 10_000);
|
|
359
|
+
|
|
292
360
|
test('wrong key: hello rejected and socket closed', async () => {
|
|
293
361
|
const stranger = makeKeypair();
|
|
294
362
|
const host = `127.0.0.1:${port}`;
|