@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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ChromeAdapter — implements IPlatformAdapter for Chrome browser debugging.
3
+ *
4
+ * Communication flow:
5
+ * MCP Server <-> ChromeAdapter <-> WebSocket <-> Chrome Extension <-> chrome.debugger API <-> CDP
6
+ *
7
+ * The Chrome Extension uses chrome.debugger to attach to the active tab and
8
+ * forwards CDP commands/responses through a WebSocket connection to this adapter.
9
+ */
10
+
11
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import type {
13
+ IPlatformAdapter,
14
+ IUiProvider,
15
+ ILogProvider,
16
+ IInteractionProvider,
17
+ INetworkProvider,
18
+ IPerformanceProvider,
19
+ IScreenshotProvider,
20
+ IAppStateProvider,
21
+ PlatformCapabilities,
22
+ ConnectionStatus,
23
+ } from "@sensai/core";
24
+
25
+ import { CdpBridge } from "./transport/cdp-bridge.js";
26
+ import { CdpEventCollector, registerChromeTools } from "./tools/chrome-tools.js";
27
+
28
+ export interface ChromeAdapterConfig {
29
+ /** WebSocket port for extension communication (default: 9230) */
30
+ wsPort?: number;
31
+ /** Auto-attach to tab matching this URL pattern */
32
+ tabUrlPattern?: string;
33
+ }
34
+
35
+ export class ChromeAdapter implements IPlatformAdapter {
36
+ readonly platform = "chrome" as const;
37
+ readonly displayName: string;
38
+ readonly capabilities: PlatformCapabilities = {
39
+ uiTree: true, // DOM tree -> UiNode
40
+ logs: true, // Console API messages
41
+ interaction: true, // Input.dispatch* via CDP
42
+ network: true, // Network domain (full request/response)
43
+ performance: true, // Performance domain + Web Vitals
44
+ screenshot: true, // Page.captureScreenshot
45
+ appState: true, // localStorage, cookies, sessionStorage
46
+ hotReload: false, // Not applicable for web (use browser refresh)
47
+ evalCode: true, // Runtime.evaluate
48
+ };
49
+
50
+ private readonly config: Required<ChromeAdapterConfig>;
51
+ private readonly bridge: CdpBridge;
52
+ private readonly collector: CdpEventCollector;
53
+ private currentTabUrl = "";
54
+
55
+ readonly ui: IUiProvider | null = null; // TODO: DomUiProvider
56
+ readonly logs: ILogProvider | null = null; // TODO: ConsoleLogProvider
57
+ readonly interaction: IInteractionProvider | null = null; // TODO: CdpInteractionProvider
58
+ readonly network: INetworkProvider | null = null; // TODO: CdpNetworkProvider
59
+ readonly performance: IPerformanceProvider | null = null;
60
+ readonly screenshot: IScreenshotProvider | null = null;
61
+ readonly appState: IAppStateProvider | null = null;
62
+
63
+ constructor(config: ChromeAdapterConfig = {}) {
64
+ this.config = {
65
+ wsPort: config.wsPort ?? parseInt(process.env.SENSAI_CHROME_PORT ?? "9230", 10),
66
+ tabUrlPattern: config.tabUrlPattern ?? process.env.SENSAI_CHROME_TAB_URL ?? "",
67
+ };
68
+
69
+ this.displayName = `Chrome (port ${this.config.wsPort})`;
70
+ this.bridge = new CdpBridge(this.config.wsPort);
71
+ this.collector = new CdpEventCollector();
72
+ }
73
+
74
+ async initialize(): Promise<ConnectionStatus> {
75
+ try {
76
+ const connected = await this.bridge.connect();
77
+ if (connected) {
78
+ this.currentTabUrl = await this.bridge.getCurrentUrl();
79
+ // Enable CDP domains and start collecting console/network events
80
+ await this.collector.enableDomains(this.bridge).catch(() => {
81
+ // Non-fatal: tools will still work, just no buffered events
82
+ });
83
+ }
84
+
85
+ return {
86
+ platform: "chrome",
87
+ device: `Chrome Tab: ${this.currentTabUrl || "none"}`,
88
+ connected,
89
+ capabilities: this.capabilities,
90
+ details: {
91
+ wsPort: this.config.wsPort,
92
+ tabUrl: this.currentTabUrl,
93
+ },
94
+ };
95
+ } catch {
96
+ return {
97
+ platform: "chrome",
98
+ device: "Chrome (not connected)",
99
+ connected: false,
100
+ capabilities: this.capabilities,
101
+ };
102
+ }
103
+ }
104
+
105
+ shutdown(): void {
106
+ this.bridge.disconnect();
107
+ }
108
+
109
+ isConnected(): boolean {
110
+ return this.bridge.isConnected();
111
+ }
112
+
113
+ registerPlatformTools(server: McpServer): void {
114
+ const prefix = "chrome_";
115
+ registerChromeTools(server, this.bridge, this.collector, prefix);
116
+ }
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @sensai/adapter-chrome — Chrome/web platform adapter for SensAI.
3
+ *
4
+ * Provides debugging tools via Chrome DevTools Protocol (CDP).
5
+ * Communicates with the SensAI Chrome Extension via Native Messaging or WebSocket.
6
+ */
7
+
8
+ export { ChromeAdapter } from "./chrome-adapter.js";
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Unit tests for CdpEventCollector.
3
+ *
4
+ * CdpEventCollector is already exported from chrome-tools.ts, no modifications needed.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import { CdpEventCollector } from "./chrome-tools.js";
9
+ import type { CdpBridge } from "../transport/cdp-bridge.js";
10
+
11
+ /* ── Helpers ──────────────────────────────────────────────────────── */
12
+
13
+ type EventHandler = (params: Record<string, unknown>) => void;
14
+
15
+ /** Creates a minimal CdpBridge mock that captures event handlers. */
16
+ function createMockBridge() {
17
+ const handlers = new Map<string, EventHandler[]>();
18
+
19
+ const bridge = {
20
+ onEvent: vi.fn((method: string, handler: EventHandler) => {
21
+ const list = handlers.get(method) ?? [];
22
+ list.push(handler);
23
+ handlers.set(method, list);
24
+ }),
25
+ send: vi.fn(async () => undefined),
26
+ } as unknown as CdpBridge;
27
+
28
+ /** Simulate a CDP event arriving. */
29
+ function emit(method: string, params: Record<string, unknown>) {
30
+ for (const h of handlers.get(method) ?? []) {
31
+ h(params);
32
+ }
33
+ }
34
+
35
+ return { bridge, emit };
36
+ }
37
+
38
+ /** Enable domains on a collector with the mock bridge. */
39
+ async function setup() {
40
+ const collector = new CdpEventCollector();
41
+ const { bridge, emit } = createMockBridge();
42
+ await collector.enableDomains(bridge);
43
+ return { collector, bridge, emit };
44
+ }
45
+
46
+ /* ── Console message buffering ────────────────────────────────────── */
47
+
48
+ describe("CdpEventCollector", () => {
49
+ describe("console messages", () => {
50
+ it("buffers a console log with timestamp", async () => {
51
+ const { collector, emit } = await setup();
52
+
53
+ emit("Runtime.consoleAPICalled", {
54
+ type: "log",
55
+ args: [{ value: "hello world" }],
56
+ timestamp: 1000,
57
+ });
58
+
59
+ const logs = collector.getConsoleLogs();
60
+ expect(logs).toHaveLength(1);
61
+ expect(logs[0]).toEqual({
62
+ level: "log",
63
+ text: "hello world",
64
+ timestamp: 1000,
65
+ url: undefined,
66
+ line: undefined,
67
+ });
68
+ });
69
+
70
+ it("concatenates multiple args with a space", async () => {
71
+ const { collector, emit } = await setup();
72
+
73
+ emit("Runtime.consoleAPICalled", {
74
+ type: "log",
75
+ args: [{ value: "a" }, { value: "b" }, { description: "c-desc" }],
76
+ timestamp: 2000,
77
+ });
78
+
79
+ expect(collector.getConsoleLogs()[0].text).toBe("a b c-desc");
80
+ });
81
+
82
+ it("captures source location from stackTrace", async () => {
83
+ const { collector, emit } = await setup();
84
+
85
+ emit("Runtime.consoleAPICalled", {
86
+ type: "warn",
87
+ args: [{ value: "warning" }],
88
+ timestamp: 3000,
89
+ stackTrace: {
90
+ callFrames: [{ url: "https://example.com/app.js", lineNumber: 42 }],
91
+ },
92
+ });
93
+
94
+ const entry = collector.getConsoleLogs()[0];
95
+ expect(entry.url).toBe("https://example.com/app.js");
96
+ expect(entry.line).toBe(42);
97
+ });
98
+
99
+ it("buffers Runtime.exceptionThrown as error level", async () => {
100
+ const { collector, emit } = await setup();
101
+
102
+ emit("Runtime.exceptionThrown", {
103
+ timestamp: 5000,
104
+ exceptionDetails: {
105
+ text: "Uncaught Error",
106
+ exception: { description: "TypeError: x is not a function" },
107
+ url: "https://example.com/main.js",
108
+ lineNumber: 10,
109
+ },
110
+ });
111
+
112
+ const logs = collector.getConsoleLogs();
113
+ expect(logs).toHaveLength(1);
114
+ expect(logs[0].level).toBe("error");
115
+ expect(logs[0].text).toBe("TypeError: x is not a function");
116
+ expect(logs[0].url).toBe("https://example.com/main.js");
117
+ expect(logs[0].line).toBe(10);
118
+ });
119
+
120
+ it("falls back to text when exception description is missing", async () => {
121
+ const { collector, emit } = await setup();
122
+
123
+ emit("Runtime.exceptionThrown", {
124
+ timestamp: 5000,
125
+ exceptionDetails: { text: "Uncaught SyntaxError" },
126
+ });
127
+
128
+ expect(collector.getConsoleLogs()[0].text).toBe("Uncaught SyntaxError");
129
+ });
130
+
131
+ it("falls back to 'Unknown exception' when all fields missing", async () => {
132
+ const { collector, emit } = await setup();
133
+
134
+ emit("Runtime.exceptionThrown", { timestamp: 5000 });
135
+
136
+ expect(collector.getConsoleLogs()[0].text).toBe("Unknown exception");
137
+ });
138
+
139
+ it("getConsoleLogs() returns all buffered messages", async () => {
140
+ const { collector, emit } = await setup();
141
+
142
+ for (let i = 0; i < 10; i++) {
143
+ emit("Runtime.consoleAPICalled", {
144
+ type: "log",
145
+ args: [{ value: `msg-${i}` }],
146
+ timestamp: 1000 + i,
147
+ });
148
+ }
149
+
150
+ expect(collector.getConsoleLogs()).toHaveLength(10);
151
+ });
152
+
153
+ it("getConsoleLogs(maxCount) returns the most recent entries", async () => {
154
+ const { collector, emit } = await setup();
155
+
156
+ for (let i = 0; i < 10; i++) {
157
+ emit("Runtime.consoleAPICalled", {
158
+ type: "log",
159
+ args: [{ value: `msg-${i}` }],
160
+ timestamp: 1000 + i,
161
+ });
162
+ }
163
+
164
+ const last3 = collector.getConsoleLogs(3);
165
+ expect(last3).toHaveLength(3);
166
+ expect(last3[0].text).toBe("msg-7");
167
+ expect(last3[2].text).toBe("msg-9");
168
+ });
169
+ });
170
+
171
+ /* ── Console buffer size limit ──────────────────────────────────── */
172
+
173
+ describe("console buffer eviction", () => {
174
+ it("enforces max buffer size of 500, evicting oldest entries", async () => {
175
+ const { collector, emit } = await setup();
176
+
177
+ for (let i = 0; i < 520; i++) {
178
+ emit("Runtime.consoleAPICalled", {
179
+ type: "log",
180
+ args: [{ value: `entry-${i}` }],
181
+ timestamp: i,
182
+ });
183
+ }
184
+
185
+ const logs = collector.getConsoleLogs();
186
+ expect(logs).toHaveLength(500);
187
+ // Oldest 20 entries (0..19) should have been evicted
188
+ expect(logs[0].text).toBe("entry-20");
189
+ expect(logs[499].text).toBe("entry-519");
190
+ });
191
+ });
192
+
193
+ /* ── Network request buffering ──────────────────────────────────── */
194
+
195
+ describe("network requests", () => {
196
+ it("buffers a network request with timing info", async () => {
197
+ const { collector, emit } = await setup();
198
+
199
+ emit("Network.requestWillBeSent", {
200
+ requestId: "r1",
201
+ request: { method: "GET", url: "https://api.example.com/data" },
202
+ type: "Fetch",
203
+ timestamp: 100.5,
204
+ });
205
+
206
+ emit("Network.responseReceived", {
207
+ requestId: "r1",
208
+ response: { status: 200, statusText: "OK" },
209
+ timestamp: 100.8,
210
+ });
211
+
212
+ emit("Network.loadingFinished", {
213
+ requestId: "r1",
214
+ encodedDataLength: 1234,
215
+ timestamp: 100.9,
216
+ });
217
+
218
+ const entries = collector.getNetworkEntries();
219
+ expect(entries).toHaveLength(1);
220
+ expect(entries[0]).toMatchObject({
221
+ requestId: "r1",
222
+ method: "GET",
223
+ url: "https://api.example.com/data",
224
+ status: 200,
225
+ statusText: "OK",
226
+ type: "Fetch",
227
+ startTime: 100.5,
228
+ endTime: 100.9,
229
+ encodedDataLength: 1234,
230
+ });
231
+ });
232
+
233
+ it("records network errors via loadingFailed", async () => {
234
+ const { collector, emit } = await setup();
235
+
236
+ emit("Network.requestWillBeSent", {
237
+ requestId: "r2",
238
+ request: { method: "POST", url: "https://api.example.com/fail" },
239
+ timestamp: 200,
240
+ });
241
+
242
+ emit("Network.loadingFailed", {
243
+ requestId: "r2",
244
+ errorText: "net::ERR_CONNECTION_REFUSED",
245
+ timestamp: 201,
246
+ });
247
+
248
+ const entries = collector.getNetworkEntries();
249
+ expect(entries).toHaveLength(1);
250
+ expect(entries[0].error).toBe("net::ERR_CONNECTION_REFUSED");
251
+ expect(entries[0].endTime).toBe(201);
252
+ });
253
+
254
+ it("getNetworkEntries() returns all buffered requests", async () => {
255
+ const { collector, emit } = await setup();
256
+
257
+ for (let i = 0; i < 5; i++) {
258
+ emit("Network.requestWillBeSent", {
259
+ requestId: `r${i}`,
260
+ request: { method: "GET", url: `https://example.com/${i}` },
261
+ timestamp: i,
262
+ });
263
+ }
264
+
265
+ expect(collector.getNetworkEntries()).toHaveLength(5);
266
+ });
267
+
268
+ it("getNetworkEntries(maxCount) returns the most recent entries", async () => {
269
+ const { collector, emit } = await setup();
270
+
271
+ for (let i = 0; i < 10; i++) {
272
+ emit("Network.requestWillBeSent", {
273
+ requestId: `r${i}`,
274
+ request: { method: "GET", url: `https://example.com/${i}` },
275
+ timestamp: i,
276
+ });
277
+ }
278
+
279
+ const last3 = collector.getNetworkEntries(3);
280
+ expect(last3).toHaveLength(3);
281
+ expect(last3[0].url).toBe("https://example.com/7");
282
+ expect(last3[2].url).toBe("https://example.com/9");
283
+ });
284
+
285
+ it("ignores response/finished events for unknown requestIds", async () => {
286
+ const { collector, emit } = await setup();
287
+
288
+ // These should not throw or create entries
289
+ emit("Network.responseReceived", {
290
+ requestId: "unknown",
291
+ response: { status: 200, statusText: "OK" },
292
+ timestamp: 1,
293
+ });
294
+
295
+ emit("Network.loadingFinished", {
296
+ requestId: "unknown",
297
+ encodedDataLength: 100,
298
+ timestamp: 2,
299
+ });
300
+
301
+ emit("Network.loadingFailed", {
302
+ requestId: "unknown",
303
+ errorText: "error",
304
+ timestamp: 3,
305
+ });
306
+
307
+ expect(collector.getNetworkEntries()).toHaveLength(0);
308
+ });
309
+ });
310
+
311
+ /* ── Network buffer eviction ────────────────────────────────────── */
312
+
313
+ describe("network buffer eviction", () => {
314
+ it("enforces max buffer size of 500, evicting oldest entries", async () => {
315
+ const { collector, emit } = await setup();
316
+
317
+ for (let i = 0; i < 520; i++) {
318
+ emit("Network.requestWillBeSent", {
319
+ requestId: `r${i}`,
320
+ request: { method: "GET", url: `https://example.com/${i}` },
321
+ timestamp: i,
322
+ });
323
+ }
324
+
325
+ const entries = collector.getNetworkEntries();
326
+ expect(entries).toHaveLength(500);
327
+ // Oldest 20 entries should be evicted
328
+ expect(entries[0].url).toBe("https://example.com/20");
329
+ expect(entries[499].url).toBe("https://example.com/519");
330
+ });
331
+ });
332
+
333
+ /* ── Filter by level ────────────────────────────────────────────── */
334
+
335
+ describe("filtering console logs by level", () => {
336
+ it("stores different levels and allows external filtering", async () => {
337
+ const { collector, emit } = await setup();
338
+
339
+ emit("Runtime.consoleAPICalled", {
340
+ type: "log",
341
+ args: [{ value: "info msg" }],
342
+ timestamp: 1,
343
+ });
344
+ emit("Runtime.consoleAPICalled", {
345
+ type: "warn",
346
+ args: [{ value: "warn msg" }],
347
+ timestamp: 2,
348
+ });
349
+ emit("Runtime.consoleAPICalled", {
350
+ type: "error",
351
+ args: [{ value: "error msg" }],
352
+ timestamp: 3,
353
+ });
354
+
355
+ const all = collector.getConsoleLogs();
356
+ expect(all).toHaveLength(3);
357
+
358
+ // Filter externally (as the tool chrome_get_console_logs does)
359
+ const errors = all.filter((e) => e.level === "error");
360
+ expect(errors).toHaveLength(1);
361
+ expect(errors[0].text).toBe("error msg");
362
+
363
+ const warnings = all.filter((e) => e.level === "warn");
364
+ expect(warnings).toHaveLength(1);
365
+ expect(warnings[0].text).toBe("warn msg");
366
+
367
+ const logs = all.filter((e) => e.level === "log");
368
+ expect(logs).toHaveLength(1);
369
+ expect(logs[0].text).toBe("info msg");
370
+ });
371
+ });
372
+
373
+ /* ── enableDomains idempotency ──────────────────────────────────── */
374
+
375
+ describe("enableDomains", () => {
376
+ it("sends Runtime.enable, Network.enable, Page.enable", async () => {
377
+ const collector = new CdpEventCollector();
378
+ const { bridge } = createMockBridge();
379
+
380
+ await collector.enableDomains(bridge);
381
+
382
+ expect(bridge.send).toHaveBeenCalledWith("Runtime.enable");
383
+ expect(bridge.send).toHaveBeenCalledWith("Network.enable");
384
+ expect(bridge.send).toHaveBeenCalledWith("Page.enable");
385
+ });
386
+
387
+ it("only enables domains once (idempotent)", async () => {
388
+ const collector = new CdpEventCollector();
389
+ const { bridge } = createMockBridge();
390
+
391
+ await collector.enableDomains(bridge);
392
+ await collector.enableDomains(bridge);
393
+
394
+ // send should have been called exactly 3 times (Runtime, Network, Page)
395
+ expect(bridge.send).toHaveBeenCalledTimes(3);
396
+ });
397
+ });
398
+
399
+ /* ── Fresh instance (clear equivalent) ──────────────────────────── */
400
+
401
+ describe("reset / clear equivalent", () => {
402
+ it("a new CdpEventCollector instance starts with empty buffers", () => {
403
+ const collector = new CdpEventCollector();
404
+ expect(collector.getConsoleLogs()).toHaveLength(0);
405
+ expect(collector.getNetworkEntries()).toHaveLength(0);
406
+ });
407
+
408
+ it("creating a new instance effectively clears all state", async () => {
409
+ const { collector, emit } = await setup();
410
+
411
+ emit("Runtime.consoleAPICalled", {
412
+ type: "log",
413
+ args: [{ value: "test" }],
414
+ timestamp: 1,
415
+ });
416
+ emit("Network.requestWillBeSent", {
417
+ requestId: "r1",
418
+ request: { method: "GET", url: "https://example.com" },
419
+ timestamp: 1,
420
+ });
421
+
422
+ expect(collector.getConsoleLogs()).toHaveLength(1);
423
+ expect(collector.getNetworkEntries()).toHaveLength(1);
424
+
425
+ // "Reset" by creating a new collector
426
+ const freshCollector = new CdpEventCollector();
427
+ expect(freshCollector.getConsoleLogs()).toHaveLength(0);
428
+ expect(freshCollector.getNetworkEntries()).toHaveLength(0);
429
+ });
430
+ });
431
+
432
+ /* ── Rapid concurrent events ────────────────────────────────────── */
433
+
434
+ describe("rapid concurrent events", () => {
435
+ it("handles many console and network events fired in rapid succession", async () => {
436
+ const { collector, emit } = await setup();
437
+
438
+ // Fire 200 console + 200 network events synchronously
439
+ for (let i = 0; i < 200; i++) {
440
+ emit("Runtime.consoleAPICalled", {
441
+ type: i % 3 === 0 ? "error" : i % 3 === 1 ? "warn" : "log",
442
+ args: [{ value: `rapid-${i}` }],
443
+ timestamp: i,
444
+ });
445
+
446
+ emit("Network.requestWillBeSent", {
447
+ requestId: `rapid-r${i}`,
448
+ request: { method: "GET", url: `https://example.com/rapid/${i}` },
449
+ timestamp: i,
450
+ });
451
+ }
452
+
453
+ expect(collector.getConsoleLogs()).toHaveLength(200);
454
+ expect(collector.getNetworkEntries()).toHaveLength(200);
455
+ });
456
+
457
+ it("handles interleaved request/response events correctly", async () => {
458
+ const { collector, emit } = await setup();
459
+
460
+ // Start multiple requests
461
+ for (let i = 0; i < 50; i++) {
462
+ emit("Network.requestWillBeSent", {
463
+ requestId: `req-${i}`,
464
+ request: { method: "GET", url: `https://example.com/${i}` },
465
+ timestamp: i * 0.1,
466
+ });
467
+ }
468
+
469
+ // Responses arrive in a different order
470
+ for (let i = 49; i >= 0; i--) {
471
+ emit("Network.responseReceived", {
472
+ requestId: `req-${i}`,
473
+ response: { status: 200, statusText: "OK" },
474
+ timestamp: 10 + i * 0.1,
475
+ });
476
+ }
477
+
478
+ const entries = collector.getNetworkEntries();
479
+ expect(entries).toHaveLength(50);
480
+
481
+ // All should have status populated
482
+ for (const entry of entries) {
483
+ expect(entry.status).toBe(200);
484
+ }
485
+ });
486
+ });
487
+
488
+ /* ── Edge cases ─────────────────────────────────────────────────── */
489
+
490
+ describe("edge cases", () => {
491
+ it("handles console entry with no args", async () => {
492
+ const { collector, emit } = await setup();
493
+
494
+ emit("Runtime.consoleAPICalled", {
495
+ type: "log",
496
+ timestamp: 1000,
497
+ // no args field
498
+ });
499
+
500
+ const logs = collector.getConsoleLogs();
501
+ expect(logs).toHaveLength(1);
502
+ expect(logs[0].text).toBe("");
503
+ });
504
+
505
+ it("handles console entry with empty args array", async () => {
506
+ const { collector, emit } = await setup();
507
+
508
+ emit("Runtime.consoleAPICalled", {
509
+ type: "log",
510
+ args: [],
511
+ timestamp: 1000,
512
+ });
513
+
514
+ const logs = collector.getConsoleLogs();
515
+ expect(logs).toHaveLength(1);
516
+ expect(logs[0].text).toBe("");
517
+ });
518
+
519
+ it("uses Date.now() fallback when timestamp is missing", async () => {
520
+ const { collector, emit } = await setup();
521
+ const before = Date.now();
522
+
523
+ emit("Runtime.consoleAPICalled", {
524
+ type: "log",
525
+ args: [{ value: "no-ts" }],
526
+ // no timestamp
527
+ });
528
+
529
+ const after = Date.now();
530
+ const ts = collector.getConsoleLogs()[0].timestamp;
531
+ expect(ts).toBeGreaterThanOrEqual(before);
532
+ expect(ts).toBeLessThanOrEqual(after);
533
+ });
534
+
535
+ it("uses description when value is undefined in args", async () => {
536
+ const { collector, emit } = await setup();
537
+
538
+ emit("Runtime.consoleAPICalled", {
539
+ type: "log",
540
+ args: [{ description: "Promise { <pending> }" }],
541
+ timestamp: 1,
542
+ });
543
+
544
+ expect(collector.getConsoleLogs()[0].text).toBe("Promise { <pending> }");
545
+ });
546
+ });
547
+ });