@sensaiorg/adapter-chrome 0.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/chrome-adapter.d.ts.map +1 -0
- package/dist/chrome-adapter.js +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/tools/chrome-tools.d.ts.map +1 -0
- package/dist/tools/chrome-tools.js +583 -0
- package/dist/tools/chrome-tools.test.d.ts.map +1 -0
- package/dist/tools/chrome-tools.test.js +442 -0
- package/dist/transport/cdp-bridge.d.ts.map +1 -0
- package/dist/transport/cdp-bridge.js +163 -0
- package/package.json +23 -0
- package/src/chrome-adapter.ts +117 -0
- package/src/index.ts +8 -0
- package/src/tools/chrome-tools.test.ts +547 -0
- package/src/tools/chrome-tools.ts +743 -0
- package/src/transport/cdp-bridge.ts +187 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for CdpEventCollector.
|
|
4
|
+
*
|
|
5
|
+
* CdpEventCollector is already exported from chrome-tools.ts, no modifications needed.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const vitest_1 = require("vitest");
|
|
9
|
+
const chrome_tools_js_1 = require("./chrome-tools.js");
|
|
10
|
+
/** Creates a minimal CdpBridge mock that captures event handlers. */
|
|
11
|
+
function createMockBridge() {
|
|
12
|
+
const handlers = new Map();
|
|
13
|
+
const bridge = {
|
|
14
|
+
onEvent: vitest_1.vi.fn((method, handler) => {
|
|
15
|
+
const list = handlers.get(method) ?? [];
|
|
16
|
+
list.push(handler);
|
|
17
|
+
handlers.set(method, list);
|
|
18
|
+
}),
|
|
19
|
+
send: vitest_1.vi.fn(async () => undefined),
|
|
20
|
+
};
|
|
21
|
+
/** Simulate a CDP event arriving. */
|
|
22
|
+
function emit(method, params) {
|
|
23
|
+
for (const h of handlers.get(method) ?? []) {
|
|
24
|
+
h(params);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { bridge, emit };
|
|
28
|
+
}
|
|
29
|
+
/** Enable domains on a collector with the mock bridge. */
|
|
30
|
+
async function setup() {
|
|
31
|
+
const collector = new chrome_tools_js_1.CdpEventCollector();
|
|
32
|
+
const { bridge, emit } = createMockBridge();
|
|
33
|
+
await collector.enableDomains(bridge);
|
|
34
|
+
return { collector, bridge, emit };
|
|
35
|
+
}
|
|
36
|
+
/* ── Console message buffering ────────────────────────────────────── */
|
|
37
|
+
(0, vitest_1.describe)("CdpEventCollector", () => {
|
|
38
|
+
(0, vitest_1.describe)("console messages", () => {
|
|
39
|
+
(0, vitest_1.it)("buffers a console log with timestamp", async () => {
|
|
40
|
+
const { collector, emit } = await setup();
|
|
41
|
+
emit("Runtime.consoleAPICalled", {
|
|
42
|
+
type: "log",
|
|
43
|
+
args: [{ value: "hello world" }],
|
|
44
|
+
timestamp: 1000,
|
|
45
|
+
});
|
|
46
|
+
const logs = collector.getConsoleLogs();
|
|
47
|
+
(0, vitest_1.expect)(logs).toHaveLength(1);
|
|
48
|
+
(0, vitest_1.expect)(logs[0]).toEqual({
|
|
49
|
+
level: "log",
|
|
50
|
+
text: "hello world",
|
|
51
|
+
timestamp: 1000,
|
|
52
|
+
url: undefined,
|
|
53
|
+
line: undefined,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
(0, vitest_1.it)("concatenates multiple args with a space", async () => {
|
|
57
|
+
const { collector, emit } = await setup();
|
|
58
|
+
emit("Runtime.consoleAPICalled", {
|
|
59
|
+
type: "log",
|
|
60
|
+
args: [{ value: "a" }, { value: "b" }, { description: "c-desc" }],
|
|
61
|
+
timestamp: 2000,
|
|
62
|
+
});
|
|
63
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()[0].text).toBe("a b c-desc");
|
|
64
|
+
});
|
|
65
|
+
(0, vitest_1.it)("captures source location from stackTrace", async () => {
|
|
66
|
+
const { collector, emit } = await setup();
|
|
67
|
+
emit("Runtime.consoleAPICalled", {
|
|
68
|
+
type: "warn",
|
|
69
|
+
args: [{ value: "warning" }],
|
|
70
|
+
timestamp: 3000,
|
|
71
|
+
stackTrace: {
|
|
72
|
+
callFrames: [{ url: "https://example.com/app.js", lineNumber: 42 }],
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const entry = collector.getConsoleLogs()[0];
|
|
76
|
+
(0, vitest_1.expect)(entry.url).toBe("https://example.com/app.js");
|
|
77
|
+
(0, vitest_1.expect)(entry.line).toBe(42);
|
|
78
|
+
});
|
|
79
|
+
(0, vitest_1.it)("buffers Runtime.exceptionThrown as error level", async () => {
|
|
80
|
+
const { collector, emit } = await setup();
|
|
81
|
+
emit("Runtime.exceptionThrown", {
|
|
82
|
+
timestamp: 5000,
|
|
83
|
+
exceptionDetails: {
|
|
84
|
+
text: "Uncaught Error",
|
|
85
|
+
exception: { description: "TypeError: x is not a function" },
|
|
86
|
+
url: "https://example.com/main.js",
|
|
87
|
+
lineNumber: 10,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const logs = collector.getConsoleLogs();
|
|
91
|
+
(0, vitest_1.expect)(logs).toHaveLength(1);
|
|
92
|
+
(0, vitest_1.expect)(logs[0].level).toBe("error");
|
|
93
|
+
(0, vitest_1.expect)(logs[0].text).toBe("TypeError: x is not a function");
|
|
94
|
+
(0, vitest_1.expect)(logs[0].url).toBe("https://example.com/main.js");
|
|
95
|
+
(0, vitest_1.expect)(logs[0].line).toBe(10);
|
|
96
|
+
});
|
|
97
|
+
(0, vitest_1.it)("falls back to text when exception description is missing", async () => {
|
|
98
|
+
const { collector, emit } = await setup();
|
|
99
|
+
emit("Runtime.exceptionThrown", {
|
|
100
|
+
timestamp: 5000,
|
|
101
|
+
exceptionDetails: { text: "Uncaught SyntaxError" },
|
|
102
|
+
});
|
|
103
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()[0].text).toBe("Uncaught SyntaxError");
|
|
104
|
+
});
|
|
105
|
+
(0, vitest_1.it)("falls back to 'Unknown exception' when all fields missing", async () => {
|
|
106
|
+
const { collector, emit } = await setup();
|
|
107
|
+
emit("Runtime.exceptionThrown", { timestamp: 5000 });
|
|
108
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()[0].text).toBe("Unknown exception");
|
|
109
|
+
});
|
|
110
|
+
(0, vitest_1.it)("getConsoleLogs() returns all buffered messages", async () => {
|
|
111
|
+
const { collector, emit } = await setup();
|
|
112
|
+
for (let i = 0; i < 10; i++) {
|
|
113
|
+
emit("Runtime.consoleAPICalled", {
|
|
114
|
+
type: "log",
|
|
115
|
+
args: [{ value: `msg-${i}` }],
|
|
116
|
+
timestamp: 1000 + i,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()).toHaveLength(10);
|
|
120
|
+
});
|
|
121
|
+
(0, vitest_1.it)("getConsoleLogs(maxCount) returns the most recent entries", async () => {
|
|
122
|
+
const { collector, emit } = await setup();
|
|
123
|
+
for (let i = 0; i < 10; i++) {
|
|
124
|
+
emit("Runtime.consoleAPICalled", {
|
|
125
|
+
type: "log",
|
|
126
|
+
args: [{ value: `msg-${i}` }],
|
|
127
|
+
timestamp: 1000 + i,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const last3 = collector.getConsoleLogs(3);
|
|
131
|
+
(0, vitest_1.expect)(last3).toHaveLength(3);
|
|
132
|
+
(0, vitest_1.expect)(last3[0].text).toBe("msg-7");
|
|
133
|
+
(0, vitest_1.expect)(last3[2].text).toBe("msg-9");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
/* ── Console buffer size limit ──────────────────────────────────── */
|
|
137
|
+
(0, vitest_1.describe)("console buffer eviction", () => {
|
|
138
|
+
(0, vitest_1.it)("enforces max buffer size of 500, evicting oldest entries", async () => {
|
|
139
|
+
const { collector, emit } = await setup();
|
|
140
|
+
for (let i = 0; i < 520; i++) {
|
|
141
|
+
emit("Runtime.consoleAPICalled", {
|
|
142
|
+
type: "log",
|
|
143
|
+
args: [{ value: `entry-${i}` }],
|
|
144
|
+
timestamp: i,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const logs = collector.getConsoleLogs();
|
|
148
|
+
(0, vitest_1.expect)(logs).toHaveLength(500);
|
|
149
|
+
// Oldest 20 entries (0..19) should have been evicted
|
|
150
|
+
(0, vitest_1.expect)(logs[0].text).toBe("entry-20");
|
|
151
|
+
(0, vitest_1.expect)(logs[499].text).toBe("entry-519");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
/* ── Network request buffering ──────────────────────────────────── */
|
|
155
|
+
(0, vitest_1.describe)("network requests", () => {
|
|
156
|
+
(0, vitest_1.it)("buffers a network request with timing info", async () => {
|
|
157
|
+
const { collector, emit } = await setup();
|
|
158
|
+
emit("Network.requestWillBeSent", {
|
|
159
|
+
requestId: "r1",
|
|
160
|
+
request: { method: "GET", url: "https://api.example.com/data" },
|
|
161
|
+
type: "Fetch",
|
|
162
|
+
timestamp: 100.5,
|
|
163
|
+
});
|
|
164
|
+
emit("Network.responseReceived", {
|
|
165
|
+
requestId: "r1",
|
|
166
|
+
response: { status: 200, statusText: "OK" },
|
|
167
|
+
timestamp: 100.8,
|
|
168
|
+
});
|
|
169
|
+
emit("Network.loadingFinished", {
|
|
170
|
+
requestId: "r1",
|
|
171
|
+
encodedDataLength: 1234,
|
|
172
|
+
timestamp: 100.9,
|
|
173
|
+
});
|
|
174
|
+
const entries = collector.getNetworkEntries();
|
|
175
|
+
(0, vitest_1.expect)(entries).toHaveLength(1);
|
|
176
|
+
(0, vitest_1.expect)(entries[0]).toMatchObject({
|
|
177
|
+
requestId: "r1",
|
|
178
|
+
method: "GET",
|
|
179
|
+
url: "https://api.example.com/data",
|
|
180
|
+
status: 200,
|
|
181
|
+
statusText: "OK",
|
|
182
|
+
type: "Fetch",
|
|
183
|
+
startTime: 100.5,
|
|
184
|
+
endTime: 100.9,
|
|
185
|
+
encodedDataLength: 1234,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
(0, vitest_1.it)("records network errors via loadingFailed", async () => {
|
|
189
|
+
const { collector, emit } = await setup();
|
|
190
|
+
emit("Network.requestWillBeSent", {
|
|
191
|
+
requestId: "r2",
|
|
192
|
+
request: { method: "POST", url: "https://api.example.com/fail" },
|
|
193
|
+
timestamp: 200,
|
|
194
|
+
});
|
|
195
|
+
emit("Network.loadingFailed", {
|
|
196
|
+
requestId: "r2",
|
|
197
|
+
errorText: "net::ERR_CONNECTION_REFUSED",
|
|
198
|
+
timestamp: 201,
|
|
199
|
+
});
|
|
200
|
+
const entries = collector.getNetworkEntries();
|
|
201
|
+
(0, vitest_1.expect)(entries).toHaveLength(1);
|
|
202
|
+
(0, vitest_1.expect)(entries[0].error).toBe("net::ERR_CONNECTION_REFUSED");
|
|
203
|
+
(0, vitest_1.expect)(entries[0].endTime).toBe(201);
|
|
204
|
+
});
|
|
205
|
+
(0, vitest_1.it)("getNetworkEntries() returns all buffered requests", async () => {
|
|
206
|
+
const { collector, emit } = await setup();
|
|
207
|
+
for (let i = 0; i < 5; i++) {
|
|
208
|
+
emit("Network.requestWillBeSent", {
|
|
209
|
+
requestId: `r${i}`,
|
|
210
|
+
request: { method: "GET", url: `https://example.com/${i}` },
|
|
211
|
+
timestamp: i,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
(0, vitest_1.expect)(collector.getNetworkEntries()).toHaveLength(5);
|
|
215
|
+
});
|
|
216
|
+
(0, vitest_1.it)("getNetworkEntries(maxCount) returns the most recent entries", async () => {
|
|
217
|
+
const { collector, emit } = await setup();
|
|
218
|
+
for (let i = 0; i < 10; i++) {
|
|
219
|
+
emit("Network.requestWillBeSent", {
|
|
220
|
+
requestId: `r${i}`,
|
|
221
|
+
request: { method: "GET", url: `https://example.com/${i}` },
|
|
222
|
+
timestamp: i,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const last3 = collector.getNetworkEntries(3);
|
|
226
|
+
(0, vitest_1.expect)(last3).toHaveLength(3);
|
|
227
|
+
(0, vitest_1.expect)(last3[0].url).toBe("https://example.com/7");
|
|
228
|
+
(0, vitest_1.expect)(last3[2].url).toBe("https://example.com/9");
|
|
229
|
+
});
|
|
230
|
+
(0, vitest_1.it)("ignores response/finished events for unknown requestIds", async () => {
|
|
231
|
+
const { collector, emit } = await setup();
|
|
232
|
+
// These should not throw or create entries
|
|
233
|
+
emit("Network.responseReceived", {
|
|
234
|
+
requestId: "unknown",
|
|
235
|
+
response: { status: 200, statusText: "OK" },
|
|
236
|
+
timestamp: 1,
|
|
237
|
+
});
|
|
238
|
+
emit("Network.loadingFinished", {
|
|
239
|
+
requestId: "unknown",
|
|
240
|
+
encodedDataLength: 100,
|
|
241
|
+
timestamp: 2,
|
|
242
|
+
});
|
|
243
|
+
emit("Network.loadingFailed", {
|
|
244
|
+
requestId: "unknown",
|
|
245
|
+
errorText: "error",
|
|
246
|
+
timestamp: 3,
|
|
247
|
+
});
|
|
248
|
+
(0, vitest_1.expect)(collector.getNetworkEntries()).toHaveLength(0);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
/* ── Network buffer eviction ────────────────────────────────────── */
|
|
252
|
+
(0, vitest_1.describe)("network buffer eviction", () => {
|
|
253
|
+
(0, vitest_1.it)("enforces max buffer size of 500, evicting oldest entries", async () => {
|
|
254
|
+
const { collector, emit } = await setup();
|
|
255
|
+
for (let i = 0; i < 520; i++) {
|
|
256
|
+
emit("Network.requestWillBeSent", {
|
|
257
|
+
requestId: `r${i}`,
|
|
258
|
+
request: { method: "GET", url: `https://example.com/${i}` },
|
|
259
|
+
timestamp: i,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const entries = collector.getNetworkEntries();
|
|
263
|
+
(0, vitest_1.expect)(entries).toHaveLength(500);
|
|
264
|
+
// Oldest 20 entries should be evicted
|
|
265
|
+
(0, vitest_1.expect)(entries[0].url).toBe("https://example.com/20");
|
|
266
|
+
(0, vitest_1.expect)(entries[499].url).toBe("https://example.com/519");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
/* ── Filter by level ────────────────────────────────────────────── */
|
|
270
|
+
(0, vitest_1.describe)("filtering console logs by level", () => {
|
|
271
|
+
(0, vitest_1.it)("stores different levels and allows external filtering", async () => {
|
|
272
|
+
const { collector, emit } = await setup();
|
|
273
|
+
emit("Runtime.consoleAPICalled", {
|
|
274
|
+
type: "log",
|
|
275
|
+
args: [{ value: "info msg" }],
|
|
276
|
+
timestamp: 1,
|
|
277
|
+
});
|
|
278
|
+
emit("Runtime.consoleAPICalled", {
|
|
279
|
+
type: "warn",
|
|
280
|
+
args: [{ value: "warn msg" }],
|
|
281
|
+
timestamp: 2,
|
|
282
|
+
});
|
|
283
|
+
emit("Runtime.consoleAPICalled", {
|
|
284
|
+
type: "error",
|
|
285
|
+
args: [{ value: "error msg" }],
|
|
286
|
+
timestamp: 3,
|
|
287
|
+
});
|
|
288
|
+
const all = collector.getConsoleLogs();
|
|
289
|
+
(0, vitest_1.expect)(all).toHaveLength(3);
|
|
290
|
+
// Filter externally (as the tool chrome_get_console_logs does)
|
|
291
|
+
const errors = all.filter((e) => e.level === "error");
|
|
292
|
+
(0, vitest_1.expect)(errors).toHaveLength(1);
|
|
293
|
+
(0, vitest_1.expect)(errors[0].text).toBe("error msg");
|
|
294
|
+
const warnings = all.filter((e) => e.level === "warn");
|
|
295
|
+
(0, vitest_1.expect)(warnings).toHaveLength(1);
|
|
296
|
+
(0, vitest_1.expect)(warnings[0].text).toBe("warn msg");
|
|
297
|
+
const logs = all.filter((e) => e.level === "log");
|
|
298
|
+
(0, vitest_1.expect)(logs).toHaveLength(1);
|
|
299
|
+
(0, vitest_1.expect)(logs[0].text).toBe("info msg");
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
/* ── enableDomains idempotency ──────────────────────────────────── */
|
|
303
|
+
(0, vitest_1.describe)("enableDomains", () => {
|
|
304
|
+
(0, vitest_1.it)("sends Runtime.enable, Network.enable, Page.enable", async () => {
|
|
305
|
+
const collector = new chrome_tools_js_1.CdpEventCollector();
|
|
306
|
+
const { bridge } = createMockBridge();
|
|
307
|
+
await collector.enableDomains(bridge);
|
|
308
|
+
(0, vitest_1.expect)(bridge.send).toHaveBeenCalledWith("Runtime.enable");
|
|
309
|
+
(0, vitest_1.expect)(bridge.send).toHaveBeenCalledWith("Network.enable");
|
|
310
|
+
(0, vitest_1.expect)(bridge.send).toHaveBeenCalledWith("Page.enable");
|
|
311
|
+
});
|
|
312
|
+
(0, vitest_1.it)("only enables domains once (idempotent)", async () => {
|
|
313
|
+
const collector = new chrome_tools_js_1.CdpEventCollector();
|
|
314
|
+
const { bridge } = createMockBridge();
|
|
315
|
+
await collector.enableDomains(bridge);
|
|
316
|
+
await collector.enableDomains(bridge);
|
|
317
|
+
// send should have been called exactly 3 times (Runtime, Network, Page)
|
|
318
|
+
(0, vitest_1.expect)(bridge.send).toHaveBeenCalledTimes(3);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
/* ── Fresh instance (clear equivalent) ──────────────────────────── */
|
|
322
|
+
(0, vitest_1.describe)("reset / clear equivalent", () => {
|
|
323
|
+
(0, vitest_1.it)("a new CdpEventCollector instance starts with empty buffers", () => {
|
|
324
|
+
const collector = new chrome_tools_js_1.CdpEventCollector();
|
|
325
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()).toHaveLength(0);
|
|
326
|
+
(0, vitest_1.expect)(collector.getNetworkEntries()).toHaveLength(0);
|
|
327
|
+
});
|
|
328
|
+
(0, vitest_1.it)("creating a new instance effectively clears all state", async () => {
|
|
329
|
+
const { collector, emit } = await setup();
|
|
330
|
+
emit("Runtime.consoleAPICalled", {
|
|
331
|
+
type: "log",
|
|
332
|
+
args: [{ value: "test" }],
|
|
333
|
+
timestamp: 1,
|
|
334
|
+
});
|
|
335
|
+
emit("Network.requestWillBeSent", {
|
|
336
|
+
requestId: "r1",
|
|
337
|
+
request: { method: "GET", url: "https://example.com" },
|
|
338
|
+
timestamp: 1,
|
|
339
|
+
});
|
|
340
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()).toHaveLength(1);
|
|
341
|
+
(0, vitest_1.expect)(collector.getNetworkEntries()).toHaveLength(1);
|
|
342
|
+
// "Reset" by creating a new collector
|
|
343
|
+
const freshCollector = new chrome_tools_js_1.CdpEventCollector();
|
|
344
|
+
(0, vitest_1.expect)(freshCollector.getConsoleLogs()).toHaveLength(0);
|
|
345
|
+
(0, vitest_1.expect)(freshCollector.getNetworkEntries()).toHaveLength(0);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
/* ── Rapid concurrent events ────────────────────────────────────── */
|
|
349
|
+
(0, vitest_1.describe)("rapid concurrent events", () => {
|
|
350
|
+
(0, vitest_1.it)("handles many console and network events fired in rapid succession", async () => {
|
|
351
|
+
const { collector, emit } = await setup();
|
|
352
|
+
// Fire 200 console + 200 network events synchronously
|
|
353
|
+
for (let i = 0; i < 200; i++) {
|
|
354
|
+
emit("Runtime.consoleAPICalled", {
|
|
355
|
+
type: i % 3 === 0 ? "error" : i % 3 === 1 ? "warn" : "log",
|
|
356
|
+
args: [{ value: `rapid-${i}` }],
|
|
357
|
+
timestamp: i,
|
|
358
|
+
});
|
|
359
|
+
emit("Network.requestWillBeSent", {
|
|
360
|
+
requestId: `rapid-r${i}`,
|
|
361
|
+
request: { method: "GET", url: `https://example.com/rapid/${i}` },
|
|
362
|
+
timestamp: i,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()).toHaveLength(200);
|
|
366
|
+
(0, vitest_1.expect)(collector.getNetworkEntries()).toHaveLength(200);
|
|
367
|
+
});
|
|
368
|
+
(0, vitest_1.it)("handles interleaved request/response events correctly", async () => {
|
|
369
|
+
const { collector, emit } = await setup();
|
|
370
|
+
// Start multiple requests
|
|
371
|
+
for (let i = 0; i < 50; i++) {
|
|
372
|
+
emit("Network.requestWillBeSent", {
|
|
373
|
+
requestId: `req-${i}`,
|
|
374
|
+
request: { method: "GET", url: `https://example.com/${i}` },
|
|
375
|
+
timestamp: i * 0.1,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
// Responses arrive in a different order
|
|
379
|
+
for (let i = 49; i >= 0; i--) {
|
|
380
|
+
emit("Network.responseReceived", {
|
|
381
|
+
requestId: `req-${i}`,
|
|
382
|
+
response: { status: 200, statusText: "OK" },
|
|
383
|
+
timestamp: 10 + i * 0.1,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const entries = collector.getNetworkEntries();
|
|
387
|
+
(0, vitest_1.expect)(entries).toHaveLength(50);
|
|
388
|
+
// All should have status populated
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
(0, vitest_1.expect)(entry.status).toBe(200);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
/* ── Edge cases ─────────────────────────────────────────────────── */
|
|
395
|
+
(0, vitest_1.describe)("edge cases", () => {
|
|
396
|
+
(0, vitest_1.it)("handles console entry with no args", async () => {
|
|
397
|
+
const { collector, emit } = await setup();
|
|
398
|
+
emit("Runtime.consoleAPICalled", {
|
|
399
|
+
type: "log",
|
|
400
|
+
timestamp: 1000,
|
|
401
|
+
// no args field
|
|
402
|
+
});
|
|
403
|
+
const logs = collector.getConsoleLogs();
|
|
404
|
+
(0, vitest_1.expect)(logs).toHaveLength(1);
|
|
405
|
+
(0, vitest_1.expect)(logs[0].text).toBe("");
|
|
406
|
+
});
|
|
407
|
+
(0, vitest_1.it)("handles console entry with empty args array", async () => {
|
|
408
|
+
const { collector, emit } = await setup();
|
|
409
|
+
emit("Runtime.consoleAPICalled", {
|
|
410
|
+
type: "log",
|
|
411
|
+
args: [],
|
|
412
|
+
timestamp: 1000,
|
|
413
|
+
});
|
|
414
|
+
const logs = collector.getConsoleLogs();
|
|
415
|
+
(0, vitest_1.expect)(logs).toHaveLength(1);
|
|
416
|
+
(0, vitest_1.expect)(logs[0].text).toBe("");
|
|
417
|
+
});
|
|
418
|
+
(0, vitest_1.it)("uses Date.now() fallback when timestamp is missing", async () => {
|
|
419
|
+
const { collector, emit } = await setup();
|
|
420
|
+
const before = Date.now();
|
|
421
|
+
emit("Runtime.consoleAPICalled", {
|
|
422
|
+
type: "log",
|
|
423
|
+
args: [{ value: "no-ts" }],
|
|
424
|
+
// no timestamp
|
|
425
|
+
});
|
|
426
|
+
const after = Date.now();
|
|
427
|
+
const ts = collector.getConsoleLogs()[0].timestamp;
|
|
428
|
+
(0, vitest_1.expect)(ts).toBeGreaterThanOrEqual(before);
|
|
429
|
+
(0, vitest_1.expect)(ts).toBeLessThanOrEqual(after);
|
|
430
|
+
});
|
|
431
|
+
(0, vitest_1.it)("uses description when value is undefined in args", async () => {
|
|
432
|
+
const { collector, emit } = await setup();
|
|
433
|
+
emit("Runtime.consoleAPICalled", {
|
|
434
|
+
type: "log",
|
|
435
|
+
args: [{ description: "Promise { <pending> }" }],
|
|
436
|
+
timestamp: 1,
|
|
437
|
+
});
|
|
438
|
+
(0, vitest_1.expect)(collector.getConsoleLogs()[0].text).toBe("Promise { <pending> }");
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
//# sourceMappingURL=chrome-tools.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cdp-bridge.d.ts","sourceRoot":"","sources":["../../src/transport/cdp-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAUH,KAAK,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;AAE9D,qBAAa,SAAS;IACpB,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqC;IACrE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAqC;IACpE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,SAAS,CAAS;gBAEd,MAAM,EAAE,MAAM;IAI1B;;OAEG;IACH,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,IAAI;IAMpD;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC;IAwCjC;;OAEG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IAwB9E;;OAEG;IACG,aAAa,IAAI,OAAO,CAAC,MAAM,CAAC;IAStC;;OAEG;IACH,UAAU,IAAI,IAAI;IAOlB,WAAW,IAAI,OAAO;IAItB,OAAO,CAAC,aAAa;IA0CrB,OAAO,CAAC,gBAAgB;CAOzB"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CdpBridge — WebSocket bridge to the SensAI Chrome Extension.
|
|
4
|
+
*
|
|
5
|
+
* The extension runs a WebSocket server that proxies CDP commands
|
|
6
|
+
* through chrome.debugger API. This bridge connects to that server
|
|
7
|
+
* and provides a simple request/response API.
|
|
8
|
+
*
|
|
9
|
+
* Protocol: JSON-RPC 2.0 over WebSocket (same as Android agent protocol).
|
|
10
|
+
*/
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.CdpBridge = void 0;
|
|
16
|
+
const ws_1 = __importDefault(require("ws"));
|
|
17
|
+
class CdpBridge {
|
|
18
|
+
ws = null;
|
|
19
|
+
requestId = 0;
|
|
20
|
+
pendingRequests = new Map();
|
|
21
|
+
eventListeners = new Map();
|
|
22
|
+
wsPort;
|
|
23
|
+
connected = false;
|
|
24
|
+
constructor(wsPort) {
|
|
25
|
+
this.wsPort = wsPort;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Register a handler for a CDP event (e.g., "Runtime.consoleAPICalled").
|
|
29
|
+
*/
|
|
30
|
+
onEvent(method, handler) {
|
|
31
|
+
const handlers = this.eventListeners.get(method) ?? [];
|
|
32
|
+
handlers.push(handler);
|
|
33
|
+
this.eventListeners.set(method, handlers);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Connect to the Chrome Extension's WebSocket server.
|
|
37
|
+
*/
|
|
38
|
+
async connect() {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
try {
|
|
41
|
+
this.ws = new ws_1.default(`ws://localhost:${this.wsPort}`);
|
|
42
|
+
const connectionTimeout = setTimeout(() => {
|
|
43
|
+
this.ws?.close();
|
|
44
|
+
this.connected = false;
|
|
45
|
+
resolve(false);
|
|
46
|
+
}, 5000);
|
|
47
|
+
this.ws.on("open", () => {
|
|
48
|
+
clearTimeout(connectionTimeout);
|
|
49
|
+
this.connected = true;
|
|
50
|
+
process.stderr.write(`[sensai:chrome] Connected to extension on port ${this.wsPort}\n`);
|
|
51
|
+
resolve(true);
|
|
52
|
+
});
|
|
53
|
+
this.ws.on("message", (data) => {
|
|
54
|
+
this.handleMessage(data.toString());
|
|
55
|
+
});
|
|
56
|
+
this.ws.on("close", () => {
|
|
57
|
+
this.connected = false;
|
|
58
|
+
this.rejectAllPending("WebSocket connection closed");
|
|
59
|
+
process.stderr.write("[sensai:chrome] Extension disconnected\n");
|
|
60
|
+
});
|
|
61
|
+
this.ws.on("error", () => {
|
|
62
|
+
clearTimeout(connectionTimeout);
|
|
63
|
+
this.connected = false;
|
|
64
|
+
resolve(false);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
this.connected = false;
|
|
69
|
+
resolve(false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Send a CDP command through the extension and wait for response.
|
|
75
|
+
*/
|
|
76
|
+
async send(method, params) {
|
|
77
|
+
if (!this.ws || !this.connected) {
|
|
78
|
+
throw new Error("Not connected to Chrome Extension");
|
|
79
|
+
}
|
|
80
|
+
const id = ++this.requestId;
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const timeout = setTimeout(() => {
|
|
83
|
+
this.pendingRequests.delete(id);
|
|
84
|
+
reject(new Error(`CDP request timed out: ${method}`));
|
|
85
|
+
}, 30_000);
|
|
86
|
+
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
87
|
+
this.ws.send(JSON.stringify({
|
|
88
|
+
jsonrpc: "2.0",
|
|
89
|
+
id,
|
|
90
|
+
method,
|
|
91
|
+
params: params ?? {},
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get the current tab URL.
|
|
97
|
+
*/
|
|
98
|
+
async getCurrentUrl() {
|
|
99
|
+
try {
|
|
100
|
+
const result = await this.send("getTabInfo");
|
|
101
|
+
return result.url ?? "";
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Disconnect from the extension.
|
|
109
|
+
*/
|
|
110
|
+
disconnect() {
|
|
111
|
+
this.rejectAllPending("Shutting down");
|
|
112
|
+
this.ws?.close();
|
|
113
|
+
this.ws = null;
|
|
114
|
+
this.connected = false;
|
|
115
|
+
}
|
|
116
|
+
isConnected() {
|
|
117
|
+
return this.connected;
|
|
118
|
+
}
|
|
119
|
+
handleMessage(raw) {
|
|
120
|
+
try {
|
|
121
|
+
const msg = JSON.parse(raw);
|
|
122
|
+
// CDP event (no id, has method) — dispatch to listeners
|
|
123
|
+
if (!msg.id && msg.method) {
|
|
124
|
+
const handlers = this.eventListeners.get(msg.method);
|
|
125
|
+
if (handlers) {
|
|
126
|
+
for (const handler of handlers) {
|
|
127
|
+
try {
|
|
128
|
+
handler(msg.params ?? {});
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// event handler error should not break the bridge
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// CDP response (has id)
|
|
138
|
+
if (msg.id && this.pendingRequests.has(msg.id)) {
|
|
139
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
140
|
+
this.pendingRequests.delete(msg.id);
|
|
141
|
+
clearTimeout(pending.timeout);
|
|
142
|
+
if (msg.error) {
|
|
143
|
+
pending.reject(new Error(msg.error.message));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
pending.resolve(msg.result);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
process.stderr.write(`[sensai:chrome] Failed to parse message: ${raw.slice(0, 200)}\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
rejectAllPending(reason) {
|
|
155
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
156
|
+
clearTimeout(pending.timeout);
|
|
157
|
+
pending.reject(new Error(reason));
|
|
158
|
+
this.pendingRequests.delete(id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
exports.CdpBridge = CdpBridge;
|
|
163
|
+
//# sourceMappingURL=cdp-bridge.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sensaiorg/adapter-chrome",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SensAI Chrome adapter — debug web apps via Chrome DevTools Protocol",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -b",
|
|
9
|
+
"clean": "rm -rf dist .tsbuildinfo"
|
|
10
|
+
},
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@sensaiorg/core": "0.1.0",
|
|
14
|
+
"ws": "^8.18.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"@types/ws": "^8.5.0",
|
|
20
|
+
"typescript": "^5.7.0",
|
|
21
|
+
"zod": "^3.24.0"
|
|
22
|
+
}
|
|
23
|
+
}
|