@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.
- package/dist/capture.d.ts +36 -1
- package/dist/capture.js +40 -1
- package/dist/client.js +2 -1
- package/dist/commands.js +145 -3
- package/dist/fetchPatch.js +3 -0
- package/dist/initiator.d.ts +20 -0
- package/dist/initiator.js +34 -0
- package/dist/storagePatch.d.ts +23 -0
- package/dist/storagePatch.js +190 -0
- package/dist/wsPatch.d.ts +26 -0
- package/dist/wsPatch.js +172 -0
- package/dist/xhrPatch.js +3 -0
- package/package.json +2 -2
- package/src/capture.ts +47 -1
- package/src/client.ts +5 -1
- package/src/commands.ts +163 -6
- package/src/commandsFilter.test.ts +167 -0
- package/src/commandsNetwork.e2e.test.ts +146 -0
- package/src/fetchPatch.test.ts +14 -0
- package/src/fetchPatch.ts +3 -0
- package/src/initiator.ts +40 -0
- package/src/runtimeClient.e2e.test.ts +246 -0
- package/src/storagePatch.test.ts +137 -0
- package/src/storagePatch.ts +213 -0
- package/src/wsPatch.test.ts +150 -0
- package/src/wsPatch.ts +198 -0
- package/src/xhrPatch.ts +3 -0
|
@@ -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
|
+
});
|
package/src/fetchPatch.test.ts
CHANGED
|
@@ -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(
|
package/src/initiator.ts
ADDED
|
@@ -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
|
+
});
|