cdp-skill 1.0.8 → 1.0.14

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.
Files changed (47) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +151 -239
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +237 -43
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +245 -69
  12. package/src/dom/click-executor.js +240 -76
  13. package/src/dom/element-locator.js +34 -25
  14. package/src/dom/fill-executor.js +55 -27
  15. package/src/page/dialog-handler.js +119 -0
  16. package/src/page/page-controller.js +190 -3
  17. package/src/runner/context-helpers.js +33 -55
  18. package/src/runner/execute-dynamic.js +8 -7
  19. package/src/runner/execute-form.js +11 -11
  20. package/src/runner/execute-input.js +2 -2
  21. package/src/runner/execute-interaction.js +99 -120
  22. package/src/runner/execute-navigation.js +11 -26
  23. package/src/runner/execute-query.js +8 -5
  24. package/src/runner/step-executors.js +225 -84
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -754
  27. package/src/tests/Aria.test.js +1025 -0
  28. package/src/tests/ContextHelpers.test.js +39 -28
  29. package/src/tests/ExecuteBrowser.test.js +572 -0
  30. package/src/tests/ExecuteDynamic.test.js +2 -457
  31. package/src/tests/ExecuteForm.test.js +700 -0
  32. package/src/tests/ExecuteInput.test.js +540 -0
  33. package/src/tests/ExecuteInteraction.test.js +319 -0
  34. package/src/tests/ExecuteQuery.test.js +820 -0
  35. package/src/tests/FillExecutor.test.js +2 -2
  36. package/src/tests/StepValidator.test.js +222 -76
  37. package/src/tests/TestRunner.test.js +36 -25
  38. package/src/tests/integration.test.js +2 -1
  39. package/src/types.js +9 -9
  40. package/src/utils/backoff.js +118 -0
  41. package/src/utils/cdp-helpers.js +130 -0
  42. package/src/utils/devices.js +140 -0
  43. package/src/utils/errors.js +242 -0
  44. package/src/utils/index.js +65 -0
  45. package/src/utils/temp.js +75 -0
  46. package/src/utils/validators.js +433 -0
  47. package/src/utils.js +14 -1142
@@ -30,9 +30,7 @@ describe('ContextHelpers', () => {
30
30
  });
31
31
 
32
32
  it('should contain form step types', () => {
33
- assert.ok(STEP_TYPES.includes('fillForm'));
34
- assert.ok(STEP_TYPES.includes('type'));
35
- assert.ok(STEP_TYPES.includes('select'));
33
+ assert.ok(STEP_TYPES.includes('selectText'));
36
34
  assert.ok(STEP_TYPES.includes('selectOption'));
37
35
  assert.ok(STEP_TYPES.includes('submit'));
38
36
  });
@@ -40,19 +38,31 @@ describe('ContextHelpers', () => {
40
38
  it('should contain tab step types', () => {
41
39
  assert.ok(STEP_TYPES.includes('listTabs'));
42
40
  assert.ok(STEP_TYPES.includes('closeTab'));
43
- assert.ok(STEP_TYPES.includes('openTab'));
41
+ assert.ok(STEP_TYPES.includes('newTab'));
42
+ assert.ok(STEP_TYPES.includes('switchTab'));
44
43
  });
45
44
 
46
- it('should contain iframe step types', () => {
47
- assert.ok(STEP_TYPES.includes('switchToFrame'));
48
- assert.ok(STEP_TYPES.includes('switchToMainFrame'));
49
- assert.ok(STEP_TYPES.includes('listFrames'));
45
+ it('should contain unified frame step', () => {
46
+ assert.ok(STEP_TYPES.includes('frame'));
47
+ assert.ok(!STEP_TYPES.includes('switchToFrame'));
48
+ assert.ok(!STEP_TYPES.includes('switchToMainFrame'));
49
+ assert.ok(!STEP_TYPES.includes('listFrames'));
50
50
  });
51
51
 
52
- it('should contain coordinate-based step types', () => {
53
- assert.ok(STEP_TYPES.includes('refAt'));
52
+ it('should contain unified elementsAt step', () => {
54
53
  assert.ok(STEP_TYPES.includes('elementsAt'));
55
- assert.ok(STEP_TYPES.includes('elementsNear'));
54
+ assert.ok(!STEP_TYPES.includes('refAt'));
55
+ assert.ok(!STEP_TYPES.includes('elementsNear'));
56
+ });
57
+
58
+ it('should contain sleep step', () => {
59
+ assert.ok(STEP_TYPES.includes('sleep'));
60
+ });
61
+
62
+ it('should not contain removed steps', () => {
63
+ assert.ok(!STEP_TYPES.includes('fillForm'));
64
+ assert.ok(!STEP_TYPES.includes('fillActive'));
65
+ assert.ok(!STEP_TYPES.includes('eval'));
56
66
  });
57
67
  });
58
68
 
@@ -65,7 +75,6 @@ describe('ContextHelpers', () => {
65
75
  assert.ok(VISUAL_ACTIONS.includes('goto'));
66
76
  assert.ok(VISUAL_ACTIONS.includes('click'));
67
77
  assert.ok(VISUAL_ACTIONS.includes('fill'));
68
- assert.ok(VISUAL_ACTIONS.includes('type'));
69
78
  assert.ok(VISUAL_ACTIONS.includes('hover'));
70
79
  assert.ok(VISUAL_ACTIONS.includes('press'));
71
80
  assert.ok(VISUAL_ACTIONS.includes('scroll'));
@@ -76,14 +85,16 @@ describe('ContextHelpers', () => {
76
85
  assert.ok(VISUAL_ACTIONS.includes('query'));
77
86
  assert.ok(VISUAL_ACTIONS.includes('queryAll'));
78
87
  assert.ok(VISUAL_ACTIONS.includes('inspect'));
79
- assert.ok(VISUAL_ACTIONS.includes('eval'));
80
- assert.ok(VISUAL_ACTIONS.includes('extract'));
88
+ assert.ok(VISUAL_ACTIONS.includes('pageFunction'));
89
+ assert.ok(VISUAL_ACTIONS.includes('get'));
81
90
  });
82
91
 
83
92
  it('should not contain non-visual actions', () => {
84
93
  assert.ok(!VISUAL_ACTIONS.includes('cookies'));
85
94
  assert.ok(!VISUAL_ACTIONS.includes('listTabs'));
86
95
  assert.ok(!VISUAL_ACTIONS.includes('closeTab'));
96
+ assert.ok(!VISUAL_ACTIONS.includes('eval'));
97
+ assert.ok(!VISUAL_ACTIONS.includes('fillForm'));
87
98
  });
88
99
  });
89
100
 
@@ -165,11 +176,6 @@ describe('ContextHelpers', () => {
165
176
  assert.strictEqual(result, 'Typed in "Email"');
166
177
  });
167
178
 
168
- it('should describe type with selector', () => {
169
- const result = buildActionContext('type', { selector: '#search' }, {});
170
- assert.strictEqual(result, 'Typed in #search');
171
- });
172
-
173
179
  it('should return generic for no params', () => {
174
180
  const result = buildActionContext('fill', {}, {});
175
181
  assert.strictEqual(result, 'Typed in input');
@@ -222,10 +228,6 @@ describe('ContextHelpers', () => {
222
228
  assert.strictEqual(result, 'Typed');
223
229
  });
224
230
 
225
- it('should return Typed for type step', () => {
226
- const result = buildCommandContext([{ type: { selector: '#input', text: 'test' } }]);
227
- assert.strictEqual(result, 'Typed');
228
- });
229
231
 
230
232
  it('should return Pressed key for press step', () => {
231
233
  const result = buildCommandContext([{ press: 'Enter' }]);
@@ -237,13 +239,13 @@ describe('ContextHelpers', () => {
237
239
  assert.strictEqual(result, 'Navigated');
238
240
  });
239
241
 
240
- it('should return Navigated for openTab step', () => {
241
- const result = buildCommandContext([{ openTab: 'https://example.com' }]);
242
+ it('should return Navigated for newTab step', () => {
243
+ const result = buildCommandContext([{ newTab: 'https://example.com' }]);
242
244
  assert.strictEqual(result, 'Navigated');
243
245
  });
244
246
 
245
- it('should return Selected for select step', () => {
246
- const result = buildCommandContext([{ select: '#dropdown' }]);
247
+ it('should return Selected for selectText step', () => {
248
+ const result = buildCommandContext([{ selectText: '#dropdown' }]);
247
249
  assert.strictEqual(result, 'Selected');
248
250
  });
249
251
 
@@ -282,7 +284,16 @@ describe('ContextHelpers', () => {
282
284
  mockPageController = {
283
285
  session: {
284
286
  send: mock.fn(async () => ({ result: { value: '' } }))
285
- }
287
+ },
288
+ evaluateInFrame: mock.fn(async (expression, options = {}) => {
289
+ const params = {
290
+ expression,
291
+ returnByValue: options.returnByValue !== false,
292
+ awaitPromise: options.awaitPromise || false
293
+ };
294
+ return mockPageController.session.send('Runtime.evaluate', params);
295
+ }),
296
+ getFrameContext: mock.fn(() => null)
286
297
  };
287
298
  });
288
299
 
@@ -0,0 +1,572 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ executePdf,
6
+ executeEval,
7
+ executeCookies,
8
+ executeListTabs,
9
+ executeCloseTab,
10
+ executeConsole,
11
+ parseExpiration,
12
+ formatStackTrace,
13
+ formatCommandConsole
14
+ } from '../runner/execute-browser.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function createMockPdfCapture(opts = {}) {
21
+ return {
22
+ saveToFile: mock.fn((path, options) => {
23
+ if (opts.saveError) throw new Error(opts.saveError);
24
+ return Promise.resolve({ path, size: 12345 });
25
+ })
26
+ };
27
+ }
28
+
29
+ function createMockPageController(opts = {}) {
30
+ return {
31
+ session: {
32
+ send: mock.fn(() => Promise.resolve({}))
33
+ },
34
+ evaluateInFrame: mock.fn((expression, options) => {
35
+ if (opts.exception) {
36
+ return Promise.resolve({
37
+ result: { value: undefined },
38
+ exceptionDetails: {
39
+ exception: { description: opts.exception },
40
+ text: opts.exception
41
+ }
42
+ });
43
+ }
44
+ if (opts.syntaxError) {
45
+ return Promise.resolve({
46
+ result: { value: undefined },
47
+ exceptionDetails: {
48
+ text: 'SyntaxError: Unexpected token'
49
+ }
50
+ });
51
+ }
52
+ if (opts.timeout) {
53
+ return new Promise(() => {}); // Never resolves
54
+ }
55
+ return Promise.resolve({
56
+ result: {
57
+ value: opts.evalResult || { success: true },
58
+ type: 'object'
59
+ }
60
+ });
61
+ }),
62
+ getFrameContext: mock.fn(() => opts.contextId || null)
63
+ };
64
+ }
65
+
66
+ function createMockCookieManager(opts = {}) {
67
+ return {
68
+ getCookies: mock.fn((urls) => {
69
+ if (opts.getError) throw new Error(opts.getError);
70
+ return Promise.resolve(opts.cookies || [
71
+ { name: 'session', value: 'abc123', domain: 'example.com' }
72
+ ]);
73
+ }),
74
+ setCookies: mock.fn((cookies) => {
75
+ if (opts.setError) throw new Error(opts.setError);
76
+ return Promise.resolve({ count: cookies.length });
77
+ }),
78
+ clearCookies: mock.fn((urls, options) => {
79
+ if (opts.clearError) throw new Error(opts.clearError);
80
+ return Promise.resolve({ count: opts.clearCount || 5 });
81
+ }),
82
+ deleteCookies: mock.fn((names, options) => {
83
+ if (opts.deleteError) throw new Error(opts.deleteError);
84
+ return Promise.resolve({ count: Array.isArray(names) ? names.length : 1 });
85
+ })
86
+ };
87
+ }
88
+
89
+ function createMockBrowser(opts = {}) {
90
+ return {
91
+ getPages: mock.fn(() => {
92
+ if (opts.getError) throw new Error(opts.getError);
93
+ return Promise.resolve(opts.pages || [
94
+ { targetId: 't1', url: 'https://example.com', title: 'Example' }
95
+ ]);
96
+ }),
97
+ closePage: mock.fn((targetId) => {
98
+ if (opts.closeError) throw new Error(opts.closeError);
99
+ return Promise.resolve();
100
+ })
101
+ };
102
+ }
103
+
104
+ function createMockConsoleCapture(opts = {}) {
105
+ return {
106
+ getMessages: mock.fn(() => opts.messages || []),
107
+ getMessagesByLevel: mock.fn((level) => opts.messagesByLevel || []),
108
+ getMessagesByType: mock.fn((type) => opts.messagesByType || []),
109
+ clear: mock.fn(() => {}),
110
+ getMessageCount: mock.fn(() => opts.messageCount || 0)
111
+ };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Tests: executePdf
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe('executePdf', () => {
119
+ afterEach(() => { mock.reset(); });
120
+
121
+ it('should throw if pdfCapture not available', async () => {
122
+ await assert.rejects(
123
+ executePdf(null, {}, 'test.pdf'),
124
+ { message: 'PDF capture not available' }
125
+ );
126
+ });
127
+
128
+ it('should save PDF with string path param', async () => {
129
+ const pdfCapture = createMockPdfCapture();
130
+ const result = await executePdf(pdfCapture, {}, 'report.pdf');
131
+
132
+ assert.strictEqual(pdfCapture.saveToFile.mock.calls.length, 1);
133
+ assert.ok(result.path);
134
+ assert.strictEqual(result.size, 12345);
135
+ });
136
+
137
+ it('should save PDF with object params', async () => {
138
+ const pdfCapture = createMockPdfCapture();
139
+ const result = await executePdf(pdfCapture, {}, {
140
+ path: 'report.pdf',
141
+ landscape: true
142
+ });
143
+
144
+ assert.ok(result.path);
145
+ assert.strictEqual(pdfCapture.saveToFile.mock.calls.length, 1);
146
+ });
147
+
148
+ it('should propagate saveToFile errors', async () => {
149
+ const pdfCapture = createMockPdfCapture({ saveError: 'Write failed' });
150
+ await assert.rejects(
151
+ executePdf(pdfCapture, {}, 'test.pdf'),
152
+ { message: 'Write failed' }
153
+ );
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Tests: executeEval
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe('executeEval', () => {
162
+ afterEach(() => { mock.reset(); });
163
+
164
+ it('should throw if expression is missing', async () => {
165
+ const pc = createMockPageController();
166
+ await assert.rejects(
167
+ executeEval(pc, {}),
168
+ { message: /requires a non-empty expression/i }
169
+ );
170
+ });
171
+
172
+ it('should execute string expression', async () => {
173
+ const pc = createMockPageController({ evalResult: { value: 42 } });
174
+ const result = await executeEval(pc, 'document.title');
175
+
176
+ assert.strictEqual(pc.evaluateInFrame.mock.calls.length, 1);
177
+ assert.ok(result.value);
178
+ });
179
+
180
+ it('should execute object params with expression', async () => {
181
+ const pc = createMockPageController({ evalResult: 123 });
182
+ const result = await executeEval(pc, { expression: '1 + 2' });
183
+
184
+ assert.ok(result);
185
+ });
186
+
187
+ it('should support await option', async () => {
188
+ const pc = createMockPageController();
189
+ await executeEval(pc, { expression: 'Promise.resolve(42)', await: true });
190
+
191
+ const call = pc.evaluateInFrame.mock.calls[0];
192
+ assert.strictEqual(call.arguments[1].awaitPromise, true);
193
+ });
194
+
195
+ it('should detect unbalanced quotes', async () => {
196
+ const pc = createMockPageController();
197
+ await assert.rejects(
198
+ executeEval(pc, 'document.querySelector("test)'),
199
+ { message: /unbalanced quotes/i }
200
+ );
201
+ });
202
+
203
+ it('should detect unbalanced braces', async () => {
204
+ const pc = createMockPageController();
205
+ await assert.rejects(
206
+ executeEval(pc, '{ foo: "bar" '),
207
+ { message: /unbalanced braces/i }
208
+ );
209
+ });
210
+
211
+ it('should detect unbalanced parentheses', async () => {
212
+ const pc = createMockPageController();
213
+ await assert.rejects(
214
+ executeEval(pc, 'Math.max(1, 2 '),
215
+ { message: /unbalanced parentheses/i }
216
+ );
217
+ });
218
+
219
+ it('should handle syntax errors with context', async () => {
220
+ const pc = createMockPageController({ syntaxError: true });
221
+ await assert.rejects(
222
+ executeEval(pc, 'invalid syntax here'),
223
+ { message: /syntax error/i }
224
+ );
225
+ });
226
+
227
+ it('should handle evaluation exceptions', async () => {
228
+ const pc = createMockPageController({ exception: 'ReferenceError: x is not defined' });
229
+ await assert.rejects(
230
+ executeEval(pc, 'x'),
231
+ { message: /Eval error/i }
232
+ );
233
+ });
234
+
235
+ it('should support timeout option', async () => {
236
+ const pc = createMockPageController({ timeout: true });
237
+ await assert.rejects(
238
+ executeEval(pc, { expression: 'new Promise(() => {})', timeout: 100 }),
239
+ { message: /timed out/i }
240
+ );
241
+ });
242
+
243
+ it('should support serialize option (default true)', async () => {
244
+ const pc = createMockPageController();
245
+ await executeEval(pc, { expression: 'document.title' });
246
+
247
+ // Serialize wraps expression
248
+ const call = pc.evaluateInFrame.mock.calls[0];
249
+ assert.ok(call.arguments[0].includes('document.title'));
250
+ });
251
+
252
+ it('should support serialize: false', async () => {
253
+ const pc = createMockPageController();
254
+ await executeEval(pc, { expression: 'document.title', serialize: false });
255
+
256
+ const call = pc.evaluateInFrame.mock.calls[0];
257
+ assert.strictEqual(call.arguments[0], 'document.title');
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Tests: parseExpiration
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe('parseExpiration', () => {
266
+ it('should return number as-is', () => {
267
+ assert.strictEqual(parseExpiration(12345), 12345);
268
+ });
269
+
270
+ it('should parse minutes', () => {
271
+ const result = parseExpiration('5m');
272
+ assert.ok(result > Date.now() / 1000);
273
+ });
274
+
275
+ it('should parse hours', () => {
276
+ const result = parseExpiration('2h');
277
+ assert.ok(result > Date.now() / 1000);
278
+ });
279
+
280
+ it('should parse days', () => {
281
+ const result = parseExpiration('7d');
282
+ assert.ok(result > Date.now() / 1000);
283
+ });
284
+
285
+ it('should parse weeks', () => {
286
+ const result = parseExpiration('2w');
287
+ assert.ok(result > Date.now() / 1000);
288
+ });
289
+
290
+ it('should parse years', () => {
291
+ const result = parseExpiration('1y');
292
+ assert.ok(result > Date.now() / 1000);
293
+ });
294
+
295
+ it('should parse number strings', () => {
296
+ const result = parseExpiration('9999');
297
+ assert.strictEqual(result, 9999);
298
+ });
299
+
300
+ it('should return undefined for invalid formats', () => {
301
+ assert.strictEqual(parseExpiration('invalid'), undefined);
302
+ // '5x' parses as number 5 (parseInt extracts leading digits)
303
+ assert.strictEqual(parseExpiration('5x'), 5);
304
+ assert.strictEqual(parseExpiration(true), undefined);
305
+ });
306
+ });
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Tests: executeCookies
310
+ // ---------------------------------------------------------------------------
311
+
312
+ describe('executeCookies', () => {
313
+ afterEach(() => { mock.reset(); });
314
+
315
+ it('should throw if cookieManager not available', async () => {
316
+ const pc = createMockPageController();
317
+ await assert.rejects(
318
+ executeCookies(null, pc, { get: true }),
319
+ { message: 'Cookie manager not available' }
320
+ );
321
+ });
322
+
323
+ it('should get cookies for current page', async () => {
324
+ const manager = createMockCookieManager();
325
+ const pc = createMockPageController();
326
+
327
+ const result = await executeCookies(manager, pc, { get: true });
328
+
329
+ assert.strictEqual(result.action, 'get');
330
+ assert.ok(Array.isArray(result.cookies));
331
+ });
332
+
333
+ it('should get cookies with name filter', async () => {
334
+ const manager = createMockCookieManager({
335
+ cookies: [
336
+ { name: 'session', value: 'abc' },
337
+ { name: 'tracking', value: 'xyz' }
338
+ ]
339
+ });
340
+ const pc = createMockPageController();
341
+
342
+ const result = await executeCookies(manager, pc, { get: true, name: 'session' });
343
+
344
+ assert.strictEqual(result.cookies.length, 1);
345
+ assert.strictEqual(result.cookies[0].name, 'session');
346
+ });
347
+
348
+ it('should set cookies', async () => {
349
+ const manager = createMockCookieManager();
350
+ const pc = createMockPageController();
351
+
352
+ const result = await executeCookies(manager, pc, {
353
+ set: [{ name: 'test', value: '123', domain: 'example.com' }]
354
+ });
355
+
356
+ assert.strictEqual(result.action, 'set');
357
+ assert.strictEqual(result.count, 1);
358
+ });
359
+
360
+ it('should parse human-readable expiration in cookies', async () => {
361
+ const manager = createMockCookieManager();
362
+ const pc = createMockPageController();
363
+
364
+ await executeCookies(manager, pc, {
365
+ set: [{ name: 'test', value: '123', expires: '7d' }]
366
+ });
367
+
368
+ const call = manager.setCookies.mock.calls[0];
369
+ const cookie = call.arguments[0][0];
370
+ assert.ok(typeof cookie.expires === 'number');
371
+ });
372
+
373
+ it('should clear cookies', async () => {
374
+ const manager = createMockCookieManager({ clearCount: 10 });
375
+ const pc = createMockPageController();
376
+
377
+ const result = await executeCookies(manager, pc, { clear: [] });
378
+
379
+ assert.strictEqual(result.action, 'clear');
380
+ assert.strictEqual(result.count, 10);
381
+ });
382
+
383
+ it('should delete cookies by name', async () => {
384
+ const manager = createMockCookieManager();
385
+ const pc = createMockPageController();
386
+
387
+ const result = await executeCookies(manager, pc, {
388
+ delete: ['session', 'tracking']
389
+ });
390
+
391
+ assert.strictEqual(result.action, 'delete');
392
+ assert.strictEqual(result.count, 2);
393
+ });
394
+
395
+ it('should throw for invalid action', async () => {
396
+ const manager = createMockCookieManager();
397
+ const pc = createMockPageController();
398
+
399
+ await assert.rejects(
400
+ executeCookies(manager, pc, {}),
401
+ { message: /requires action/i }
402
+ );
403
+ });
404
+ });
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Tests: executeListTabs
408
+ // ---------------------------------------------------------------------------
409
+
410
+ describe('executeListTabs', () => {
411
+ afterEach(() => { mock.reset(); });
412
+
413
+ it('should throw if browser not available', async () => {
414
+ // executeListTabs checks browser synchronously
415
+ try {
416
+ await executeListTabs(null);
417
+ assert.fail('Should have thrown');
418
+ } catch (e) {
419
+ assert.ok(e.message.includes('Browser not available'));
420
+ }
421
+ });
422
+
423
+ it('should list all tabs', async () => {
424
+ const browser = createMockBrowser({
425
+ pages: [
426
+ { targetId: 't1', url: 'https://a.com', title: 'A' },
427
+ { targetId: 't2', url: 'https://b.com', title: 'B' }
428
+ ]
429
+ });
430
+
431
+ const result = await executeListTabs(browser);
432
+
433
+ assert.strictEqual(result.count, 2);
434
+ assert.strictEqual(result.tabs.length, 2);
435
+ assert.strictEqual(result.tabs[0].targetId, 't1');
436
+ assert.strictEqual(result.tabs[1].targetId, 't2');
437
+ });
438
+
439
+ it('should return empty tabs list', async () => {
440
+ const browser = createMockBrowser({ pages: [] });
441
+ const result = await executeListTabs(browser);
442
+
443
+ assert.strictEqual(result.count, 0);
444
+ assert.strictEqual(result.tabs.length, 0);
445
+ });
446
+ });
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Tests: executeCloseTab
450
+ // ---------------------------------------------------------------------------
451
+
452
+ describe('executeCloseTab', () => {
453
+ afterEach(() => { mock.reset(); });
454
+
455
+ it('should throw if browser not available', async () => {
456
+ // executeCloseTab checks browser synchronously
457
+ try {
458
+ await executeCloseTab(null, 't1');
459
+ assert.fail('Should have thrown');
460
+ } catch (e) {
461
+ assert.ok(e.message.includes('Browser not available'));
462
+ }
463
+ });
464
+
465
+ it('should close tab by targetId', async () => {
466
+ const browser = createMockBrowser();
467
+ const result = await executeCloseTab(browser, 't1');
468
+
469
+ assert.strictEqual(result.closed, 't1');
470
+ assert.strictEqual(browser.closePage.mock.calls.length, 1);
471
+ assert.strictEqual(browser.closePage.mock.calls[0].arguments[0], 't1');
472
+ });
473
+
474
+ it('should propagate close errors', async () => {
475
+ const browser = createMockBrowser({ closeError: 'Tab not found' });
476
+ await assert.rejects(
477
+ executeCloseTab(browser, 't999'),
478
+ { message: 'Tab not found' }
479
+ );
480
+ });
481
+ });
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Tests: formatStackTrace
485
+ // ---------------------------------------------------------------------------
486
+
487
+ describe('formatStackTrace', () => {
488
+ it('should return null for empty stack', () => {
489
+ assert.strictEqual(formatStackTrace(null), null);
490
+ assert.strictEqual(formatStackTrace({}), null);
491
+ });
492
+
493
+ it('should format stack frames', () => {
494
+ const stack = {
495
+ callFrames: [
496
+ { functionName: 'foo', url: 'https://example.com/app.js', lineNumber: 10, columnNumber: 5 },
497
+ { functionName: '', url: 'https://example.com/app.js', lineNumber: 20, columnNumber: 10 }
498
+ ]
499
+ };
500
+
501
+ const result = formatStackTrace(stack);
502
+
503
+ assert.strictEqual(result.length, 2);
504
+ assert.strictEqual(result[0].functionName, 'foo');
505
+ assert.strictEqual(result[0].lineNumber, 10);
506
+ assert.strictEqual(result[1].functionName, '(anonymous)');
507
+ });
508
+ });
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Tests: executeConsole
512
+ // ---------------------------------------------------------------------------
513
+
514
+ describe('executeConsole', () => {
515
+ afterEach(() => { mock.reset(); });
516
+
517
+ it('should return error if consoleCapture not available', async () => {
518
+ const result = await executeConsole(null, {});
519
+
520
+ assert.ok(result.error);
521
+ assert.strictEqual(result.messages.length, 0);
522
+ });
523
+
524
+ it('should get console messages', async () => {
525
+ const capture = createMockConsoleCapture({
526
+ messages: [
527
+ { level: 'log', text: 'Hello', timestamp: 1000 },
528
+ { level: 'error', text: 'Error!', timestamp: 2000 }
529
+ ]
530
+ });
531
+
532
+ const result = await executeConsole(capture, {});
533
+
534
+ assert.strictEqual(capture.getMessages.mock.calls.length, 1);
535
+ });
536
+
537
+ it('should filter by level', async () => {
538
+ const capture = createMockConsoleCapture({
539
+ messagesByLevel: [
540
+ { level: 'error', text: 'Error 1' }
541
+ ]
542
+ });
543
+
544
+ await executeConsole(capture, { level: 'error' });
545
+
546
+ assert.strictEqual(capture.getMessagesByLevel.mock.calls.length, 1);
547
+ assert.strictEqual(capture.getMessagesByLevel.mock.calls[0].arguments[0], 'error');
548
+ });
549
+
550
+ it('should filter by type', async () => {
551
+ const capture = createMockConsoleCapture({
552
+ messagesByType: [
553
+ { type: 'exception', text: 'Exception!' }
554
+ ]
555
+ });
556
+
557
+ await executeConsole(capture, { type: 'exception' });
558
+
559
+ assert.strictEqual(capture.getMessagesByType.mock.calls.length, 1);
560
+ });
561
+
562
+ it('should apply limit', async () => {
563
+ const capture = createMockConsoleCapture({
564
+ messages: Array(100).fill().map((_, i) => ({ level: 'log', text: `Msg ${i}` }))
565
+ });
566
+
567
+ await executeConsole(capture, { limit: 10 });
568
+
569
+ // Limit is applied by slicing messages
570
+ assert.ok(capture.getMessages.mock.calls.length > 0);
571
+ });
572
+ });