@harness-fe/mcp-server 3.0.1 → 3.1.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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * End-to-end tests for the MCP tool layer added in the multi-tab observability
3
+ * sweep. Uses InMemoryTransport.createLinkedPair() to run a real McpServer +
4
+ * Client in-process — exercising the registerTool → handler → bridge.sendCommand
5
+ * (or store call) → response round-trip without stdio overhead.
6
+ *
7
+ * The bridge has a real JsonlStore on tmpdir + a real `ws` runtime-client
8
+ * peer so visitor.timeline and the *.tail tools have something to read.
9
+ */
10
+
11
+ import { afterEach, describe, expect, it } from 'vitest';
12
+ import { WebSocket } from 'ws';
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
18
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
19
+ import { Bridge } from './bridge.js';
20
+ import { JsonlStore } from './store/index.js';
21
+ import { createMcpServer } from './mcp.js';
22
+ import type {
23
+ EventFrame,
24
+ HelloAckFrame,
25
+ NetworkEntry,
26
+ StorageEntry,
27
+ WsEntry,
28
+ } from '@harness-fe/protocol';
29
+
30
+ interface TestEnv {
31
+ bridge: Bridge;
32
+ store: JsonlStore;
33
+ dir: string;
34
+ port: number;
35
+ client: Client;
36
+ teardown: () => Promise<void>;
37
+ }
38
+
39
+ const envs: TestEnv[] = [];
40
+
41
+ async function setup(): Promise<TestEnv> {
42
+ const dir = mkdtempSync(join(tmpdir(), 'harness-mcp-e2e-'));
43
+ const store = new JsonlStore(dir);
44
+ const bridge = new Bridge({
45
+ port: 0,
46
+ host: '127.0.0.1',
47
+ store,
48
+ taskStore: null,
49
+ autoPurge: { enabled: false },
50
+ });
51
+ await bridge.start();
52
+ const port = bridge.getBoundPort();
53
+ if (!port) throw new Error('no port');
54
+
55
+ const server = createMcpServer(bridge);
56
+ const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
57
+ const client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} });
58
+ await server.connect(serverTransport);
59
+ await client.connect(clientTransport);
60
+
61
+ const env: TestEnv = {
62
+ bridge,
63
+ store,
64
+ dir,
65
+ port,
66
+ client,
67
+ teardown: async () => {
68
+ await client.close();
69
+ await server.close();
70
+ await bridge.stop();
71
+ store.close();
72
+ rmSync(dir, { recursive: true, force: true });
73
+ },
74
+ };
75
+ envs.push(env);
76
+ return env;
77
+ }
78
+
79
+ afterEach(async () => {
80
+ while (envs.length > 0) {
81
+ const env = envs.pop()!;
82
+ await env.teardown();
83
+ }
84
+ });
85
+
86
+ async function connectRuntime(
87
+ port: number,
88
+ opts: { tabId: string; projectId: string; sessionId: string; visitorId?: string },
89
+ ): Promise<WebSocket> {
90
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
91
+ await new Promise<void>((resolve, reject) => {
92
+ ws.once('open', () => resolve());
93
+ ws.once('error', reject);
94
+ });
95
+ ws.send(JSON.stringify({
96
+ type: 'hello',
97
+ id: 'h1',
98
+ role: 'runtime-client',
99
+ projectId: opts.projectId,
100
+ tabId: opts.tabId,
101
+ sessionId: opts.sessionId,
102
+ visitorId: opts.visitorId,
103
+ page: { url: 'http://localhost:5173/', title: 'Demo' },
104
+ }));
105
+ await new Promise<HelloAckFrame>((resolve, reject) => {
106
+ const timer = setTimeout(() => reject(new Error('hello.ack timeout')), 1000);
107
+ ws.once('message', (raw) => {
108
+ clearTimeout(timer);
109
+ resolve(JSON.parse(raw.toString()) as HelloAckFrame);
110
+ });
111
+ });
112
+ return ws;
113
+ }
114
+
115
+ function emit(ws: WebSocket, name: string, payload: unknown, tabId: string): void {
116
+ ws.send(JSON.stringify({
117
+ type: 'event',
118
+ id: `evt-${randomUUID()}`,
119
+ tabId,
120
+ name,
121
+ ts: Date.now(),
122
+ payload,
123
+ } satisfies EventFrame));
124
+ }
125
+
126
+ function parseToolText(result: { content: Array<{ type: string; text?: string }> }): unknown {
127
+ const text = result.content?.[0]?.text ?? '';
128
+ try { return JSON.parse(text); } catch { return text; }
129
+ }
130
+
131
+ describe('MCP E2E — visitor.timeline tool round-trip', () => {
132
+ it('client.callTool("visitor.timeline") returns merged events across two tabs', async () => {
133
+ const env = await setup();
134
+ const projectId = 'tanka';
135
+ const visitorId = `v-${randomUUID()}`;
136
+ const sessA = randomUUID();
137
+ const sessB = randomUUID();
138
+ const tabA = 't-mcp-a';
139
+ const tabB = 't-mcp-b';
140
+ const wsA = await connectRuntime(env.port, { tabId: tabA, projectId, sessionId: sessA, visitorId });
141
+ const wsB = await connectRuntime(env.port, { tabId: tabB, projectId, sessionId: sessB, visitorId });
142
+
143
+ emit(wsA, 'ws', { ts: Date.now(), id: 'w1', phase: 'recv', url: 'wss://x/', payload: { type: 'kick' } } satisfies WsEntry, tabA);
144
+ emit(wsB, 'storage', { ts: Date.now() + 5, op: 'remove', which: 'local', key: 'token', initiator: { stack: 'at clearToken' } } satisfies StorageEntry, tabB);
145
+ emit(wsB, 'network', { ts: Date.now() + 10, id: 'r1', phase: 'req', method: 'POST', url: 'https://api.test/sync' } satisfies NetworkEntry, tabB);
146
+
147
+ // Let the daemon ingest + flush.
148
+ await new Promise((r) => setTimeout(r, 60));
149
+ await env.store.flush();
150
+ wsA.close();
151
+ wsB.close();
152
+
153
+ const result = await env.client.callTool({
154
+ name: 'visitor.timeline',
155
+ arguments: { visitorId },
156
+ });
157
+ const body = parseToolText(result as { content: Array<{ type: string; text?: string }> }) as {
158
+ visitorId: string;
159
+ sessionCount: number;
160
+ eventCount: number;
161
+ events: Array<{ t: string; tab?: string }>;
162
+ };
163
+ expect(body.visitorId).toBe(visitorId);
164
+ expect(body.sessionCount).toBe(2);
165
+ expect(body.events.length).toBeGreaterThanOrEqual(3);
166
+ const types = new Set(body.events.map((e) => e.t));
167
+ expect(types.has('ws')).toBe(true);
168
+ expect(types.has('storage')).toBe(true);
169
+ expect(types.has('network')).toBe(true);
170
+ const tabs = new Set(body.events.map((e) => e.tab));
171
+ expect(tabs.has(tabA)).toBe(true);
172
+ expect(tabs.has(tabB)).toBe(true);
173
+ });
174
+
175
+ it('visitor.timeline with unknown visitorId surfaces an error via isError', async () => {
176
+ const env = await setup();
177
+ const result = await env.client.callTool({
178
+ name: 'visitor.timeline',
179
+ arguments: { visitorId: 'nonexistent' },
180
+ }) as { isError?: boolean; content: Array<{ text?: string }> };
181
+ expect(result.isError).toBe(true);
182
+ expect(result.content?.[0]?.text ?? '').toContain('visitor not found');
183
+ });
184
+ });
185
+
186
+ describe('MCP E2E — tools are listable and discoverable', () => {
187
+ it('listTools includes the new tools added in this sweep', async () => {
188
+ const env = await setup();
189
+ const { tools } = await env.client.listTools();
190
+ const names = new Set(tools.map((t) => t.name));
191
+ // Every new tool we added should show up on the wire.
192
+ for (const name of [
193
+ 'ws.tail',
194
+ 'storage.tail',
195
+ 'network.get',
196
+ 'ws.get',
197
+ 'network.wait_for',
198
+ 'network.wait_for_idle',
199
+ 'visitor.timeline',
200
+ ]) {
201
+ expect(names.has(name)).toBe(true);
202
+ }
203
+ });
204
+
205
+ it('network.tail tool advertises the new filter / match / urlContains / method / statusCode params', async () => {
206
+ const env = await setup();
207
+ const { tools } = await env.client.listTools();
208
+ const networkTail = tools.find((t) => t.name === 'network.tail');
209
+ expect(networkTail).toBeDefined();
210
+ const props = (networkTail!.inputSchema as { properties?: Record<string, unknown> }).properties ?? {};
211
+ for (const key of ['filter', 'match', 'urlContains', 'method', 'statusCode']) {
212
+ expect(props[key]).toBeDefined();
213
+ }
214
+ });
215
+ });
216
+
217
+ describe('MCP E2E — capability surfacing on tool descriptions', () => {
218
+ it('session.tail description cross-references visitor.timeline', async () => {
219
+ const env = await setup();
220
+ const { tools } = await env.client.listTools();
221
+ const sessionTail = tools.find((t) => t.name === 'session.tail');
222
+ expect(sessionTail).toBeDefined();
223
+ expect(sessionTail!.description ?? '').toContain('visitor.timeline');
224
+ });
225
+
226
+ it('console/network/errors/ws/storage tail tools cross-reference session.tail', async () => {
227
+ const env = await setup();
228
+ const { tools } = await env.client.listTools();
229
+ for (const name of ['console.tail', 'network.tail', 'errors.tail', 'ws.tail', 'storage.tail']) {
230
+ const t = tools.find((x) => x.name === name);
231
+ expect(t, `${name} should exist`).toBeDefined();
232
+ expect(t!.description ?? '', `${name} description should reference session.tail`).toContain('session.tail');
233
+ }
234
+ });
235
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * End-to-end tests for the capabilities added in the multi-tab observability
3
+ * sweep: ws / storage event ingestion, initiator stack preservation, and the
4
+ * `visitor.timeline` aggregation.
5
+ *
6
+ * These run a real Bridge + JsonlStore on tmpdir and a `ws` (node) client
7
+ * pretending to be a runtime-client. We emit the exact event shapes the
8
+ * patched runtime would emit and assert what the store + tools observe.
9
+ */
10
+
11
+ import { afterEach, describe, expect, it } from 'vitest';
12
+ import { WebSocket } from 'ws';
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { Bridge } from './bridge.js';
18
+ import { JsonlStore } from './store/index.js';
19
+ import type {
20
+ EventFrame,
21
+ HelloAckFrame,
22
+ NetworkEntry,
23
+ StorageEntry,
24
+ WsEntry,
25
+ } from '@harness-fe/protocol';
26
+ import { buildVisitorTimeline } from './visitorTimeline.js';
27
+
28
+ interface TestEnv {
29
+ bridge: Bridge;
30
+ store: JsonlStore;
31
+ dir: string;
32
+ port: number;
33
+ }
34
+
35
+ const envs: TestEnv[] = [];
36
+
37
+ async function setup(): Promise<TestEnv> {
38
+ const dir = mkdtempSync(join(tmpdir(), 'harness-e2e-'));
39
+ const store = new JsonlStore(dir);
40
+ const bridge = new Bridge({
41
+ port: 0,
42
+ host: '127.0.0.1',
43
+ store,
44
+ taskStore: null,
45
+ autoPurge: { enabled: false },
46
+ });
47
+ await bridge.start();
48
+ const port = bridge.getBoundPort();
49
+ if (!port) throw new Error('no port');
50
+ const env = { bridge, store, dir, port };
51
+ envs.push(env);
52
+ return env;
53
+ }
54
+
55
+ afterEach(async () => {
56
+ while (envs.length > 0) {
57
+ const env = envs.pop()!;
58
+ await env.bridge.stop();
59
+ env.store.close();
60
+ rmSync(env.dir, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ async function connectRuntimeClient(
65
+ port: number,
66
+ opts: { tabId: string; projectId: string; sessionId: string; visitorId?: string },
67
+ ): Promise<WebSocket> {
68
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
69
+ await new Promise<void>((resolve, reject) => {
70
+ ws.once('open', () => resolve());
71
+ ws.once('error', reject);
72
+ });
73
+ ws.send(JSON.stringify({
74
+ type: 'hello',
75
+ id: 'h1',
76
+ role: 'runtime-client',
77
+ projectId: opts.projectId,
78
+ tabId: opts.tabId,
79
+ sessionId: opts.sessionId,
80
+ visitorId: opts.visitorId,
81
+ page: { url: 'http://localhost:5173/', title: 'Demo' },
82
+ }));
83
+ const ack = await new Promise<HelloAckFrame>((resolve, reject) => {
84
+ const timer = setTimeout(() => reject(new Error('hello.ack timeout')), 1000);
85
+ ws.once('message', (raw) => {
86
+ clearTimeout(timer);
87
+ resolve(JSON.parse(raw.toString()) as HelloAckFrame);
88
+ });
89
+ });
90
+ if (ack.error) throw new Error(`hello.ack error: ${ack.error}`);
91
+ return ws;
92
+ }
93
+
94
+ function sendEvent(ws: WebSocket, name: string, payload: unknown, tabId: string, extras: Partial<EventFrame> = {}): void {
95
+ ws.send(JSON.stringify({
96
+ type: 'event',
97
+ id: `evt-${randomUUID()}`,
98
+ tabId,
99
+ name,
100
+ ts: Date.now(),
101
+ payload,
102
+ ...extras,
103
+ } satisfies EventFrame));
104
+ }
105
+
106
+ /** Wait long enough for the bridge to ingest queued frames + flush JSONL. */
107
+ async function waitForIngestion(store: JsonlStore): Promise<void> {
108
+ await new Promise((r) => setTimeout(r, 50));
109
+ await store.flush();
110
+ }
111
+
112
+ describe('E2E — WebSocket frame ingestion', () => {
113
+ it('routes ws events from runtime → bridge → jsonl with t="ws" and visitorId stamped', async () => {
114
+ const env = await setup();
115
+ const tabId = 't-ws';
116
+ const projectId = 'demo';
117
+ const sessionId = randomUUID();
118
+ const visitorId = 'v-ws';
119
+
120
+ const ws = await connectRuntimeClient(env.port, { tabId, projectId, sessionId, visitorId });
121
+
122
+ // Mirror what runtime-client's wsPatch would emit.
123
+ const wsId = randomUUID();
124
+ sendEvent(ws, 'ws', { ts: Date.now(), id: wsId, phase: 'open', url: 'wss://chat.test/', protocols: ['v1'], initiator: { stack: 'Error\n at userCode' } } satisfies WsEntry, tabId);
125
+ sendEvent(ws, 'ws', { ts: Date.now(), id: wsId, phase: 'send', url: 'wss://chat.test/', payload: { type: 'ping' }, initiator: { stack: 'Error\n at sendPing' } } satisfies WsEntry, tabId);
126
+ sendEvent(ws, 'ws', { ts: Date.now(), id: wsId, phase: 'recv', url: 'wss://chat.test/', payload: { type: 'kick', reason: 'duplicate-login' } } satisfies WsEntry, tabId);
127
+ sendEvent(ws, 'ws', { ts: Date.now(), id: wsId, phase: 'close', url: 'wss://chat.test/', code: 4001, reason: 'duplicate-login', wasClean: false } satisfies WsEntry, tabId);
128
+
129
+ await waitForIngestion(env.store);
130
+ ws.close();
131
+
132
+ const events = env.store.tail(sessionId, { n: 50 });
133
+ const wsEvents = events.filter((e) => e.t === 'ws');
134
+ expect(wsEvents).toHaveLength(4);
135
+ expect(wsEvents.map((e) => (e.d as WsEntry).phase)).toEqual(['open', 'send', 'recv', 'close']);
136
+ // Every persisted ws row carries visitorId from the runtime hello.
137
+ expect(wsEvents.every((e) => e.visitorId === visitorId)).toBe(true);
138
+ // Close event preserves code/reason — proves the schema travels intact.
139
+ const close = wsEvents.find((e) => (e.d as WsEntry).phase === 'close')!;
140
+ expect((close.d as WsEntry).code).toBe(4001);
141
+ expect((close.d as WsEntry).reason).toBe('duplicate-login');
142
+ // Initiator on 'send' survives the round-trip.
143
+ const send = wsEvents.find((e) => (e.d as WsEntry).phase === 'send')!;
144
+ expect((send.d as WsEntry).initiator?.stack).toContain('sendPing');
145
+ });
146
+
147
+ it('session.tail filtered by t=ws returns only ws rows', async () => {
148
+ const env = await setup();
149
+ const tabId = 't-mix';
150
+ const projectId = 'demo';
151
+ const sessionId = randomUUID();
152
+ const ws = await connectRuntimeClient(env.port, { tabId, projectId, sessionId });
153
+ const wsId = randomUUID();
154
+
155
+ sendEvent(ws, 'ws', { ts: Date.now(), id: wsId, phase: 'open', url: 'wss://x/' } satisfies WsEntry, tabId);
156
+ sendEvent(ws, 'network', { ts: Date.now(), id: 'r1', phase: 'req', method: 'GET', url: 'https://api.test/' } satisfies NetworkEntry, tabId);
157
+
158
+ await waitForIngestion(env.store);
159
+ ws.close();
160
+
161
+ const wsOnly = env.store.tail(sessionId, { n: 50, type: 'ws' });
162
+ expect(wsOnly).toHaveLength(1);
163
+ expect((wsOnly[0].d as WsEntry).phase).toBe('open');
164
+ });
165
+ });
166
+
167
+ describe('E2E — storage event ingestion', () => {
168
+ it('persists storage mutations with initiator stack and crossTab flag', async () => {
169
+ const env = await setup();
170
+ const tabId = 't-storage';
171
+ const projectId = 'demo';
172
+ const sessionId = randomUUID();
173
+ const visitorId = 'v-storage';
174
+ const ws = await connectRuntimeClient(env.port, { tabId, projectId, sessionId, visitorId });
175
+
176
+ sendEvent(ws, 'storage', { ts: Date.now(), op: 'set', which: 'local', key: 'token', value: 'abc', initiator: { stack: 'Error\n at setToken' } } satisfies StorageEntry, tabId);
177
+ sendEvent(ws, 'storage', { ts: Date.now(), op: 'remove', which: 'local', key: 'token', initiator: { stack: 'Error\n at logout' } } satisfies StorageEntry, tabId);
178
+ sendEvent(ws, 'storage', { ts: Date.now(), op: 'remove', which: 'local', key: 'token', crossTab: true } satisfies StorageEntry, tabId);
179
+
180
+ await waitForIngestion(env.store);
181
+ ws.close();
182
+
183
+ const rows = env.store.tail(sessionId, { n: 50, type: 'storage' });
184
+ expect(rows).toHaveLength(3);
185
+
186
+ const logoutRow = rows.find((r) => (r.d as StorageEntry).op === 'remove' && !(r.d as StorageEntry).crossTab);
187
+ expect(logoutRow).toBeDefined();
188
+ expect((logoutRow!.d as StorageEntry).initiator?.stack).toContain('logout');
189
+
190
+ const crossTabRow = rows.find((r) => (r.d as StorageEntry).crossTab);
191
+ expect(crossTabRow).toBeDefined();
192
+ expect((crossTabRow!.d as StorageEntry).initiator).toBeUndefined();
193
+ });
194
+ });
195
+
196
+ describe('E2E — network initiator stack round-trip', () => {
197
+ it('preserves initiator stack across bridge ingestion', async () => {
198
+ const env = await setup();
199
+ const tabId = 't-net';
200
+ const projectId = 'demo';
201
+ const sessionId = randomUUID();
202
+ const ws = await connectRuntimeClient(env.port, { tabId, projectId, sessionId });
203
+
204
+ sendEvent(ws, 'network', {
205
+ ts: Date.now(),
206
+ id: 'r1',
207
+ phase: 'req',
208
+ method: 'POST',
209
+ url: 'https://api.test/logout',
210
+ initiator: { stack: 'Error\n at AppLogout.tsx:42\n at clickHandler' },
211
+ } satisfies NetworkEntry, tabId);
212
+
213
+ await waitForIngestion(env.store);
214
+ ws.close();
215
+
216
+ const rows = env.store.tail(sessionId, { n: 10, type: 'network' });
217
+ expect(rows).toHaveLength(1);
218
+ const entry = rows[0].d as NetworkEntry;
219
+ expect(entry.initiator?.stack).toContain('AppLogout.tsx:42');
220
+ });
221
+ });
222
+
223
+ describe('E2E — visitor.timeline across two tabs', () => {
224
+ it('merges ws + storage + network events from two tabs into one ascending timeline', async () => {
225
+ const env = await setup();
226
+ const projectId = 'tanka';
227
+ const visitorId = 'v-cross';
228
+
229
+ // Tab A: ws connection.
230
+ const tabA = 't-a';
231
+ const sessA = randomUUID();
232
+ const wsA = await connectRuntimeClient(env.port, { tabId: tabA, projectId, sessionId: sessA, visitorId });
233
+
234
+ // Tab B: storage + network in a separate session.
235
+ const tabB = 't-b';
236
+ const sessB = randomUUID();
237
+ const wsB = await connectRuntimeClient(env.port, { tabId: tabB, projectId, sessionId: sessB, visitorId });
238
+
239
+ const t0 = Date.now();
240
+ const wsId = randomUUID();
241
+ // Tab A receives a kick frame.
242
+ sendEvent(wsA, 'ws', { ts: t0 + 10, id: wsId, phase: 'recv', url: 'wss://x/', payload: { type: 'kick' } } satisfies WsEntry, tabA);
243
+ // Tab B's network call to /sync runs.
244
+ sendEvent(wsB, 'network', { ts: t0 + 20, id: 'r1', phase: 'req', method: 'POST', url: 'https://api.test/sync' } satisfies NetworkEntry, tabB);
245
+ // Tab B then drops the local token.
246
+ sendEvent(wsB, 'storage', { ts: t0 + 30, op: 'remove', which: 'local', key: 'token', initiator: { stack: 'at clearToken' } } satisfies StorageEntry, tabB);
247
+
248
+ await waitForIngestion(env.store);
249
+ wsA.close();
250
+ wsB.close();
251
+
252
+ const result = buildVisitorTimeline(env.store, visitorId);
253
+ if ('error' in result) throw new Error(result.error);
254
+
255
+ expect(result.sessionCount).toBe(2);
256
+ // Ascending by ts.
257
+ const tsSeq = result.events.map((e) => e.ts);
258
+ expect(tsSeq).toEqual([...tsSeq].sort((a, b) => a - b));
259
+ // All three event types are present.
260
+ const types = new Set(result.events.map((e) => e.t));
261
+ expect(types.has('ws')).toBe(true);
262
+ expect(types.has('storage')).toBe(true);
263
+ expect(types.has('network')).toBe(true);
264
+ // Tab attribution is visible — both tabs contributed.
265
+ const tabs = new Set(result.events.map((e) => e.tab));
266
+ expect(tabs.has(tabA)).toBe(true);
267
+ expect(tabs.has(tabB)).toBe(true);
268
+ });
269
+
270
+ it('does not leak another visitor\'s events into the timeline', async () => {
271
+ const env = await setup();
272
+ const projectId = 'tanka';
273
+ const myVisitor = 'v-mine';
274
+ const otherVisitor = 'v-other';
275
+ const tabId = 't-shared';
276
+ const sessionId = randomUUID();
277
+ const myWs = await connectRuntimeClient(env.port, { tabId, projectId, sessionId, visitorId: myVisitor });
278
+
279
+ // Same session, but synthesize a row tagged with another visitor by
280
+ // writing directly through the store (mirrors the real-world case
281
+ // where a session is shared, e.g. iframes from different origins).
282
+ sendEvent(myWs, 'network', { ts: Date.now(), id: 'mine', phase: 'req', method: 'GET', url: '/mine' } satisfies NetworkEntry, tabId);
283
+
284
+ await waitForIngestion(env.store);
285
+ // Inject a foreign-visitor row directly into the same session.
286
+ env.store.appendEvent(sessionId, {
287
+ ts: Date.now(),
288
+ t: 'network',
289
+ tab: tabId,
290
+ visitorId: otherVisitor,
291
+ d: { id: 'other', phase: 'req', method: 'GET', url: '/other' } as NetworkEntry,
292
+ });
293
+ await env.store.flush();
294
+ myWs.close();
295
+
296
+ const result = buildVisitorTimeline(env.store, myVisitor);
297
+ if ('error' in result) throw new Error(result.error);
298
+ expect(result.events.every((e) => e.visitorId === myVisitor)).toBe(true);
299
+ const urls = result.events.map((e) => (e.d as NetworkEntry).url);
300
+ expect(urls).toContain('/mine');
301
+ expect(urls).not.toContain('/other');
302
+ });
303
+ });
@@ -48,6 +48,7 @@ export type EventType =
48
48
  | 'note' // project-level note written by agent/user
49
49
  | 'load' // page-load initial snapshot
50
50
  | 'storage' // localStorage/sessionStorage/cookie mutation
51
+ | 'ws' // WebSocket frame (open / send / recv / close)
51
52
  | 'server-log' // Node.js console log from node-runtime SDK
52
53
  | 'server-err' // Node.js uncaughtException / unhandledRejection from node-runtime SDK
53
54
  | 'server-action'// Route Handler / Server Action timing from withHarnessTracing()