@harness-fe/runtime 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,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
+ });
@@ -101,6 +101,20 @@ describe('installFetchPatch — emission', () => {
101
101
  expect(res.responseBody).toEqual({ ok: true });
102
102
  });
103
103
 
104
+ it('stamps initiator.stack on req entries', async () => {
105
+ setMockFetch(() => Promise.resolve(jsonResponse({ ok: true })));
106
+ dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
107
+ await window.fetch('http://x/');
108
+ await new Promise((r) => setTimeout(r, 10));
109
+ const req = entries.find((e) => e.phase === 'req')!;
110
+ expect(req.initiator).toBeDefined();
111
+ // Stack should be a non-empty string when V8 produced one.
112
+ if (req.initiator?.stack !== undefined) {
113
+ expect(typeof req.initiator.stack).toBe('string');
114
+ expect(req.initiator.stack.length).toBeGreaterThan(0);
115
+ }
116
+ });
117
+
104
118
  it('captures and caps a large JSON response body', async () => {
105
119
  const huge = 'x'.repeat(2000);
106
120
  setMockFetch(() => Promise.resolve(jsonResponse({ s: huge })));
package/src/fetchPatch.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import type { NetworkEntry } from '@harness-fe/protocol';
27
+ import { captureInitiator } from './initiator.js';
27
28
 
28
29
  const DEFAULT_BODY_CAP = 256 * 1024;
29
30
  const INTERNAL_FLAG = '__hfeInternal';
@@ -76,6 +77,7 @@ export function installFetchPatch(opts: FetchPatchOptions): () => void {
76
77
  const id = generateId();
77
78
  const startedAt = performance.now();
78
79
  const startedTs = Date.now();
80
+ const initiator = captureInitiator();
79
81
 
80
82
  // Emit request record eagerly (req body is read async — second emit
81
83
  // updates the record once body is serialized; consumers join by id).
@@ -86,6 +88,7 @@ export function installFetchPatch(opts: FetchPatchOptions): () => void {
86
88
  method: meta.method,
87
89
  url: meta.url,
88
90
  requestHeaders: meta.headers,
91
+ initiator,
89
92
  };
90
93
  emit(reqRecord);
91
94
  cloneRequestBody(input, init, bodyCap).then(
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Initiator stack capture — answers "who issued this network/storage call?".
3
+ *
4
+ * Called synchronously from inside a patched API (fetch / xhr / ws / storage).
5
+ * `new Error().stack` walks the JS call stack from the V8 perspective:
6
+ * - frame 0: this helper
7
+ * - frame 1: the patched wrapper
8
+ * - frame 2+: caller code
9
+ *
10
+ * We trim the first 2 frames so the returned `stack` starts at the business
11
+ * code that triggered the call. Best-effort: shapes vary across engines, so
12
+ * if the format is unexpected we return the raw stack.
13
+ *
14
+ * Cost: ~0.2–0.5 ms per call on a modern V8. Safe to leave on in development;
15
+ * gated behind NODE_ENV elsewhere so production is unaffected.
16
+ */
17
+
18
+ const FRAMES_TO_TRIM = 2;
19
+
20
+ export interface Initiator {
21
+ stack?: string;
22
+ }
23
+
24
+ export function captureInitiator(): Initiator {
25
+ const err = new Error();
26
+ const raw = err.stack;
27
+ if (!raw) return {};
28
+
29
+ const lines = raw.split('\n');
30
+ if (lines.length <= FRAMES_TO_TRIM + 1) return { stack: raw };
31
+
32
+ // Preserve the "Error" header line + caller frames. Drop the frames
33
+ // representing the helper and the patched wrapper.
34
+ const header = lines[0].startsWith('Error') ? lines[0] : '';
35
+ const callerFrames = lines.slice(FRAMES_TO_TRIM + 1);
36
+ const trimmed = header
37
+ ? [header, ...callerFrames].join('\n')
38
+ : callerFrames.join('\n');
39
+ return { stack: trimmed };
40
+ }
@@ -0,0 +1,246 @@
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 setup(): Promise<Env> {
46
+ const dir = mkdtempSync(join(tmpdir(), 'harness-rt-e2e-'));
47
+ const store = new JsonlStore(dir);
48
+ const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
49
+ await bridge.start();
50
+ const port = bridge.getBoundPort();
51
+ if (!port) throw new Error('no port');
52
+
53
+ // happy-dom keeps singletons across tests — reset the patch state so this
54
+ // RuntimeClient's onEvent wiring takes hold.
55
+ getCaptureStore().dispose();
56
+
57
+ const client = new RuntimeClient({
58
+ projectId: 'rt-e2e',
59
+ mcpUrl: `ws://127.0.0.1:${port}`,
60
+ });
61
+ client.start();
62
+
63
+ // Wait for hello.ack — bridge logs `peer connected` when the runtime is
64
+ // registered. We poll the store's session list until ours shows up.
65
+ const deadline = Date.now() + 2000;
66
+ while (Date.now() < deadline) {
67
+ const sessions = store.listSessions({ projectId: 'rt-e2e', limit: 5 });
68
+ if (sessions.length > 0 && client.getConnectionState() === 'open') {
69
+ break;
70
+ }
71
+ await new Promise((r) => setTimeout(r, 30));
72
+ }
73
+ if (client.getConnectionState() !== 'open') {
74
+ throw new Error('runtime-client never connected');
75
+ }
76
+
77
+ return { bridge, store, dir, port, client, sessionId: client.sessionId };
78
+ }
79
+
80
+ beforeEach(async () => {
81
+ env = await setup();
82
+ });
83
+
84
+ afterEach(async () => {
85
+ if (!env) return;
86
+ env.client.stop();
87
+ await env.bridge.stop();
88
+ env.store.close();
89
+ rmSync(env.dir, { recursive: true, force: true });
90
+ // Reset capture singleton so subsequent tests get a clean install.
91
+ const cap = getCaptureStore();
92
+ cap.dispose();
93
+ cap.console.clear();
94
+ cap.network.clear();
95
+ cap.errors.clear();
96
+ cap.ws.clear();
97
+ cap.storage.clear();
98
+ env = undefined;
99
+ });
100
+
101
+ /** Read events of a given type from the session timeline, polling for flush. */
102
+ async function readTypedEvents(
103
+ store: JsonlStore,
104
+ sessionId: string,
105
+ type: string,
106
+ expectedMin: number,
107
+ timeoutMs = 1500,
108
+ ): Promise<StoreEvent[]> {
109
+ const deadline = Date.now() + timeoutMs;
110
+ while (Date.now() < deadline) {
111
+ await store.flush();
112
+ const rows = store.tail(sessionId, { n: 200, type });
113
+ if (rows.length >= expectedMin) return rows;
114
+ await new Promise((r) => setTimeout(r, 30));
115
+ }
116
+ await store.flush();
117
+ return store.tail(sessionId, { n: 200, type });
118
+ }
119
+
120
+ describe('RuntimeClient E2E — bridge connection sanity', () => {
121
+ it('connects, sends hello, opens a session in the store', () => {
122
+ const e = env!;
123
+ const sessions = e.store.listSessions({ projectId: 'rt-e2e', limit: 5 });
124
+ expect(sessions.length).toBeGreaterThanOrEqual(1);
125
+ expect(sessions.find((s) => s.id === e.sessionId)).toBeDefined();
126
+ });
127
+
128
+ it('daemon connection is denylisted from ws capture (no self-loop)', async () => {
129
+ const e = env!;
130
+ // No user code touched WebSocket yet, but the runtime opened its own
131
+ // ws to the daemon. If the patch were intercepting, the buffer would
132
+ // already have a `phase:'open'` entry for that URL.
133
+ const cap = getCaptureStore();
134
+ const selfFrames = cap.ws.tail(50).filter((f) =>
135
+ f.url.startsWith(`ws://127.0.0.1:${e.port}`),
136
+ );
137
+ expect(selfFrames).toHaveLength(0);
138
+ });
139
+ });
140
+
141
+ describe('RuntimeClient E2E — patched WebSocket flows to bridge', () => {
142
+ it('user-issued ws frames land in the store as t=ws', async () => {
143
+ const e = env!;
144
+ // happy-dom's WebSocket attempts real network, which we don't want.
145
+ // Replace the global WebSocket BEFORE we trigger user code, then the
146
+ // patched constructor wraps our fake instead.
147
+ const realWs = (window as unknown as { WebSocket: typeof WebSocket }).WebSocket;
148
+ class FakeWS extends EventTarget {
149
+ static readonly CONNECTING = 0;
150
+ static readonly OPEN = 1;
151
+ static readonly CLOSING = 2;
152
+ static readonly CLOSED = 3;
153
+ url: string;
154
+ readyState = 1;
155
+ constructor(url: string | URL) {
156
+ super();
157
+ this.url = typeof url === 'string' ? url : url.toString();
158
+ }
159
+ send(_data: unknown): void { /* swallow */ }
160
+ close(): void { this.readyState = 3; }
161
+ }
162
+ // Patched WebSocket caches the OriginalWS reference inside, but it
163
+ // was captured at install-time. We must point window.WebSocket to
164
+ // FakeWS BEFORE that — which means re-installing the patch. Easiest:
165
+ // tear down + re-install via a fresh dispose + capture install.
166
+ const cap = getCaptureStore();
167
+ cap.dispose();
168
+ (window as unknown as { WebSocket: typeof WebSocket }).WebSocket = FakeWS as unknown as typeof WebSocket;
169
+ cap.install(
170
+ (name, payload) => e.client.sendEvent(name, payload),
171
+ { daemonUrl: `ws://127.0.0.1:${e.port}` },
172
+ );
173
+
174
+ try {
175
+ const ws = new window.WebSocket('wss://chat.test/v1') as unknown as FakeWS;
176
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ kind: 'kick' }) }));
177
+ ws.send(JSON.stringify({ kind: 'ack' }));
178
+ ws.dispatchEvent(new CloseEvent('close', { code: 4001, reason: 'kicked', wasClean: false }));
179
+
180
+ const rows = await readTypedEvents(e.store, e.sessionId, 'ws', 3);
181
+ const phases = rows.map((r) => (r.d as WsEntry).phase);
182
+ expect(phases).toContain('open');
183
+ expect(phases).toContain('recv');
184
+ expect(phases).toContain('send');
185
+ expect(phases).toContain('close');
186
+
187
+ const close = rows.find((r) => (r.d as WsEntry).phase === 'close')!;
188
+ expect((close.d as WsEntry).code).toBe(4001);
189
+ // visitorId stamped on row by bridge ingestion.
190
+ expect(close.visitorId).toBe(e.client.visitorId);
191
+ } finally {
192
+ (window as unknown as { WebSocket: typeof WebSocket }).WebSocket = realWs;
193
+ }
194
+ });
195
+ });
196
+
197
+ describe('RuntimeClient E2E — patched storage flows to bridge', () => {
198
+ it('localStorage.setItem / removeItem reaches the store as t=storage', async () => {
199
+ const e = env!;
200
+ localStorage.setItem('Tanka_tokenInfo', 'abc');
201
+ localStorage.removeItem('Tanka_tokenInfo');
202
+
203
+ const rows = await readTypedEvents(e.store, e.sessionId, 'storage', 2);
204
+ const ops = rows.map((r) => (r.d as StorageEntry).op);
205
+ expect(ops).toContain('set');
206
+ expect(ops).toContain('remove');
207
+ const removeRow = rows.find((r) => (r.d as StorageEntry).op === 'remove')!;
208
+ expect((removeRow.d as StorageEntry).key).toBe('Tanka_tokenInfo');
209
+ // initiator stack survives the round-trip.
210
+ expect((removeRow.d as StorageEntry).initiator?.stack).toBeDefined();
211
+ });
212
+ });
213
+
214
+ describe('RuntimeClient E2E — patched fetch initiator round-trip', () => {
215
+ it('fetch() carries an initiator.stack into the store', async () => {
216
+ const e = env!;
217
+ // Stub fetch with a minimal mock — happy-dom's fetch would try real
218
+ // network, which we don't want.
219
+ const origFetch = window.fetch;
220
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
+ (window as any).fetch = async () => new Response('{}', {
222
+ status: 200,
223
+ headers: { 'content-type': 'application/json' },
224
+ });
225
+ const cap = getCaptureStore();
226
+ // Re-install so fetch patch wraps the new stub.
227
+ cap.dispose();
228
+ cap.install(
229
+ (name, payload) => e.client.sendEvent(name, payload),
230
+ { daemonUrl: `ws://127.0.0.1:${e.port}` },
231
+ );
232
+
233
+ try {
234
+ await window.fetch('http://api.test/users');
235
+ await new Promise((r) => setTimeout(r, 20));
236
+
237
+ const rows = await readTypedEvents(e.store, e.sessionId, 'network', 1);
238
+ const req = rows.find((r) => (r.d as NetworkEntry).phase === 'req')!;
239
+ expect(req).toBeDefined();
240
+ expect((req.d as NetworkEntry).initiator?.stack).toBeDefined();
241
+ } finally {
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
243
+ (window as any).fetch = origFetch;
244
+ }
245
+ });
246
+ });
@@ -0,0 +1,137 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import { installStoragePatch } from './storagePatch.js';
4
+ import type { StorageEntry } from '@harness-fe/protocol';
5
+
6
+ let entries: StorageEntry[];
7
+ let dispose: () => void;
8
+
9
+ beforeEach(() => {
10
+ entries = [];
11
+ try {
12
+ window.localStorage.clear();
13
+ window.sessionStorage.clear();
14
+ } catch {
15
+ /* ignore */
16
+ }
17
+ });
18
+
19
+ afterEach(() => {
20
+ dispose?.();
21
+ });
22
+
23
+ describe('installStoragePatch', () => {
24
+ it('captures localStorage.setItem with initiator stack', () => {
25
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
26
+ localStorage.setItem('token', 'abc123');
27
+ const entry = entries.find((e) => e.op === 'set' && e.which === 'local')!;
28
+ expect(entry).toBeDefined();
29
+ expect(entry.key).toBe('token');
30
+ expect(entry.value).toBe('abc123');
31
+ expect(entry.initiator).toBeDefined();
32
+ // setItem still actually wrote.
33
+ expect(localStorage.getItem('token')).toBe('abc123');
34
+ });
35
+
36
+ it('captures localStorage.removeItem', () => {
37
+ localStorage.setItem('token', 'x');
38
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
39
+ localStorage.removeItem('token');
40
+ const entry = entries.find((e) => e.op === 'remove')!;
41
+ expect(entry).toBeDefined();
42
+ expect(entry.key).toBe('token');
43
+ expect(entry.which).toBe('local');
44
+ expect(localStorage.getItem('token')).toBeNull();
45
+ });
46
+
47
+ it('captures localStorage.clear', () => {
48
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
49
+ localStorage.setItem('a', '1');
50
+ entries.length = 0;
51
+ localStorage.clear();
52
+ const entry = entries.find((e) => e.op === 'clear')!;
53
+ expect(entry).toBeDefined();
54
+ expect(entry.which).toBe('local');
55
+ });
56
+
57
+ it('disambiguates sessionStorage vs localStorage when they are distinct instances', () => {
58
+ // happy-dom's sessionStorage and localStorage may share an underlying
59
+ // instance, in which case the patch dispatches once with kind='local'.
60
+ // We only assert the disambiguation in environments where the two are
61
+ // genuinely distinct objects.
62
+ if (window.sessionStorage === window.localStorage) {
63
+ return;
64
+ }
65
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
66
+ sessionStorage.setItem('s', '1');
67
+ localStorage.setItem('l', '1');
68
+ const session = entries.find((e) => e.which === 'session');
69
+ const local = entries.find((e) => e.which === 'local');
70
+ expect(session?.key).toBe('s');
71
+ expect(local?.key).toBe('l');
72
+ });
73
+
74
+ it('clips oversized values', () => {
75
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e), valueCap: 5 });
76
+ localStorage.setItem('big', 'x'.repeat(50));
77
+ const entry = entries.find((e) => e.key === 'big')!;
78
+ expect(entry.value?.startsWith('xxxxx')).toBe(true);
79
+ expect(entry.value?.includes('+45B')).toBe(true);
80
+ });
81
+
82
+ it('captures crossTab event without initiator', () => {
83
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
84
+ // Simulate cross-tab event by dispatching a StorageEvent manually.
85
+ const ev = new StorageEvent('storage', {
86
+ key: 'token',
87
+ newValue: null,
88
+ oldValue: 'x',
89
+ storageArea: window.localStorage,
90
+ });
91
+ window.dispatchEvent(ev);
92
+ const entry = entries.find((e) => e.crossTab)!;
93
+ expect(entry).toBeDefined();
94
+ expect(entry.op).toBe('remove');
95
+ expect(entry.which).toBe('local');
96
+ expect(entry.initiator).toBeUndefined();
97
+ });
98
+
99
+ it('captures cookie set / remove via Max-Age=0 when document.cookie is descriptor-backed', () => {
100
+ // happy-dom may not expose document.cookie via Document.prototype with
101
+ // a property descriptor. We skip when the descriptor isn't writable
102
+ // since the patch path is provably unreachable in that environment.
103
+ const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
104
+ if (!desc?.set || !desc?.get) return;
105
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
106
+ document.cookie = 'sid=abc; Path=/';
107
+ document.cookie = 'sid=; Max-Age=0; Path=/';
108
+ const sets = entries.filter((e) => e.which === 'cookie' && e.op === 'set');
109
+ const removes = entries.filter((e) => e.which === 'cookie' && e.op === 'remove');
110
+ expect(sets.length).toBeGreaterThanOrEqual(1);
111
+ expect(removes.length).toBeGreaterThanOrEqual(1);
112
+ expect(sets[0].key).toBe('sid');
113
+ expect(removes[0].key).toBe('sid');
114
+ });
115
+
116
+ it('parses cookie removal via past Expires', () => {
117
+ const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
118
+ if (!desc?.set || !desc?.get) return;
119
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
120
+ document.cookie = 'sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/';
121
+ const removes = entries.filter((e) => e.which === 'cookie' && e.op === 'remove');
122
+ expect(removes.length).toBeGreaterThanOrEqual(1);
123
+ });
124
+
125
+ it('dispose stops capture and leaves storage operations working', () => {
126
+ dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
127
+ localStorage.setItem('foo', 'bar');
128
+ expect(entries.length).toBeGreaterThan(0);
129
+ const before = entries.length;
130
+ dispose();
131
+ // After dispose, further mutations should not push new entries.
132
+ localStorage.setItem('foo2', 'bar2');
133
+ expect(entries.length).toBe(before);
134
+ // But the write itself still happened.
135
+ expect(localStorage.getItem('foo2')).toBe('bar2');
136
+ });
137
+ });