@harness-fe/runtime 3.0.1 → 3.2.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,167 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest';
3
+ import { COMMAND, type ConsoleEntry, type NetworkEntry, type WsEntry, type ErrorEntry } from '@harness-fe/protocol';
4
+ import { commandHandlers } from './commands.js';
5
+ import type { CaptureStore } from './capture.js';
6
+
7
+ /**
8
+ * Construct a fake CaptureStore wide enough to drive the *_TAIL handlers.
9
+ * We seed the RingBuffers directly so we don't have to actually patch fetch /
10
+ * console / WebSocket in this test.
11
+ */
12
+ function makeCapture(seed: {
13
+ console?: ConsoleEntry[];
14
+ network?: NetworkEntry[];
15
+ errors?: ErrorEntry[];
16
+ ws?: WsEntry[];
17
+ }): CaptureStore {
18
+ function ring<T>(items: T[]): { tail: (n: number) => T[] } {
19
+ return { tail: (n) => items.slice(-n) };
20
+ }
21
+ return {
22
+ console: ring(seed.console ?? []),
23
+ network: ring(seed.network ?? []),
24
+ errors: ring(seed.errors ?? []),
25
+ ws: ring(seed.ws ?? []),
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ } as any;
28
+ }
29
+
30
+ describe('console.tail filtering', () => {
31
+ const seed: ConsoleEntry[] = [
32
+ { ts: 1, level: 'log', args: ['boot complete'] },
33
+ { ts: 2, level: 'warn', args: ['cache miss'] },
34
+ { ts: 3, level: 'error', args: ['logout failed', { reason: 'token expired' }] },
35
+ { ts: 4, level: 'log', args: ['heartbeat'] },
36
+ ];
37
+
38
+ it('filter substring (case-insensitive)', async () => {
39
+ const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
40
+ { n: 10, filter: 'LOGOUT' },
41
+ { capture: makeCapture({ console: seed }) },
42
+ ) as { entries: ConsoleEntry[] };
43
+ expect(out.entries.map((e) => e.level)).toEqual(['error']);
44
+ });
45
+
46
+ it('filter regex', async () => {
47
+ const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
48
+ { n: 10, filter: 'boot|heart', match: 'regex' },
49
+ { capture: makeCapture({ console: seed }) },
50
+ ) as { entries: ConsoleEntry[] };
51
+ expect(out.entries).toHaveLength(2);
52
+ });
53
+
54
+ it('level narrow', async () => {
55
+ const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
56
+ { n: 10, level: 'error' },
57
+ { capture: makeCapture({ console: seed }) },
58
+ ) as { entries: ConsoleEntry[] };
59
+ expect(out.entries).toHaveLength(1);
60
+ expect(out.entries[0].args[0]).toBe('logout failed');
61
+ });
62
+
63
+ it('invalid regex falls back to substring', async () => {
64
+ const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
65
+ { n: 10, filter: '[bad', match: 'regex' },
66
+ { capture: makeCapture({ console: seed }) },
67
+ ) as { entries: ConsoleEntry[] };
68
+ // No entry literally contains "[bad" — fallback returns 0 matches.
69
+ expect(out.entries).toHaveLength(0);
70
+ });
71
+ });
72
+
73
+ describe('network.tail filtering', () => {
74
+ const seed: NetworkEntry[] = [
75
+ { ts: 1, id: 'r1', phase: 'req', method: 'GET', url: 'https://api.test/users' },
76
+ { ts: 2, id: 'r1', phase: 'res', method: 'GET', url: 'https://api.test/users', status: 200 },
77
+ { ts: 3, id: 'r2', phase: 'req', method: 'POST', url: 'https://api.test/logout' },
78
+ { ts: 4, id: 'r2', phase: 'res', method: 'POST', url: 'https://api.test/logout', status: 401 },
79
+ { ts: 5, id: 'r3', phase: 'req', method: 'GET', url: 'https://cdn.test/assets/app.js' },
80
+ ];
81
+
82
+ it('urlContains narrow', async () => {
83
+ const out = await commandHandlers[COMMAND.NETWORK_TAIL](
84
+ { n: 10, urlContains: 'logout' },
85
+ { capture: makeCapture({ network: seed }) },
86
+ ) as { entries: NetworkEntry[] };
87
+ expect(out.entries.every((e) => e.url.includes('logout'))).toBe(true);
88
+ expect(out.entries).toHaveLength(2);
89
+ });
90
+
91
+ it('method narrow (case-insensitive)', async () => {
92
+ const out = await commandHandlers[COMMAND.NETWORK_TAIL](
93
+ { n: 10, method: 'post' },
94
+ { capture: makeCapture({ network: seed }) },
95
+ ) as { entries: NetworkEntry[] };
96
+ expect(out.entries.every((e) => e.method === 'POST')).toBe(true);
97
+ });
98
+
99
+ it('statusCode narrow (drops req entries without status)', async () => {
100
+ const out = await commandHandlers[COMMAND.NETWORK_TAIL](
101
+ { n: 10, statusCode: 401 },
102
+ { capture: makeCapture({ network: seed }) },
103
+ ) as { entries: NetworkEntry[] };
104
+ expect(out.entries).toHaveLength(1);
105
+ expect(out.entries[0].status).toBe(401);
106
+ });
107
+
108
+ it('filter combined with narrow', async () => {
109
+ const out = await commandHandlers[COMMAND.NETWORK_TAIL](
110
+ { n: 10, urlContains: 'api.test', filter: 'logout' },
111
+ { capture: makeCapture({ network: seed }) },
112
+ ) as { entries: NetworkEntry[] };
113
+ expect(out.entries.every((e) => e.url.includes('logout'))).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe('ws.tail filtering', () => {
118
+ const seed: WsEntry[] = [
119
+ { ts: 1, id: 'w1', phase: 'open', url: 'wss://chat.test/' },
120
+ { ts: 2, id: 'w1', phase: 'send', url: 'wss://chat.test/', payload: { type: 'ping' } },
121
+ { ts: 3, id: 'w1', phase: 'recv', url: 'wss://chat.test/', payload: { type: 'kick', reason: 'duplicate-login' } },
122
+ { ts: 4, id: 'w1', phase: 'close', url: 'wss://chat.test/', code: 4001, reason: 'duplicate-login' },
123
+ ];
124
+
125
+ it('phase narrow', async () => {
126
+ const out = await commandHandlers[COMMAND.WS_TAIL](
127
+ { n: 10, phase: 'recv' },
128
+ { capture: makeCapture({ ws: seed }) },
129
+ ) as { entries: WsEntry[] };
130
+ expect(out.entries).toHaveLength(1);
131
+ expect(out.entries[0].phase).toBe('recv');
132
+ });
133
+
134
+ it('filter against payload', async () => {
135
+ const out = await commandHandlers[COMMAND.WS_TAIL](
136
+ { n: 10, filter: 'kick' },
137
+ { capture: makeCapture({ ws: seed }) },
138
+ ) as { entries: WsEntry[] };
139
+ expect(out.entries).toHaveLength(1);
140
+ expect(out.entries[0].phase).toBe('recv');
141
+ });
142
+
143
+ it('filter against close reason', async () => {
144
+ const out = await commandHandlers[COMMAND.WS_TAIL](
145
+ { n: 10, filter: 'duplicate-login', phase: 'close' },
146
+ { capture: makeCapture({ ws: seed }) },
147
+ ) as { entries: WsEntry[] };
148
+ expect(out.entries).toHaveLength(1);
149
+ expect(out.entries[0].code).toBe(4001);
150
+ });
151
+ });
152
+
153
+ describe('errors.tail filtering', () => {
154
+ const seed: ErrorEntry[] = [
155
+ { ts: 1, message: 'Cannot read property foo of undefined', stack: 'at A.run', source: 'a.js:1:1' },
156
+ { ts: 2, message: 'NetworkError', stack: 'at fetchX', source: 'net.js:9:9' },
157
+ ];
158
+
159
+ it('filter by message substring', async () => {
160
+ const out = await commandHandlers[COMMAND.ERRORS_TAIL](
161
+ { n: 10, filter: 'network' },
162
+ { capture: makeCapture({ errors: seed }) },
163
+ ) as { entries: ErrorEntry[] };
164
+ expect(out.entries).toHaveLength(1);
165
+ expect(out.entries[0].message).toBe('NetworkError');
166
+ });
167
+ });
@@ -0,0 +1,146 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * End-to-end tests for the runtime-side command handlers added by the new
4
+ * tooling: `network.wait_for`, `network.wait_for_idle`, `network.get`, and
5
+ * `ws.get`. These were previously untested at the handler level — the unit
6
+ * test suite covered tail()-shaped commands but not the async ones.
7
+ *
8
+ * We exercise the handler directly with a real CaptureStore, pushing entries
9
+ * on a real timer the same way the patched fetch / WebSocket would.
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+ import { COMMAND, type NetworkEntry, type WsEntry } from '@harness-fe/protocol';
14
+ import { commandHandlers } from './commands.js';
15
+ import { CaptureStore } from './capture.js';
16
+
17
+ function makeCapture(): CaptureStore {
18
+ // We don't install patches — we drive the RingBuffer directly so the
19
+ // tests don't fight happy-dom's fetch.
20
+ return new CaptureStore();
21
+ }
22
+
23
+ describe('NETWORK_WAIT_FOR', () => {
24
+ it('resolves when a matching request arrives after the call', async () => {
25
+ const cap = makeCapture();
26
+ const promise = commandHandlers[COMMAND.NETWORK_WAIT_FOR](
27
+ { urlContains: '/logout', timeoutMs: 2000 },
28
+ { capture: cap },
29
+ );
30
+ setTimeout(() => {
31
+ cap.network.push({ ts: Date.now(), id: 'r1', phase: 'req', method: 'POST', url: 'https://api.test/logout' } satisfies NetworkEntry);
32
+ }, 100);
33
+ const result = (await promise) as { ok: boolean; entry: NetworkEntry };
34
+ expect(result.ok).toBe(true);
35
+ expect(result.entry.url).toContain('/logout');
36
+ });
37
+
38
+ it('rejects on timeout when no match arrives', async () => {
39
+ const cap = makeCapture();
40
+ const promise = commandHandlers[COMMAND.NETWORK_WAIT_FOR](
41
+ { urlContains: '/never', timeoutMs: 200 },
42
+ { capture: cap },
43
+ );
44
+ cap.network.push({ ts: Date.now(), id: 'x', phase: 'req', method: 'GET', url: 'https://api.test/users' });
45
+ await expect(promise).rejects.toThrow(/no matching request/);
46
+ });
47
+
48
+ it('matches by method + statusCode and ignores pre-baseline matches', async () => {
49
+ const cap = makeCapture();
50
+ // Pre-existing entry — should NOT satisfy the wait (baseline anchored on call).
51
+ cap.network.push({ ts: Date.now() - 1000, id: 'old', phase: 'res', method: 'POST', url: '/x', status: 401 });
52
+ const promise = commandHandlers[COMMAND.NETWORK_WAIT_FOR](
53
+ { method: 'POST', statusCode: 401, timeoutMs: 2000 },
54
+ { capture: cap },
55
+ );
56
+ setTimeout(() => {
57
+ cap.network.push({ ts: Date.now(), id: 'new', phase: 'res', method: 'POST', url: '/y', status: 401 });
58
+ }, 80);
59
+ const out = (await promise) as { entry: NetworkEntry };
60
+ expect(out.entry.id).toBe('new');
61
+ });
62
+
63
+ it('matches by urlRegex (case-insensitive)', async () => {
64
+ const cap = makeCapture();
65
+ const promise = commandHandlers[COMMAND.NETWORK_WAIT_FOR](
66
+ { urlRegex: 'api\\.test/(login|logout)', timeoutMs: 2000 },
67
+ { capture: cap },
68
+ );
69
+ setTimeout(() => {
70
+ cap.network.push({ ts: Date.now(), id: 'x', phase: 'req', method: 'POST', url: 'https://API.test/logout' });
71
+ }, 60);
72
+ const out = (await promise) as { entry: NetworkEntry };
73
+ expect(out.entry.url).toContain('logout');
74
+ });
75
+ });
76
+
77
+ describe('NETWORK_WAIT_FOR_IDLE', () => {
78
+ it('resolves once no new entries arrive for idleMs', async () => {
79
+ const cap = makeCapture();
80
+ cap.network.push({ ts: Date.now(), id: 'a', phase: 'req', method: 'GET', url: '/a' });
81
+ const promise = commandHandlers[COMMAND.NETWORK_WAIT_FOR_IDLE](
82
+ { idleMs: 150, timeoutMs: 2000 },
83
+ { capture: cap },
84
+ );
85
+ setTimeout(() => cap.network.push({ ts: Date.now(), id: 'b', phase: 'req', method: 'GET', url: '/b' }), 50);
86
+ const out = (await promise) as { ok: boolean; idleFor: number };
87
+ expect(out.ok).toBe(true);
88
+ expect(out.idleFor).toBeGreaterThanOrEqual(150);
89
+ });
90
+
91
+ it('rejects when the network never quiets within timeoutMs', async () => {
92
+ const cap = makeCapture();
93
+ const promise = commandHandlers[COMMAND.NETWORK_WAIT_FOR_IDLE](
94
+ { idleMs: 200, timeoutMs: 300 },
95
+ { capture: cap },
96
+ );
97
+ const handle = setInterval(() => {
98
+ cap.network.push({ ts: Date.now(), id: `n-${Math.random()}`, phase: 'req', method: 'GET', url: '/spam' });
99
+ }, 50);
100
+ await expect(promise).rejects.toThrow(/never quiet/);
101
+ clearInterval(handle);
102
+ });
103
+ });
104
+
105
+ describe('NETWORK_GET', () => {
106
+ it('returns req + res entries for the given reqId', async () => {
107
+ const cap = makeCapture();
108
+ cap.network.push({ ts: 1, id: 'r1', phase: 'req', method: 'GET', url: '/x', requestBody: { q: 1 } });
109
+ cap.network.push({ ts: 2, id: 'r1', phase: 'res', method: 'GET', url: '/x', status: 200, responseBody: { ok: true } });
110
+ cap.network.push({ ts: 3, id: 'r2', phase: 'req', method: 'GET', url: '/y' });
111
+
112
+ const out = await commandHandlers[COMMAND.NETWORK_GET]({ reqId: 'r1' }, { capture: cap }) as { entries: NetworkEntry[]; found: boolean };
113
+ expect(out.found).toBe(true);
114
+ expect(out.entries).toHaveLength(2);
115
+ expect(out.entries.map((e) => e.phase)).toEqual(['req', 'res']);
116
+ // Bodies survive untouched.
117
+ expect((out.entries[0] as NetworkEntry).requestBody).toEqual({ q: 1 });
118
+ expect((out.entries[1] as NetworkEntry).responseBody).toEqual({ ok: true });
119
+ });
120
+
121
+ it('returns found=false when id is unknown', async () => {
122
+ const cap = makeCapture();
123
+ cap.network.push({ ts: 1, id: 'r1', phase: 'req', method: 'GET', url: '/x' });
124
+ const out = await commandHandlers[COMMAND.NETWORK_GET]({ reqId: 'nope' }, { capture: cap }) as { entries: NetworkEntry[]; found: boolean };
125
+ expect(out.found).toBe(false);
126
+ expect(out.entries).toHaveLength(0);
127
+ });
128
+ });
129
+
130
+ describe('WS_GET', () => {
131
+ it('returns all phases (open/send/recv/close) for a single ws id', async () => {
132
+ const cap = makeCapture();
133
+ const id = 'w1';
134
+ cap.ws.push({ ts: 1, id, phase: 'open', url: 'wss://x/' });
135
+ cap.ws.push({ ts: 2, id, phase: 'send', url: 'wss://x/', payload: { ping: 1 } });
136
+ cap.ws.push({ ts: 3, id, phase: 'recv', url: 'wss://x/', payload: { pong: 1 } });
137
+ cap.ws.push({ ts: 4, id, phase: 'close', url: 'wss://x/', code: 1000, wasClean: true });
138
+ cap.ws.push({ ts: 5, id: 'w-other', phase: 'open', url: 'wss://y/' });
139
+
140
+ const out = await commandHandlers[COMMAND.WS_GET]({ wsId: id }, { capture: cap }) as { entries: WsEntry[]; found: boolean };
141
+ expect(out.found).toBe(true);
142
+ expect(out.entries).toHaveLength(4);
143
+ expect(out.entries.map((e) => e.phase)).toEqual(['open', 'send', 'recv', 'close']);
144
+ expect(out.entries.every((e) => e.id === id)).toBe(true);
145
+ });
146
+ });
@@ -0,0 +1,264 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * End-to-end test for the complete runtime-client → bridge → store path on
4
+ * the capabilities added in this sweep:
5
+ *
6
+ * user code triggers `new WebSocket(...)` / `localStorage.setItem(...)`
7
+ * → patched runtime captures + emits via outbox
8
+ * → real Bridge over a real WebSocket connection
9
+ * → events land in JsonlStore with the right `t` field
10
+ *
11
+ * We use a real `RuntimeClient` instance (happy-dom is its environment) and
12
+ * a real Bridge bound to an ephemeral port on 127.0.0.1.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+
17
+ // rrweb has CommonJS interop issues under happy-dom + Vite ESM. We never need
18
+ // the actual recorder in this test — it's purely background DOM capture — so
19
+ // stub the module surface used by client.ts before any imports resolve it.
20
+ vi.mock('rrweb', () => ({
21
+ record: () => () => {},
22
+ EventType: { Custom: 5 },
23
+ }));
24
+ import { mkdtempSync, rmSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { Bridge } from '../../mcp-server/src/bridge.js';
28
+ import { JsonlStore } from '../../mcp-server/src/store/index.js';
29
+ import { RuntimeClient } from './client.js';
30
+ import { getCaptureStore } from './capture.js';
31
+ import type { StoreEvent } from '../../mcp-server/src/store/index.js';
32
+ import type { NetworkEntry, StorageEntry, WsEntry } from '@harness-fe/protocol';
33
+
34
+ interface Env {
35
+ bridge: Bridge;
36
+ store: JsonlStore;
37
+ dir: string;
38
+ port: number;
39
+ client: RuntimeClient;
40
+ sessionId: string;
41
+ }
42
+
43
+ let env: Env | undefined;
44
+
45
+ async function rmDirWithRetry(dir: string, attempts = 5): Promise<void> {
46
+ for (let i = 0; i < attempts; i++) {
47
+ try {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ return;
50
+ } catch (err) {
51
+ const code = (err as NodeJS.ErrnoException).code;
52
+ if (code !== 'ENOTEMPTY' && code !== 'EBUSY' && code !== 'EPERM') throw err;
53
+ if (i === attempts - 1) throw err;
54
+ await new Promise((r) => setTimeout(r, 20 * (i + 1)));
55
+ }
56
+ }
57
+ }
58
+
59
+ async function setup(): Promise<Env> {
60
+ const dir = mkdtempSync(join(tmpdir(), 'harness-rt-e2e-'));
61
+ const store = new JsonlStore(dir);
62
+ const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
63
+ await bridge.start();
64
+ const port = bridge.getBoundPort();
65
+ if (!port) throw new Error('no port');
66
+
67
+ // happy-dom keeps singletons across tests — reset the patch state so this
68
+ // RuntimeClient's onEvent wiring takes hold.
69
+ getCaptureStore().dispose();
70
+
71
+ const client = new RuntimeClient({
72
+ projectId: 'rt-e2e',
73
+ mcpUrl: `ws://127.0.0.1:${port}`,
74
+ });
75
+ client.start();
76
+
77
+ // Wait for hello.ack — bridge logs `peer connected` when the runtime is
78
+ // registered. We poll the store's session list until ours shows up.
79
+ const deadline = Date.now() + 2000;
80
+ while (Date.now() < deadline) {
81
+ const sessions = store.listSessions({ projectId: 'rt-e2e', limit: 5 });
82
+ if (sessions.length > 0 && client.getConnectionState() === 'open') {
83
+ break;
84
+ }
85
+ await new Promise((r) => setTimeout(r, 30));
86
+ }
87
+ if (client.getConnectionState() !== 'open') {
88
+ throw new Error('runtime-client never connected');
89
+ }
90
+
91
+ return { bridge, store, dir, port, client, sessionId: client.sessionId };
92
+ }
93
+
94
+ beforeEach(async () => {
95
+ env = await setup();
96
+ });
97
+
98
+ afterEach(async () => {
99
+ if (!env) return;
100
+ env.client.stop();
101
+ await env.bridge.stop();
102
+ // close() drains the async write queue — must await, else rmSync races
103
+ // file writes and the dir-recursive-rm trips ENOTEMPTY on Linux CI.
104
+ await env.store.close();
105
+ // Even after drain, Node's directory cache can lag by a tick on Linux —
106
+ // retry-with-backoff handles the residual race deterministically.
107
+ await rmDirWithRetry(env.dir);
108
+ // Reset capture singleton so subsequent tests get a clean install.
109
+ const cap = getCaptureStore();
110
+ cap.dispose();
111
+ cap.console.clear();
112
+ cap.network.clear();
113
+ cap.errors.clear();
114
+ cap.ws.clear();
115
+ cap.storage.clear();
116
+ env = undefined;
117
+ });
118
+
119
+ /** Read events of a given type from the session timeline, polling for flush. */
120
+ async function readTypedEvents(
121
+ store: JsonlStore,
122
+ sessionId: string,
123
+ type: string,
124
+ expectedMin: number,
125
+ timeoutMs = 1500,
126
+ ): Promise<StoreEvent[]> {
127
+ const deadline = Date.now() + timeoutMs;
128
+ while (Date.now() < deadline) {
129
+ await store.flush();
130
+ const rows = store.tail(sessionId, { n: 200, type });
131
+ if (rows.length >= expectedMin) return rows;
132
+ await new Promise((r) => setTimeout(r, 30));
133
+ }
134
+ await store.flush();
135
+ return store.tail(sessionId, { n: 200, type });
136
+ }
137
+
138
+ describe('RuntimeClient E2E — bridge connection sanity', () => {
139
+ it('connects, sends hello, opens a session in the store', () => {
140
+ const e = env!;
141
+ const sessions = e.store.listSessions({ projectId: 'rt-e2e', limit: 5 });
142
+ expect(sessions.length).toBeGreaterThanOrEqual(1);
143
+ expect(sessions.find((s) => s.id === e.sessionId)).toBeDefined();
144
+ });
145
+
146
+ it('daemon connection is denylisted from ws capture (no self-loop)', async () => {
147
+ const e = env!;
148
+ // No user code touched WebSocket yet, but the runtime opened its own
149
+ // ws to the daemon. If the patch were intercepting, the buffer would
150
+ // already have a `phase:'open'` entry for that URL.
151
+ const cap = getCaptureStore();
152
+ const selfFrames = cap.ws.tail(50).filter((f) =>
153
+ f.url.startsWith(`ws://127.0.0.1:${e.port}`),
154
+ );
155
+ expect(selfFrames).toHaveLength(0);
156
+ });
157
+ });
158
+
159
+ describe('RuntimeClient E2E — patched WebSocket flows to bridge', () => {
160
+ it('user-issued ws frames land in the store as t=ws', async () => {
161
+ const e = env!;
162
+ // happy-dom's WebSocket attempts real network, which we don't want.
163
+ // Replace the global WebSocket BEFORE we trigger user code, then the
164
+ // patched constructor wraps our fake instead.
165
+ const realWs = (window as unknown as { WebSocket: typeof WebSocket }).WebSocket;
166
+ class FakeWS extends EventTarget {
167
+ static readonly CONNECTING = 0;
168
+ static readonly OPEN = 1;
169
+ static readonly CLOSING = 2;
170
+ static readonly CLOSED = 3;
171
+ url: string;
172
+ readyState = 1;
173
+ constructor(url: string | URL) {
174
+ super();
175
+ this.url = typeof url === 'string' ? url : url.toString();
176
+ }
177
+ send(_data: unknown): void { /* swallow */ }
178
+ close(): void { this.readyState = 3; }
179
+ }
180
+ // Patched WebSocket caches the OriginalWS reference inside, but it
181
+ // was captured at install-time. We must point window.WebSocket to
182
+ // FakeWS BEFORE that — which means re-installing the patch. Easiest:
183
+ // tear down + re-install via a fresh dispose + capture install.
184
+ const cap = getCaptureStore();
185
+ cap.dispose();
186
+ (window as unknown as { WebSocket: typeof WebSocket }).WebSocket = FakeWS as unknown as typeof WebSocket;
187
+ cap.install(
188
+ (name, payload) => e.client.sendEvent(name, payload),
189
+ { daemonUrl: `ws://127.0.0.1:${e.port}` },
190
+ );
191
+
192
+ try {
193
+ const ws = new window.WebSocket('wss://chat.test/v1') as unknown as FakeWS;
194
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ kind: 'kick' }) }));
195
+ ws.send(JSON.stringify({ kind: 'ack' }));
196
+ ws.dispatchEvent(new CloseEvent('close', { code: 4001, reason: 'kicked', wasClean: false }));
197
+
198
+ const rows = await readTypedEvents(e.store, e.sessionId, 'ws', 3);
199
+ const phases = rows.map((r) => (r.d as WsEntry).phase);
200
+ expect(phases).toContain('open');
201
+ expect(phases).toContain('recv');
202
+ expect(phases).toContain('send');
203
+ expect(phases).toContain('close');
204
+
205
+ const close = rows.find((r) => (r.d as WsEntry).phase === 'close')!;
206
+ expect((close.d as WsEntry).code).toBe(4001);
207
+ // visitorId stamped on row by bridge ingestion.
208
+ expect(close.visitorId).toBe(e.client.visitorId);
209
+ } finally {
210
+ (window as unknown as { WebSocket: typeof WebSocket }).WebSocket = realWs;
211
+ }
212
+ });
213
+ });
214
+
215
+ describe('RuntimeClient E2E — patched storage flows to bridge', () => {
216
+ it('localStorage.setItem / removeItem reaches the store as t=storage', async () => {
217
+ const e = env!;
218
+ localStorage.setItem('Tanka_tokenInfo', 'abc');
219
+ localStorage.removeItem('Tanka_tokenInfo');
220
+
221
+ const rows = await readTypedEvents(e.store, e.sessionId, 'storage', 2);
222
+ const ops = rows.map((r) => (r.d as StorageEntry).op);
223
+ expect(ops).toContain('set');
224
+ expect(ops).toContain('remove');
225
+ const removeRow = rows.find((r) => (r.d as StorageEntry).op === 'remove')!;
226
+ expect((removeRow.d as StorageEntry).key).toBe('Tanka_tokenInfo');
227
+ // initiator stack survives the round-trip.
228
+ expect((removeRow.d as StorageEntry).initiator?.stack).toBeDefined();
229
+ });
230
+ });
231
+
232
+ describe('RuntimeClient E2E — patched fetch initiator round-trip', () => {
233
+ it('fetch() carries an initiator.stack into the store', async () => {
234
+ const e = env!;
235
+ // Stub fetch with a minimal mock — happy-dom's fetch would try real
236
+ // network, which we don't want.
237
+ const origFetch = window.fetch;
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
239
+ (window as any).fetch = async () => new Response('{}', {
240
+ status: 200,
241
+ headers: { 'content-type': 'application/json' },
242
+ });
243
+ const cap = getCaptureStore();
244
+ // Re-install so fetch patch wraps the new stub.
245
+ cap.dispose();
246
+ cap.install(
247
+ (name, payload) => e.client.sendEvent(name, payload),
248
+ { daemonUrl: `ws://127.0.0.1:${e.port}` },
249
+ );
250
+
251
+ try {
252
+ await window.fetch('http://api.test/users');
253
+ await new Promise((r) => setTimeout(r, 20));
254
+
255
+ const rows = await readTypedEvents(e.store, e.sessionId, 'network', 1);
256
+ const req = rows.find((r) => (r.d as NetworkEntry).phase === 'req')!;
257
+ expect(req).toBeDefined();
258
+ expect((req.d as NetworkEntry).initiator?.stack).toBeDefined();
259
+ } finally {
260
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
261
+ (window as any).fetch = origFetch;
262
+ }
263
+ });
264
+ });
@@ -1,39 +0,0 @@
1
- /**
2
- * fetch monkey-patch — captures URL/method/headers/body for request and
3
- * response, including streaming SSE responses, without changing
4
- * business-observable fetch behavior.
5
- *
6
- * Safety contract (do not weaken without updating the spec):
7
- * 1. Identity-preserving: replacement is a named `fetch`, with
8
- * defineProperty'd name/length/toString so library fingerprint checks
9
- * still pass. Response / Request instances are NOT wrapped.
10
- * 2. Error-isolated: capture failures are swallowed via `safeEmit`; they
11
- * NEVER propagate to business code.
12
- * 3. No timing or value change: the original Promise is returned to the
13
- * caller unchanged. body capture reads `response.clone()` on a side
14
- * branch — the business path retains an untouched stream.
15
- * 4. Self-traffic guard: requests carrying `init.__hfeInternal === true`
16
- * short-circuit to the original fetch. A URL denylist also skips HMR /
17
- * dev-server traffic to prevent capture feedback loops.
18
- * 5. Bounded memory: bodies are capped at BODY_CAP per request. SSE
19
- * streams stop accumulating and `cancel()` the cloned reader once
20
- * the cap is hit.
21
- *
22
- * The patch is idempotent (re-install is a no-op) and returns a dispose
23
- * function that restores the original `window.fetch`.
24
- */
25
- import type { NetworkEntry } from '@harness-fe/protocol';
26
- export interface FetchPatchOptions {
27
- /** Called once for each emitted record (request and response are separate calls). */
28
- onEntry: (entry: NetworkEntry) => void;
29
- /** Per-body byte cap. Default 256 KB. */
30
- bodyCap?: number;
31
- /** URL patterns to skip capture entirely. */
32
- denylist?: RegExp[];
33
- }
34
- /**
35
- * Install the fetch patch. Returns a dispose function that restores the
36
- * original window.fetch. Safe to call multiple times (subsequent calls
37
- * are no-ops while a patch is active).
38
- */
39
- export declare function installFetchPatch(opts: FetchPatchOptions): () => void;