cdp-skill 1.0.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,1529 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { createTestRunner, validateSteps } from '../runner.js';
4
+ import { ErrorTypes } from '../utils.js';
5
+
6
+ describe('TestRunner', () => {
7
+ let testRunner;
8
+ let mockPageController;
9
+ let mockElementLocator;
10
+ let mockInputEmulator;
11
+ let mockScreenshotCapture;
12
+
13
+ // Helper to create a mock element handle
14
+ function createMockHandle(box = { x: 100, y: 200, width: 50, height: 30 }) {
15
+ return {
16
+ objectId: 'mock-object-id-123',
17
+ scrollIntoView: mock.fn(() => Promise.resolve()),
18
+ waitForStability: mock.fn(() => Promise.resolve(box)),
19
+ isActionable: mock.fn(() => Promise.resolve({ actionable: true, reason: null })),
20
+ getBoundingBox: mock.fn(() => Promise.resolve(box)),
21
+ dispose: mock.fn(() => Promise.resolve()),
22
+ focus: mock.fn(() => Promise.resolve())
23
+ };
24
+ }
25
+
26
+ beforeEach(() => {
27
+ mockPageController = {
28
+ navigate: mock.fn(() => Promise.resolve()),
29
+ getUrl: mock.fn(() => Promise.resolve('http://test.com')),
30
+ session: { send: null } // Will be set after mockElementLocator is created
31
+ };
32
+
33
+ const mockHandle = createMockHandle();
34
+ // Mock session.send to return appropriate values for different CDP calls
35
+ const mockSessionSend = mock.fn((method, params) => {
36
+ // Handle Runtime.evaluate for getCurrentUrl (window.location.href)
37
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('window.location.href')) {
38
+ return Promise.resolve({ result: { value: 'http://test.com' } });
39
+ }
40
+ // Handle Runtime.evaluate for ActionabilityChecker.findElementInternal
41
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('document.querySelector')) {
42
+ return Promise.resolve({ result: { objectId: 'mock-object-id-123' } });
43
+ }
44
+ // Handle Runtime.evaluate for viewport bounds (ClickExecutor._getViewportBounds)
45
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('innerWidth')) {
46
+ return Promise.resolve({ result: { value: { width: 1920, height: 1080 } } });
47
+ }
48
+ // Handle Runtime.evaluate for WaitExecutor text search
49
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('document.body.innerText')) {
50
+ return Promise.resolve({ result: { value: true } });
51
+ }
52
+ // Handle Runtime.evaluate for browser-side waitForSelector (MutationObserver)
53
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('MutationObserver') && params?.expression?.includes('querySelector')) {
54
+ return Promise.resolve({ result: { value: { found: true, immediate: true } } });
55
+ }
56
+ // Handle Runtime.evaluate for WaitExecutor element count
57
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('querySelectorAll')) {
58
+ return Promise.resolve({ result: { value: 10 } });
59
+ }
60
+ // Handle Runtime.evaluate for WaitExecutor hidden check
61
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('getComputedStyle')) {
62
+ return Promise.resolve({ result: { value: true } });
63
+ }
64
+ // Handle Runtime.callFunctionOn for ActionabilityChecker - visible check
65
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('visibility')) {
66
+ return Promise.resolve({ result: { value: { matches: true, received: 'visible' } } });
67
+ }
68
+ // Handle Runtime.callFunctionOn for ActionabilityChecker - enabled check
69
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('aria-disabled')) {
70
+ return Promise.resolve({ result: { value: { matches: true, received: 'enabled' } } });
71
+ }
72
+ // Handle Runtime.callFunctionOn for ActionabilityChecker - stable check
73
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('requestAnimationFrame')) {
74
+ return Promise.resolve({ result: { value: { matches: true, received: 'stable' } } });
75
+ }
76
+ // Handle Runtime.callFunctionOn for ActionabilityChecker - editable check
77
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('isContentEditable')) {
78
+ return Promise.resolve({ result: { value: { matches: true, received: 'editable' } } });
79
+ }
80
+ // Handle Runtime.callFunctionOn for ElementValidator.isEditable
81
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('readOnly')) {
82
+ return Promise.resolve({ result: { value: { editable: true, reason: null } } });
83
+ }
84
+ // Handle Runtime.callFunctionOn for ElementValidator.isClickable
85
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('clickable')) {
86
+ return Promise.resolve({ result: { value: { clickable: true, reason: null, willNavigate: false } } });
87
+ }
88
+ // Handle Runtime.callFunctionOn for focus calls
89
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('focus')) {
90
+ return Promise.resolve({ result: { value: true } });
91
+ }
92
+ // Handle Runtime.callFunctionOn for JS click execution
93
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('.click()')) {
94
+ return Promise.resolve({ result: { value: { success: true, targetReceived: true } } });
95
+ }
96
+ // Handle Runtime.callFunctionOn for getClickablePoint (getBoundingClientRect in actionability checker)
97
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('getBoundingClientRect')) {
98
+ // getClickablePoint returns { x: centerX, y: centerY, rect: {...} }
99
+ return Promise.resolve({
100
+ result: {
101
+ value: {
102
+ x: 125, // center: 100 + 50/2
103
+ y: 215, // center: 200 + 30/2
104
+ rect: { x: 100, y: 200, width: 50, height: 30 }
105
+ }
106
+ }
107
+ });
108
+ }
109
+ // Handle Runtime.releaseObject (cleanup)
110
+ if (method === 'Runtime.releaseObject') {
111
+ return Promise.resolve({});
112
+ }
113
+ // Default response for other calls
114
+ return Promise.resolve({ result: { value: true } });
115
+ });
116
+ mockElementLocator = {
117
+ waitForSelector: mock.fn(() => Promise.resolve()),
118
+ waitForText: mock.fn(() => Promise.resolve()),
119
+ findElement: mock.fn(() => Promise.resolve({ nodeId: 123, _handle: mockHandle })),
120
+ getBoundingBox: mock.fn(() => Promise.resolve({ x: 100, y: 200, width: 50, height: 30 })),
121
+ querySelectorAll: mock.fn(() => Promise.resolve([])),
122
+ session: { send: mockSessionSend }
123
+ };
124
+
125
+ // Set mockPageController session to use same send function
126
+ mockPageController.session = { send: mockSessionSend };
127
+
128
+ mockInputEmulator = {
129
+ click: mock.fn(() => Promise.resolve()),
130
+ type: mock.fn(() => Promise.resolve()),
131
+ press: mock.fn(() => Promise.resolve()),
132
+ selectAll: mock.fn(() => Promise.resolve())
133
+ };
134
+
135
+ mockScreenshotCapture = {
136
+ captureToFile: mock.fn(() => Promise.resolve('/tmp/screenshot.png')),
137
+ getViewportDimensions: mock.fn(() => Promise.resolve({ width: 1920, height: 1080 }))
138
+ };
139
+
140
+ testRunner = createTestRunner({
141
+ pageController: mockPageController,
142
+ elementLocator: mockElementLocator,
143
+ inputEmulator: mockInputEmulator,
144
+ screenshotCapture: mockScreenshotCapture
145
+ });
146
+ });
147
+
148
+ afterEach(() => {
149
+ mock.reset();
150
+ });
151
+
152
+ describe('run', () => {
153
+ it('should execute all steps and return passed status', async () => {
154
+ const steps = [
155
+ { goto: 'http://test.com' },
156
+ { wait: '#main' },
157
+ { click: '#button' }
158
+ ];
159
+
160
+ const result = await testRunner.run(steps);
161
+
162
+ assert.strictEqual(result.status, 'passed');
163
+ assert.strictEqual(result.steps.length, 3);
164
+ assert.strictEqual(result.errors.length, 0);
165
+ });
166
+
167
+ it('should stop on first error by default', async () => {
168
+ // Override session.send to return null element for querySelector
169
+ const originalSend = mockElementLocator.session.send;
170
+ mockElementLocator.session.send = mock.fn((method, params) => {
171
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('document.querySelector')) {
172
+ return Promise.resolve({ result: { subtype: 'null' } });
173
+ }
174
+ return originalSend(method, params);
175
+ });
176
+
177
+ const steps = [
178
+ { goto: 'http://test.com' },
179
+ { click: '#nonexistent' },
180
+ { click: '#button' }
181
+ ];
182
+
183
+ const result = await testRunner.run(steps, { stepTimeout: 500 });
184
+
185
+ assert.strictEqual(result.status, 'failed');
186
+ assert.strictEqual(result.steps.length, 2);
187
+ assert.strictEqual(result.errors.length, 1);
188
+ assert.strictEqual(result.errors[0].step, 2);
189
+
190
+ // Restore original mock
191
+ mockElementLocator.session.send = originalSend;
192
+ });
193
+
194
+ it('should continue on error when stopOnError is false', async () => {
195
+ // Track which selector is being queried
196
+ let callCount = 0;
197
+ const originalSend = mockElementLocator.session.send;
198
+ mockElementLocator.session.send = mock.fn((method, params) => {
199
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('document.querySelector')) {
200
+ callCount++;
201
+ // First querySelector call (for #nonexistent) should fail, second (for #button) should succeed
202
+ if (params.expression.includes('#nonexistent')) {
203
+ return Promise.resolve({ result: { subtype: 'null' } });
204
+ }
205
+ return Promise.resolve({ result: { objectId: 'mock-object-id-123' } });
206
+ }
207
+ return originalSend(method, params);
208
+ });
209
+
210
+ const steps = [
211
+ { goto: 'http://test.com' },
212
+ { click: '#nonexistent' },
213
+ { click: '#button' }
214
+ ];
215
+
216
+ const result = await testRunner.run(steps, { stopOnError: false, stepTimeout: 500 });
217
+
218
+ assert.strictEqual(result.status, 'failed');
219
+ assert.strictEqual(result.steps.length, 3);
220
+ assert.strictEqual(result.errors.length, 1);
221
+
222
+ // Restore original mock
223
+ mockElementLocator.session.send = originalSend;
224
+ });
225
+
226
+ it('should collect screenshots', async () => {
227
+ const steps = [
228
+ { goto: 'http://test.com' },
229
+ { screenshot: '/tmp/test.png' }
230
+ ];
231
+
232
+ const result = await testRunner.run(steps);
233
+
234
+ assert.strictEqual(result.screenshots.length, 1);
235
+ assert.strictEqual(result.screenshots[0], '/tmp/screenshot.png');
236
+ });
237
+ });
238
+
239
+ describe('executeStep - goto', () => {
240
+ it('should execute goto step with URL', async () => {
241
+ const result = await testRunner.executeStep({ goto: 'http://test.com' });
242
+
243
+ assert.strictEqual(result.action, 'goto');
244
+ assert.deepStrictEqual(result.params, { url: 'http://test.com' });
245
+ assert.strictEqual(result.status, 'passed');
246
+ assert.strictEqual(mockPageController.navigate.mock.calls.length, 1);
247
+ assert.strictEqual(mockPageController.navigate.mock.calls[0].arguments[0], 'http://test.com');
248
+ });
249
+ });
250
+
251
+ describe('executeStep - wait', () => {
252
+ it('should wait for selector string', async () => {
253
+ const result = await testRunner.executeStep({ wait: '#main' });
254
+
255
+ assert.strictEqual(result.action, 'wait');
256
+ assert.strictEqual(result.params, '#main');
257
+ assert.strictEqual(result.status, 'passed');
258
+ // Browser-side polling now uses session.send directly, not elementLocator.waitForSelector
259
+ });
260
+
261
+ it('should wait for selector with timeout', async () => {
262
+ const result = await testRunner.executeStep({ wait: { selector: '#main', timeout: 5000 } });
263
+
264
+ assert.strictEqual(result.action, 'wait');
265
+ assert.strictEqual(result.status, 'passed');
266
+ assert.deepStrictEqual(result.params, { selector: '#main', timeout: 5000 });
267
+ });
268
+
269
+ it('should wait for text', async () => {
270
+ const result = await testRunner.executeStep({ wait: { text: 'Hello World', timeout: 3000 } });
271
+
272
+ // WaitExecutor now uses session.send directly for text wait
273
+ assert.strictEqual(result.action, 'wait');
274
+ assert.strictEqual(result.status, 'passed');
275
+ assert.deepStrictEqual(result.params, { text: 'Hello World', timeout: 3000 });
276
+ });
277
+
278
+ it('should wait for time delay', async () => {
279
+ const startTime = Date.now();
280
+ const result = await testRunner.executeStep({ wait: { time: 50 } });
281
+ const elapsed = Date.now() - startTime;
282
+
283
+ assert.strictEqual(result.action, 'wait');
284
+ assert.ok(elapsed >= 50);
285
+ });
286
+
287
+ it('should fail on invalid wait params', async () => {
288
+ const result = await testRunner.executeStep({ wait: { invalid: true } });
289
+
290
+ assert.strictEqual(result.status, 'failed');
291
+ assert.ok(result.error.includes('Invalid wait params'));
292
+ });
293
+ });
294
+
295
+ describe('executeStep - click', () => {
296
+ it('should click element by selector string', async () => {
297
+ const result = await testRunner.executeStep({ click: '#button' });
298
+
299
+ assert.strictEqual(result.action, 'click');
300
+ assert.strictEqual(result.status, 'passed');
301
+ // The ClickExecutor uses actionabilityChecker which goes through session.send
302
+ // instead of elementLocator.findElement, so we verify click was called
303
+ assert.strictEqual(mockInputEmulator.click.mock.calls.length, 1);
304
+ // Click coordinates should be center of bounding box (100+25, 200+15)
305
+ assert.strictEqual(mockInputEmulator.click.mock.calls[0].arguments[0], 125);
306
+ assert.strictEqual(mockInputEmulator.click.mock.calls[0].arguments[1], 215);
307
+ });
308
+
309
+ it('should click element by selector object', async () => {
310
+ const result = await testRunner.executeStep({ click: { selector: '#button' } });
311
+
312
+ assert.strictEqual(result.status, 'passed');
313
+ // Verify click was performed (actionabilityChecker handles element finding)
314
+ assert.strictEqual(mockInputEmulator.click.mock.calls.length, 1);
315
+ });
316
+
317
+ it('should fail when element not found', async () => {
318
+ // Override session.send to return null element for querySelector
319
+ const originalSend = mockElementLocator.session.send;
320
+ mockElementLocator.session.send = mock.fn((method, params) => {
321
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('document.querySelector')) {
322
+ return Promise.resolve({ result: { subtype: 'null' } });
323
+ }
324
+ return originalSend(method, params);
325
+ });
326
+
327
+ const result = await testRunner.executeStep({ click: '#missing' }, { stepTimeout: 1000 });
328
+
329
+ assert.strictEqual(result.status, 'failed');
330
+ // The error could be about element not found, actionability, or timeout
331
+ assert.ok(result.error.includes('Element not found') ||
332
+ result.error.includes('not actionable') ||
333
+ result.error.includes('Timeout') ||
334
+ result.error.includes('timed out'));
335
+
336
+ // Restore original mock
337
+ mockElementLocator.session.send = originalSend;
338
+ });
339
+
340
+ it('should fail when element not actionable', async () => {
341
+ // Override session.send to return failure for visibility check
342
+ const originalSend = mockElementLocator.session.send;
343
+ mockElementLocator.session.send = mock.fn((method, params) => {
344
+ // Return element found
345
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('document.querySelector')) {
346
+ return Promise.resolve({ result: { objectId: 'mock-object-id-123' } });
347
+ }
348
+ // Return visibility failure
349
+ if (method === 'Runtime.callFunctionOn' && params?.functionDeclaration?.includes('visibility')) {
350
+ return Promise.resolve({ result: { value: { matches: false, received: 'visibility:hidden' } } });
351
+ }
352
+ return originalSend(method, params);
353
+ });
354
+
355
+ const result = await testRunner.executeStep({ click: '#hidden' }, { stepTimeout: 1000 });
356
+
357
+ assert.strictEqual(result.status, 'failed');
358
+ // The error could be about visibility, actionability, or timeout
359
+ assert.ok(result.error.includes('not actionable') ||
360
+ result.error.includes('visible') ||
361
+ result.error.includes('Timeout') ||
362
+ result.error.includes('timed out'));
363
+
364
+ // Restore original mock
365
+ mockElementLocator.session.send = originalSend;
366
+ });
367
+ });
368
+
369
+ describe('executeStep - fill', () => {
370
+ it('should fill input field', async () => {
371
+ const result = await testRunner.executeStep({ fill: { selector: '#input', value: 'test' } });
372
+
373
+ assert.strictEqual(result.action, 'fill');
374
+ // FillExecutor uses actionabilityChecker which goes through session.send
375
+ // We verify the input operations were called
376
+ assert.strictEqual(mockInputEmulator.click.mock.calls.length, 1);
377
+ assert.strictEqual(mockInputEmulator.selectAll.mock.calls.length, 1);
378
+ assert.strictEqual(mockInputEmulator.type.mock.calls.length, 1);
379
+ assert.strictEqual(mockInputEmulator.type.mock.calls[0].arguments[0], 'test');
380
+ });
381
+
382
+ it('should fill without clearing when clear is false', async () => {
383
+ const result = await testRunner.executeStep({ fill: { selector: '#input', value: 'test', clear: false } });
384
+
385
+ assert.strictEqual(mockInputEmulator.selectAll.mock.calls.length, 0);
386
+ assert.strictEqual(mockInputEmulator.type.mock.calls.length, 1);
387
+ });
388
+
389
+ it('should fail without selector or ref', async () => {
390
+ const result = await testRunner.executeStep({ fill: { value: 'test' } });
391
+
392
+ assert.strictEqual(result.status, 'failed');
393
+ assert.ok(result.error.includes('Fill requires selector or ref'));
394
+ });
395
+
396
+ it('should fail without value', async () => {
397
+ const result = await testRunner.executeStep({ fill: { selector: '#input' } });
398
+
399
+ assert.strictEqual(result.status, 'failed');
400
+ assert.ok(result.error.includes('Fill requires value'));
401
+ });
402
+
403
+ it('should fail when element not found', async () => {
404
+ // Override session.send to return null element for querySelector
405
+ const originalSend = mockElementLocator.session.send;
406
+ mockElementLocator.session.send = mock.fn((method, params) => {
407
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('document.querySelector')) {
408
+ return Promise.resolve({ result: { subtype: 'null' } });
409
+ }
410
+ return originalSend(method, params);
411
+ });
412
+
413
+ const result = await testRunner.executeStep({ fill: { selector: '#missing', value: 'test' } }, { stepTimeout: 1000 });
414
+
415
+ assert.strictEqual(result.status, 'failed');
416
+ // The error could be about element not found, actionability, or timeout
417
+ assert.ok(result.error.includes('Element not found') ||
418
+ result.error.includes('not actionable') ||
419
+ result.error.includes('Timeout') ||
420
+ result.error.includes('timed out'));
421
+
422
+ // Restore original mock
423
+ mockElementLocator.session.send = originalSend;
424
+ });
425
+ });
426
+
427
+ describe('executeStep - press', () => {
428
+ it('should press key', async () => {
429
+ const result = await testRunner.executeStep({ press: 'Enter' });
430
+
431
+ assert.strictEqual(result.action, 'press');
432
+ assert.deepStrictEqual(result.params, { key: 'Enter' });
433
+ assert.strictEqual(mockInputEmulator.press.mock.calls.length, 1);
434
+ assert.strictEqual(mockInputEmulator.press.mock.calls[0].arguments[0], 'Enter');
435
+ });
436
+ });
437
+
438
+ describe('executeStep - screenshot', () => {
439
+ it('should capture screenshot with path string', async () => {
440
+ const result = await testRunner.executeStep({ screenshot: '/tmp/test.png' });
441
+
442
+ assert.strictEqual(result.action, 'screenshot');
443
+ assert.strictEqual(result.screenshot, '/tmp/screenshot.png');
444
+ assert.strictEqual(mockScreenshotCapture.captureToFile.mock.calls[0].arguments[0], '/tmp/test.png');
445
+ });
446
+
447
+ it('should capture screenshot with options object', async () => {
448
+ const result = await testRunner.executeStep({ screenshot: { path: '/tmp/test.png', fullPage: true } });
449
+
450
+ assert.strictEqual(mockScreenshotCapture.captureToFile.mock.calls[0].arguments[0], '/tmp/test.png');
451
+ assert.strictEqual(mockScreenshotCapture.captureToFile.mock.calls[0].arguments[1].fullPage, true);
452
+ });
453
+ });
454
+
455
+ describe('executeStep - unknown', () => {
456
+ it('should fail on unknown step type', async () => {
457
+ const result = await testRunner.executeStep({ unknownAction: true });
458
+
459
+ assert.strictEqual(result.status, 'failed');
460
+ assert.ok(result.error.includes('Unknown step type'));
461
+ });
462
+
463
+ it('should fail on ambiguous step with multiple actions', async () => {
464
+ const result = await testRunner.executeStep({ goto: 'http://test.com', click: '#button' });
465
+
466
+ assert.strictEqual(result.status, 'failed');
467
+ assert.ok(result.error.includes('Ambiguous step'));
468
+ assert.ok(result.error.includes('goto'));
469
+ assert.ok(result.error.includes('click'));
470
+ });
471
+ });
472
+
473
+ describe('executeStep - timeout', () => {
474
+ it('should timeout long-running steps', async () => {
475
+ mockPageController.navigate.mock.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000)));
476
+
477
+ const result = await testRunner.executeStep({ goto: 'http://test.com' }, { stepTimeout: 50 });
478
+
479
+ assert.strictEqual(result.status, 'failed');
480
+ assert.ok(result.error.includes('timed out'));
481
+ });
482
+ });
483
+
484
+ describe('step timing', () => {
485
+ it('should track step duration', async () => {
486
+ const result = await testRunner.executeStep({ wait: { time: 50 } });
487
+
488
+ assert.ok(result.duration >= 50);
489
+ });
490
+ });
491
+
492
+ describe('validateSteps', () => {
493
+ it('should pass valid steps', () => {
494
+ const steps = [
495
+ { goto: 'http://test.com' },
496
+ { wait: '#main' },
497
+ { wait: { selector: '#element', timeout: 5000 } },
498
+ { wait: { text: 'Hello', timeout: 3000 } },
499
+ { wait: { time: 100 } },
500
+ { click: '#button' },
501
+ { click: { selector: '#link' } },
502
+ { fill: { selector: '#input', value: 'test' } },
503
+ { press: 'Enter' },
504
+ { screenshot: '/tmp/test.png' },
505
+ { screenshot: { path: '/tmp/test2.png', fullPage: true } }
506
+ ];
507
+
508
+ const result = testRunner.validateSteps(steps);
509
+ assert.strictEqual(result.valid, true);
510
+ assert.strictEqual(result.errors.length, 0);
511
+ });
512
+
513
+ it('should return errors for unknown step type', () => {
514
+ const steps = [{ unknownAction: true }];
515
+
516
+ const result = testRunner.validateSteps(steps);
517
+ assert.strictEqual(result.valid, false);
518
+ assert.strictEqual(result.errors.length, 1);
519
+ assert.strictEqual(result.errors[0].index, 0);
520
+ assert.ok(result.errors[0].errors[0].includes('unknown step type'));
521
+ });
522
+
523
+ it('should return errors for ambiguous step', () => {
524
+ const steps = [{ goto: 'http://test.com', click: '#button' }];
525
+
526
+ const result = testRunner.validateSteps(steps);
527
+ assert.strictEqual(result.valid, false);
528
+ assert.strictEqual(result.errors.length, 1);
529
+ assert.ok(result.errors[0].errors[0].includes('ambiguous'));
530
+ });
531
+
532
+ it('should return errors for empty goto URL', () => {
533
+ const steps = [{ goto: '' }];
534
+
535
+ const result = testRunner.validateSteps(steps);
536
+ assert.strictEqual(result.valid, false);
537
+ assert.ok(result.errors[0].errors[0].includes('non-empty URL'));
538
+ });
539
+
540
+ it('should return errors for non-string goto', () => {
541
+ const steps = [{ goto: 123 }];
542
+
543
+ const result = testRunner.validateSteps(steps);
544
+ assert.strictEqual(result.valid, false);
545
+ assert.ok(result.errors[0].errors[0].includes('non-empty URL'));
546
+ });
547
+
548
+ it('should return errors for empty wait selector', () => {
549
+ const steps = [{ wait: '' }];
550
+
551
+ const result = testRunner.validateSteps(steps);
552
+ assert.strictEqual(result.valid, false);
553
+ assert.ok(result.errors[0].errors[0].includes('cannot be empty'));
554
+ });
555
+
556
+ it('should return errors for invalid wait object', () => {
557
+ const steps = [{ wait: { invalid: true } }];
558
+
559
+ const result = testRunner.validateSteps(steps);
560
+ assert.strictEqual(result.valid, false);
561
+ assert.ok(result.errors[0].errors[0].includes('selector, text, textRegex, time, or urlContains'));
562
+ });
563
+
564
+ it('should return errors for negative wait time', () => {
565
+ const steps = [{ wait: { time: -100 } }];
566
+
567
+ const result = testRunner.validateSteps(steps);
568
+ assert.strictEqual(result.valid, false);
569
+ assert.ok(result.errors[0].errors[0].includes('non-negative number'));
570
+ });
571
+
572
+ it('should return errors for empty click selector', () => {
573
+ const steps = [{ click: '' }];
574
+
575
+ const result = testRunner.validateSteps(steps);
576
+ assert.strictEqual(result.valid, false);
577
+ assert.ok(result.errors[0].errors[0].includes('cannot be empty'));
578
+ });
579
+
580
+ it('should return errors for click without selector', () => {
581
+ const steps = [{ click: { other: 'value' } }];
582
+
583
+ const result = testRunner.validateSteps(steps);
584
+ assert.strictEqual(result.valid, false);
585
+ assert.ok(result.errors[0].errors[0].includes('requires selector'));
586
+ });
587
+
588
+ it('should return errors for fill without selector or ref', () => {
589
+ const steps = [{ fill: { value: 'test' } }];
590
+
591
+ const result = testRunner.validateSteps(steps);
592
+ assert.strictEqual(result.valid, false);
593
+ assert.ok(result.errors[0].errors.some(e => e.includes('requires selector or ref')));
594
+ });
595
+
596
+ it('should return errors for fill without value', () => {
597
+ const steps = [{ fill: { selector: '#input' } }];
598
+
599
+ const result = testRunner.validateSteps(steps);
600
+ assert.strictEqual(result.valid, false);
601
+ assert.ok(result.errors[0].errors.some(e => e.includes('requires value')));
602
+ });
603
+
604
+ it('should return errors for fill with non-object', () => {
605
+ const steps = [{ fill: '#input' }];
606
+
607
+ const result = testRunner.validateSteps(steps);
608
+ assert.strictEqual(result.valid, false);
609
+ assert.ok(result.errors[0].errors[0].includes('object with selector/ref and value'));
610
+ });
611
+
612
+ it('should accept fill with ref instead of selector', () => {
613
+ const steps = [{ fill: { ref: 'e3', value: 'test' } }];
614
+
615
+ const result = testRunner.validateSteps(steps);
616
+ assert.strictEqual(result.valid, true);
617
+ });
618
+
619
+ it('should validate fillForm step', () => {
620
+ const steps = [{ fillForm: { '#firstName': 'John', '#lastName': 'Doe' } }];
621
+
622
+ const result = testRunner.validateSteps(steps);
623
+ assert.strictEqual(result.valid, true);
624
+ });
625
+
626
+ it('should return errors for empty fillForm', () => {
627
+ const steps = [{ fillForm: {} }];
628
+
629
+ const result = testRunner.validateSteps(steps);
630
+ assert.strictEqual(result.valid, false);
631
+ assert.ok(result.errors[0].errors[0].includes('at least one field'));
632
+ });
633
+
634
+ it('should return errors for fillForm with non-object', () => {
635
+ const steps = [{ fillForm: '#input' }];
636
+
637
+ const result = testRunner.validateSteps(steps);
638
+ assert.strictEqual(result.valid, false);
639
+ assert.ok(result.errors[0].errors[0].includes('object mapping'));
640
+ });
641
+
642
+ it('should return errors for empty press key', () => {
643
+ const steps = [{ press: '' }];
644
+
645
+ const result = testRunner.validateSteps(steps);
646
+ assert.strictEqual(result.valid, false);
647
+ assert.ok(result.errors[0].errors[0].includes('non-empty key'));
648
+ });
649
+
650
+ it('should return errors for non-string press key', () => {
651
+ const steps = [{ press: 123 }];
652
+
653
+ const result = testRunner.validateSteps(steps);
654
+ assert.strictEqual(result.valid, false);
655
+ assert.ok(result.errors[0].errors[0].includes('non-empty key'));
656
+ });
657
+
658
+ it('should return errors for empty screenshot path', () => {
659
+ const steps = [{ screenshot: '' }];
660
+
661
+ const result = testRunner.validateSteps(steps);
662
+ assert.strictEqual(result.valid, false);
663
+ assert.ok(result.errors[0].errors[0].includes('cannot be empty'));
664
+ });
665
+
666
+ it('should return errors for screenshot without path', () => {
667
+ const steps = [{ screenshot: { fullPage: true } }];
668
+
669
+ const result = testRunner.validateSteps(steps);
670
+ assert.strictEqual(result.valid, false);
671
+ assert.ok(result.errors[0].errors[0].includes('requires path'));
672
+ });
673
+
674
+ it('should collect all invalid steps', () => {
675
+ const steps = [
676
+ { goto: 'http://test.com' },
677
+ { click: '' },
678
+ { wait: '#main' },
679
+ { fill: { selector: '#input' } },
680
+ { unknownAction: true }
681
+ ];
682
+
683
+ const result = testRunner.validateSteps(steps);
684
+ assert.strictEqual(result.valid, false);
685
+ assert.strictEqual(result.errors.length, 3);
686
+ assert.strictEqual(result.errors[0].index, 1);
687
+ assert.strictEqual(result.errors[1].index, 3);
688
+ assert.strictEqual(result.errors[2].index, 4);
689
+ });
690
+
691
+ it('should return errors for non-object step', () => {
692
+ const steps = [null];
693
+
694
+ const result = testRunner.validateSteps(steps);
695
+ assert.strictEqual(result.valid, false);
696
+ assert.ok(result.errors[0].errors[0].includes('must be an object'));
697
+ });
698
+ });
699
+
700
+ describe('run with validation', () => {
701
+ it('should validate steps before execution', async () => {
702
+ const steps = [
703
+ { goto: 'http://test.com' },
704
+ { unknownAction: true }
705
+ ];
706
+
707
+ await assert.rejects(
708
+ () => testRunner.run(steps),
709
+ (err) => err.name === ErrorTypes.STEP_VALIDATION
710
+ );
711
+ assert.strictEqual(mockPageController.navigate.mock.calls.length, 0);
712
+ });
713
+
714
+ it('should not execute any steps if validation fails', async () => {
715
+ const steps = [
716
+ { goto: 'http://test.com' },
717
+ { click: '' },
718
+ { wait: '#element' }
719
+ ];
720
+
721
+ await assert.rejects(
722
+ () => testRunner.run(steps),
723
+ (err) => err.name === ErrorTypes.STEP_VALIDATION
724
+ );
725
+ assert.strictEqual(mockPageController.navigate.mock.calls.length, 0);
726
+ assert.strictEqual(mockElementLocator.findElement.mock.calls.length, 0);
727
+ assert.strictEqual(mockElementLocator.waitForSelector.mock.calls.length, 0);
728
+ });
729
+ });
730
+
731
+ describe('hover step validation', () => {
732
+ it('should accept valid hover with selector string', () => {
733
+ const result = validateSteps([{ hover: '#menu-item' }]);
734
+ assert.strictEqual(result.valid, true);
735
+ });
736
+
737
+ it('should accept valid hover with selector object', () => {
738
+ const result = validateSteps([{ hover: { selector: '.dropdown' } }]);
739
+ assert.strictEqual(result.valid, true);
740
+ });
741
+
742
+ it('should accept valid hover with ref', () => {
743
+ const result = validateSteps([{ hover: { ref: 'e4' } }]);
744
+ assert.strictEqual(result.valid, true);
745
+ });
746
+
747
+ it('should reject empty hover selector', () => {
748
+ const result = validateSteps([{ hover: '' }]);
749
+ assert.strictEqual(result.valid, false);
750
+ assert.ok(result.errors[0].errors[0].includes('cannot be empty'));
751
+ });
752
+
753
+ it('should reject hover without selector or ref', () => {
754
+ const result = validateSteps([{ hover: { duration: 500 } }]);
755
+ assert.strictEqual(result.valid, false);
756
+ assert.ok(result.errors[0].errors[0].includes('requires selector or ref'));
757
+ });
758
+ });
759
+
760
+ describe('viewport step validation', () => {
761
+ it('should accept valid viewport', () => {
762
+ const result = validateSteps([{ viewport: { width: 1280, height: 720 } }]);
763
+ assert.strictEqual(result.valid, true);
764
+ });
765
+
766
+ it('should accept viewport with all options', () => {
767
+ const result = validateSteps([{
768
+ viewport: { width: 375, height: 667, mobile: true, hasTouch: true, deviceScaleFactor: 2 }
769
+ }]);
770
+ assert.strictEqual(result.valid, true);
771
+ });
772
+
773
+ it('should reject viewport without width', () => {
774
+ const result = validateSteps([{ viewport: { height: 720 } }]);
775
+ assert.strictEqual(result.valid, false);
776
+ assert.ok(result.errors[0].errors[0].includes('requires numeric width'));
777
+ });
778
+
779
+ it('should reject viewport without height', () => {
780
+ const result = validateSteps([{ viewport: { width: 1280 } }]);
781
+ assert.strictEqual(result.valid, false);
782
+ assert.ok(result.errors[0].errors[0].includes('requires numeric height'));
783
+ });
784
+
785
+ it('should accept viewport with device preset string', () => {
786
+ // Viewport now accepts device preset strings
787
+ const result = validateSteps([{ viewport: 'iphone12' }]);
788
+ assert.strictEqual(result.valid, true);
789
+ });
790
+
791
+ it('should reject viewport with invalid type', () => {
792
+ const result = validateSteps([{ viewport: 123 }]);
793
+ assert.strictEqual(result.valid, false);
794
+ assert.ok(result.errors[0].errors[0].includes('requires a device preset string or object'));
795
+ });
796
+ });
797
+
798
+ describe('cookies step validation', () => {
799
+ it('should accept valid cookies get', () => {
800
+ const result = validateSteps([{ cookies: { get: true } }]);
801
+ assert.strictEqual(result.valid, true);
802
+ });
803
+
804
+ it('should accept cookies get with URL filter', () => {
805
+ const result = validateSteps([{ cookies: { get: ['https://example.com'] } }]);
806
+ assert.strictEqual(result.valid, true);
807
+ });
808
+
809
+ it('should accept valid cookies set', () => {
810
+ const result = validateSteps([{
811
+ cookies: { set: [{ name: 'session', value: 'abc', domain: 'example.com' }] }
812
+ }]);
813
+ assert.strictEqual(result.valid, true);
814
+ });
815
+
816
+ it('should accept valid cookies clear', () => {
817
+ const result = validateSteps([{ cookies: { clear: true } }]);
818
+ assert.strictEqual(result.valid, true);
819
+ });
820
+
821
+ it('should reject non-object cookies', () => {
822
+ const result = validateSteps([{ cookies: 'get' }]);
823
+ assert.strictEqual(result.valid, false);
824
+ assert.ok(result.errors[0].errors[0].includes('requires an object'));
825
+ });
826
+
827
+ it('should reject cookies set with non-array', () => {
828
+ const result = validateSteps([{ cookies: { set: { name: 'session', value: 'abc' } } }]);
829
+ assert.strictEqual(result.valid, false);
830
+ assert.ok(result.errors[0].errors[0].includes('requires an array'));
831
+ });
832
+ });
833
+
834
+ describe('press with keyboard combos', () => {
835
+ it('should accept simple key press', () => {
836
+ const result = validateSteps([{ press: 'Enter' }]);
837
+ assert.strictEqual(result.valid, true);
838
+ });
839
+
840
+ it('should accept keyboard combo', () => {
841
+ const result = validateSteps([{ press: 'Control+a' }]);
842
+ assert.strictEqual(result.valid, true);
843
+ });
844
+
845
+ it('should accept complex keyboard combo', () => {
846
+ const result = validateSteps([{ press: 'Control+Shift+Enter' }]);
847
+ assert.strictEqual(result.valid, true);
848
+ });
849
+
850
+ it('should accept Meta key combo', () => {
851
+ const result = validateSteps([{ press: 'Meta+c' }]);
852
+ assert.strictEqual(result.valid, true);
853
+ });
854
+ });
855
+
856
+ describe('assert step validation', () => {
857
+ it('should accept valid URL assertion with contains', () => {
858
+ const result = validateSteps([{ assert: { url: { contains: '/wiki/Albert' } } }]);
859
+ assert.strictEqual(result.valid, true);
860
+ });
861
+
862
+ it('should accept valid URL assertion with equals', () => {
863
+ const result = validateSteps([{ assert: { url: { equals: 'https://example.com' } } }]);
864
+ assert.strictEqual(result.valid, true);
865
+ });
866
+
867
+ it('should accept valid URL assertion with startsWith', () => {
868
+ const result = validateSteps([{ assert: { url: { startsWith: 'https://' } } }]);
869
+ assert.strictEqual(result.valid, true);
870
+ });
871
+
872
+ it('should accept valid URL assertion with endsWith', () => {
873
+ const result = validateSteps([{ assert: { url: { endsWith: '/success' } } }]);
874
+ assert.strictEqual(result.valid, true);
875
+ });
876
+
877
+ it('should accept valid URL assertion with matches', () => {
878
+ const result = validateSteps([{ assert: { url: { matches: '^https://.*\\.example\\.com' } } }]);
879
+ assert.strictEqual(result.valid, true);
880
+ });
881
+
882
+ it('should accept valid text assertion', () => {
883
+ const result = validateSteps([{ assert: { text: 'Welcome' } }]);
884
+ assert.strictEqual(result.valid, true);
885
+ });
886
+
887
+ it('should accept text assertion with selector', () => {
888
+ const result = validateSteps([{ assert: { selector: 'h1', text: 'Title' } }]);
889
+ assert.strictEqual(result.valid, true);
890
+ });
891
+
892
+ it('should reject assert without url or text', () => {
893
+ const result = validateSteps([{ assert: { selector: 'h1' } }]);
894
+ assert.strictEqual(result.valid, false);
895
+ assert.ok(result.errors[0].errors[0].includes('requires url or text'));
896
+ });
897
+
898
+ it('should reject non-object assert', () => {
899
+ const result = validateSteps([{ assert: 'text' }]);
900
+ assert.strictEqual(result.valid, false);
901
+ assert.ok(result.errors[0].errors[0].includes('requires an object'));
902
+ });
903
+
904
+ it('should reject url assertion without valid operator', () => {
905
+ const result = validateSteps([{ assert: { url: { invalid: 'test' } } }]);
906
+ assert.strictEqual(result.valid, false);
907
+ assert.ok(result.errors[0].errors[0].includes('requires contains, equals'));
908
+ });
909
+
910
+ it('should reject url assertion with non-object url', () => {
911
+ const result = validateSteps([{ assert: { url: '/wiki/Albert' } }]);
912
+ assert.strictEqual(result.valid, false);
913
+ assert.ok(result.errors[0].errors[0].includes('url must be an object'));
914
+ });
915
+
916
+ it('should reject text assertion with non-string text', () => {
917
+ const result = validateSteps([{ assert: { text: 123 } }]);
918
+ assert.strictEqual(result.valid, false);
919
+ assert.ok(result.errors[0].errors[0].includes('text must be a string'));
920
+ });
921
+ });
922
+
923
+ describe('queryAll step validation', () => {
924
+ it('should accept valid queryAll with string selectors', () => {
925
+ const result = validateSteps([{ queryAll: { title: 'h1', links: 'a' } }]);
926
+ assert.strictEqual(result.valid, true);
927
+ });
928
+
929
+ it('should accept queryAll with query config objects', () => {
930
+ const result = validateSteps([{ queryAll: { buttons: { role: 'button' } } }]);
931
+ assert.strictEqual(result.valid, true);
932
+ });
933
+
934
+ it('should accept queryAll with mixed selectors and configs', () => {
935
+ const result = validateSteps([{
936
+ queryAll: {
937
+ title: 'h1',
938
+ buttons: { role: 'button', name: 'Submit' }
939
+ }
940
+ }]);
941
+ assert.strictEqual(result.valid, true);
942
+ });
943
+
944
+ it('should reject empty queryAll', () => {
945
+ const result = validateSteps([{ queryAll: {} }]);
946
+ assert.strictEqual(result.valid, false);
947
+ assert.ok(result.errors[0].errors[0].includes('requires at least one query'));
948
+ });
949
+
950
+ it('should reject non-object queryAll', () => {
951
+ const result = validateSteps([{ queryAll: 'h1' }]);
952
+ assert.strictEqual(result.valid, false);
953
+ assert.ok(result.errors[0].errors[0].includes('requires an object'));
954
+ });
955
+
956
+ it('should reject queryAll with invalid selector type', () => {
957
+ const result = validateSteps([{ queryAll: { title: 123 } }]);
958
+ assert.strictEqual(result.valid, false);
959
+ assert.ok(result.errors[0].errors[0].includes('must be a selector string or query object'));
960
+ });
961
+ });
962
+
963
+ describe('cookies with name filter', () => {
964
+ it('should accept cookies get with name filter', () => {
965
+ const result = validateSteps([{ cookies: { get: true, name: 'session_id' } }]);
966
+ assert.strictEqual(result.valid, true);
967
+ });
968
+
969
+ it('should accept cookies get with array of names', () => {
970
+ const result = validateSteps([{ cookies: { get: true, name: ['session_id', 'auth_token'] } }]);
971
+ assert.strictEqual(result.valid, true);
972
+ });
973
+ });
974
+
975
+ describe('cookies with human-readable expiration', () => {
976
+ it('should accept cookies set with human-readable expiration', () => {
977
+ const result = validateSteps([{
978
+ cookies: { set: [{ name: 'temp', value: 'data', domain: 'example.com', expires: '1h' }] }
979
+ }]);
980
+ assert.strictEqual(result.valid, true);
981
+ });
982
+
983
+ it('should accept cookies set with numeric expiration', () => {
984
+ const result = validateSteps([{
985
+ cookies: { set: [{ name: 'temp', value: 'data', domain: 'example.com', expires: 1706547600 }] }
986
+ }]);
987
+ assert.strictEqual(result.valid, true);
988
+ });
989
+ });
990
+
991
+ describe('console with stackTrace option', () => {
992
+ it('should accept console with stackTrace option', () => {
993
+ const result = validateSteps([{ console: { stackTrace: true } }]);
994
+ assert.strictEqual(result.valid, true);
995
+ });
996
+
997
+ it('should accept console with stackTrace and other options', () => {
998
+ const result = validateSteps([{ console: { level: 'error', stackTrace: true, limit: 10 } }]);
999
+ assert.strictEqual(result.valid, true);
1000
+ });
1001
+ });
1002
+
1003
+ describe('executeStep - assert', () => {
1004
+ it('should pass URL assertion with contains', async () => {
1005
+ // Mock getUrl to return a test URL
1006
+ mockPageController.getUrl = mock.fn(() => Promise.resolve('https://example.com/wiki/Albert_Einstein'));
1007
+
1008
+ const result = await testRunner.executeStep({
1009
+ assert: { url: { contains: '/wiki/Albert' } }
1010
+ });
1011
+
1012
+ assert.strictEqual(result.status, 'passed');
1013
+ assert.strictEqual(result.action, 'assert');
1014
+ assert.strictEqual(result.output.passed, true);
1015
+ assert.strictEqual(result.output.assertions.length, 1);
1016
+ assert.strictEqual(result.output.assertions[0].type, 'url');
1017
+ assert.strictEqual(result.output.assertions[0].passed, true);
1018
+ });
1019
+
1020
+ it('should fail URL assertion when not matching', async () => {
1021
+ mockPageController.getUrl = mock.fn(() => Promise.resolve('https://example.com/home'));
1022
+
1023
+ const result = await testRunner.executeStep({
1024
+ assert: { url: { contains: '/wiki/Albert' } }
1025
+ });
1026
+
1027
+ assert.strictEqual(result.status, 'failed');
1028
+ assert.ok(result.error.includes('URL assertion failed'));
1029
+ });
1030
+
1031
+ it('should pass URL assertion with equals', async () => {
1032
+ mockPageController.getUrl = mock.fn(() => Promise.resolve('https://example.com'));
1033
+
1034
+ const result = await testRunner.executeStep({
1035
+ assert: { url: { equals: 'https://example.com' } }
1036
+ });
1037
+
1038
+ assert.strictEqual(result.status, 'passed');
1039
+ assert.strictEqual(result.output.assertions[0].passed, true);
1040
+ });
1041
+
1042
+ it('should pass URL assertion with startsWith', async () => {
1043
+ mockPageController.getUrl = mock.fn(() => Promise.resolve('https://secure.example.com/page'));
1044
+
1045
+ const result = await testRunner.executeStep({
1046
+ assert: { url: { startsWith: 'https://' } }
1047
+ });
1048
+
1049
+ assert.strictEqual(result.status, 'passed');
1050
+ assert.strictEqual(result.output.assertions[0].passed, true);
1051
+ });
1052
+
1053
+ it('should pass URL assertion with endsWith', async () => {
1054
+ mockPageController.getUrl = mock.fn(() => Promise.resolve('https://example.com/success'));
1055
+
1056
+ const result = await testRunner.executeStep({
1057
+ assert: { url: { endsWith: '/success' } }
1058
+ });
1059
+
1060
+ assert.strictEqual(result.status, 'passed');
1061
+ assert.strictEqual(result.output.assertions[0].passed, true);
1062
+ });
1063
+
1064
+ it('should pass URL assertion with matches (regex)', async () => {
1065
+ mockPageController.getUrl = mock.fn(() => Promise.resolve('https://api.example.com/v1/users'));
1066
+
1067
+ const result = await testRunner.executeStep({
1068
+ assert: { url: { matches: '^https://.*\\.example\\.com' } }
1069
+ });
1070
+
1071
+ assert.strictEqual(result.status, 'passed');
1072
+ assert.strictEqual(result.output.assertions[0].passed, true);
1073
+ });
1074
+
1075
+ it('should pass text assertion when text is found', async () => {
1076
+ const originalSend = mockElementLocator.session.send;
1077
+ mockElementLocator.session.send = mock.fn((method, params) => {
1078
+ // Handle text content query
1079
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('textContent')) {
1080
+ return Promise.resolve({ result: { value: 'Welcome to our website! Please login.' } });
1081
+ }
1082
+ return originalSend(method, params);
1083
+ });
1084
+ mockPageController.session.send = mockElementLocator.session.send;
1085
+
1086
+ const result = await testRunner.executeStep({
1087
+ assert: { text: 'Welcome' }
1088
+ });
1089
+
1090
+ assert.strictEqual(result.status, 'passed');
1091
+ assert.strictEqual(result.output.passed, true);
1092
+ assert.strictEqual(result.output.assertions[0].type, 'text');
1093
+ assert.strictEqual(result.output.assertions[0].passed, true);
1094
+
1095
+ mockElementLocator.session.send = originalSend;
1096
+ mockPageController.session.send = originalSend;
1097
+ });
1098
+
1099
+ it('should fail text assertion when text is not found', async () => {
1100
+ const originalSend = mockElementLocator.session.send;
1101
+ mockElementLocator.session.send = mock.fn((method, params) => {
1102
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('textContent')) {
1103
+ return Promise.resolve({ result: { value: 'Hello World' } });
1104
+ }
1105
+ return originalSend(method, params);
1106
+ });
1107
+ mockPageController.session.send = mockElementLocator.session.send;
1108
+
1109
+ const result = await testRunner.executeStep({
1110
+ assert: { text: 'Goodbye' }
1111
+ });
1112
+
1113
+ assert.strictEqual(result.status, 'failed');
1114
+ assert.ok(result.error.includes('Text assertion failed'));
1115
+
1116
+ mockElementLocator.session.send = originalSend;
1117
+ mockPageController.session.send = originalSend;
1118
+ });
1119
+
1120
+ it('should support text assertion with selector', async () => {
1121
+ const originalSend = mockElementLocator.session.send;
1122
+ mockElementLocator.session.send = mock.fn((method, params) => {
1123
+ if (method === 'Runtime.evaluate' && params?.expression?.includes('textContent')) {
1124
+ // Verify the selector is being used
1125
+ assert.ok(params.expression.includes('h1'));
1126
+ return Promise.resolve({ result: { value: 'Page Title' } });
1127
+ }
1128
+ return originalSend(method, params);
1129
+ });
1130
+ mockPageController.session.send = mockElementLocator.session.send;
1131
+
1132
+ const result = await testRunner.executeStep({
1133
+ assert: { selector: 'h1', text: 'Title' }
1134
+ });
1135
+
1136
+ assert.strictEqual(result.status, 'passed');
1137
+ assert.strictEqual(result.output.assertions[0].selector, 'h1');
1138
+
1139
+ mockElementLocator.session.send = originalSend;
1140
+ mockPageController.session.send = originalSend;
1141
+ });
1142
+ });
1143
+
1144
+ describe('executeStep - queryAll', () => {
1145
+ it('should execute multiple queries and return results', async () => {
1146
+ const originalSend = mockElementLocator.session.send;
1147
+ mockElementLocator.querySelectorAll = mock.fn((selector) => {
1148
+ if (selector === 'h1') {
1149
+ return Promise.resolve([{
1150
+ objectId: 'h1-obj',
1151
+ dispose: mock.fn(() => Promise.resolve())
1152
+ }]);
1153
+ }
1154
+ if (selector === 'a') {
1155
+ return Promise.resolve([
1156
+ { objectId: 'a1-obj', dispose: mock.fn(() => Promise.resolve()) },
1157
+ { objectId: 'a2-obj', dispose: mock.fn(() => Promise.resolve()) }
1158
+ ]);
1159
+ }
1160
+ return Promise.resolve([]);
1161
+ });
1162
+
1163
+ mockElementLocator.session.send = mock.fn((method, params) => {
1164
+ // Mock text content for query output
1165
+ if (method === 'Runtime.callFunctionOn') {
1166
+ return Promise.resolve({ result: { value: 'Sample text' } });
1167
+ }
1168
+ return originalSend(method, params);
1169
+ });
1170
+
1171
+ const result = await testRunner.executeStep({
1172
+ queryAll: {
1173
+ title: 'h1',
1174
+ links: 'a'
1175
+ }
1176
+ });
1177
+
1178
+ assert.strictEqual(result.status, 'passed');
1179
+ assert.strictEqual(result.action, 'queryAll');
1180
+ assert.ok(result.output.title);
1181
+ assert.ok(result.output.links);
1182
+ assert.strictEqual(result.output.title.total, 1);
1183
+ assert.strictEqual(result.output.links.total, 2);
1184
+
1185
+ mockElementLocator.session.send = originalSend;
1186
+ });
1187
+
1188
+ it('should handle errors in individual queries', async () => {
1189
+ mockElementLocator.querySelectorAll = mock.fn((selector) => {
1190
+ if (selector === 'h1') {
1191
+ return Promise.resolve([{
1192
+ objectId: 'h1-obj',
1193
+ dispose: mock.fn(() => Promise.resolve())
1194
+ }]);
1195
+ }
1196
+ if (selector === '.nonexistent') {
1197
+ throw new Error('Query failed');
1198
+ }
1199
+ return Promise.resolve([]);
1200
+ });
1201
+
1202
+ const result = await testRunner.executeStep({
1203
+ queryAll: {
1204
+ title: 'h1',
1205
+ missing: '.nonexistent'
1206
+ }
1207
+ });
1208
+
1209
+ assert.strictEqual(result.status, 'passed');
1210
+ assert.ok(result.output.title);
1211
+ assert.ok(result.output.missing.error);
1212
+
1213
+ // Restore mock
1214
+ mockElementLocator.querySelectorAll = mock.fn(() => Promise.resolve([]));
1215
+ });
1216
+ });
1217
+
1218
+ describe('executeStep - cookies with name filter', () => {
1219
+ it('should filter cookies by name', async () => {
1220
+ const mockCookieManager = {
1221
+ getCookies: mock.fn(() => Promise.resolve([
1222
+ { name: 'session_id', value: 'abc123', domain: 'example.com' },
1223
+ { name: 'auth_token', value: 'xyz789', domain: 'example.com' },
1224
+ { name: 'tracking_id', value: 'track123', domain: 'example.com' }
1225
+ ]))
1226
+ };
1227
+
1228
+ const testRunnerWithCookies = createTestRunner({
1229
+ pageController: mockPageController,
1230
+ elementLocator: mockElementLocator,
1231
+ inputEmulator: mockInputEmulator,
1232
+ screenshotCapture: mockScreenshotCapture,
1233
+ cookieManager: mockCookieManager
1234
+ });
1235
+
1236
+ const result = await testRunnerWithCookies.executeStep({
1237
+ cookies: { get: true, name: 'session_id' }
1238
+ });
1239
+
1240
+ assert.strictEqual(result.status, 'passed');
1241
+ assert.strictEqual(result.output.cookies.length, 1);
1242
+ assert.strictEqual(result.output.cookies[0].name, 'session_id');
1243
+ });
1244
+
1245
+ it('should filter cookies by multiple names', async () => {
1246
+ const mockCookieManager = {
1247
+ getCookies: mock.fn(() => Promise.resolve([
1248
+ { name: 'session_id', value: 'abc123', domain: 'example.com' },
1249
+ { name: 'auth_token', value: 'xyz789', domain: 'example.com' },
1250
+ { name: 'tracking_id', value: 'track123', domain: 'example.com' }
1251
+ ]))
1252
+ };
1253
+
1254
+ const testRunnerWithCookies = createTestRunner({
1255
+ pageController: mockPageController,
1256
+ elementLocator: mockElementLocator,
1257
+ inputEmulator: mockInputEmulator,
1258
+ screenshotCapture: mockScreenshotCapture,
1259
+ cookieManager: mockCookieManager
1260
+ });
1261
+
1262
+ const result = await testRunnerWithCookies.executeStep({
1263
+ cookies: { get: true, name: ['session_id', 'auth_token'] }
1264
+ });
1265
+
1266
+ assert.strictEqual(result.status, 'passed');
1267
+ assert.strictEqual(result.output.cookies.length, 2);
1268
+ const names = result.output.cookies.map(c => c.name);
1269
+ assert.ok(names.includes('session_id'));
1270
+ assert.ok(names.includes('auth_token'));
1271
+ assert.ok(!names.includes('tracking_id'));
1272
+ });
1273
+ });
1274
+
1275
+ describe('executeStep - cookies with human-readable expiration', () => {
1276
+ it('should parse hours expiration', async () => {
1277
+ const setCookiesCall = { cookies: null };
1278
+ const mockCookieManager = {
1279
+ setCookies: mock.fn((cookies) => {
1280
+ setCookiesCall.cookies = cookies;
1281
+ return Promise.resolve();
1282
+ })
1283
+ };
1284
+
1285
+ const testRunnerWithCookies = createTestRunner({
1286
+ pageController: mockPageController,
1287
+ elementLocator: mockElementLocator,
1288
+ inputEmulator: mockInputEmulator,
1289
+ screenshotCapture: mockScreenshotCapture,
1290
+ cookieManager: mockCookieManager
1291
+ });
1292
+
1293
+ const beforeTime = Math.floor(Date.now() / 1000);
1294
+ await testRunnerWithCookies.executeStep({
1295
+ cookies: { set: [{ name: 'temp', value: 'data', domain: 'example.com', expires: '1h' }] }
1296
+ });
1297
+ const afterTime = Math.floor(Date.now() / 1000);
1298
+
1299
+ assert.strictEqual(setCookiesCall.cookies.length, 1);
1300
+ const expiresTimestamp = setCookiesCall.cookies[0].expires;
1301
+ // Should be approximately 1 hour (3600 seconds) from now
1302
+ assert.ok(expiresTimestamp >= beforeTime + 3600 - 1);
1303
+ assert.ok(expiresTimestamp <= afterTime + 3600 + 1);
1304
+ });
1305
+
1306
+ it('should parse days expiration', async () => {
1307
+ const setCookiesCall = { cookies: null };
1308
+ const mockCookieManager = {
1309
+ setCookies: mock.fn((cookies) => {
1310
+ setCookiesCall.cookies = cookies;
1311
+ return Promise.resolve();
1312
+ })
1313
+ };
1314
+
1315
+ const testRunnerWithCookies = createTestRunner({
1316
+ pageController: mockPageController,
1317
+ elementLocator: mockElementLocator,
1318
+ inputEmulator: mockInputEmulator,
1319
+ screenshotCapture: mockScreenshotCapture,
1320
+ cookieManager: mockCookieManager
1321
+ });
1322
+
1323
+ const beforeTime = Math.floor(Date.now() / 1000);
1324
+ await testRunnerWithCookies.executeStep({
1325
+ cookies: { set: [{ name: 'persist', value: 'data', domain: 'example.com', expires: '7d' }] }
1326
+ });
1327
+ const afterTime = Math.floor(Date.now() / 1000);
1328
+
1329
+ const expiresTimestamp = setCookiesCall.cookies[0].expires;
1330
+ // Should be approximately 7 days from now
1331
+ const sevenDaysInSeconds = 7 * 24 * 60 * 60;
1332
+ assert.ok(expiresTimestamp >= beforeTime + sevenDaysInSeconds - 1);
1333
+ assert.ok(expiresTimestamp <= afterTime + sevenDaysInSeconds + 1);
1334
+ });
1335
+
1336
+ it('should parse minutes expiration', async () => {
1337
+ const setCookiesCall = { cookies: null };
1338
+ const mockCookieManager = {
1339
+ setCookies: mock.fn((cookies) => {
1340
+ setCookiesCall.cookies = cookies;
1341
+ return Promise.resolve();
1342
+ })
1343
+ };
1344
+
1345
+ const testRunnerWithCookies = createTestRunner({
1346
+ pageController: mockPageController,
1347
+ elementLocator: mockElementLocator,
1348
+ inputEmulator: mockInputEmulator,
1349
+ screenshotCapture: mockScreenshotCapture,
1350
+ cookieManager: mockCookieManager
1351
+ });
1352
+
1353
+ const beforeTime = Math.floor(Date.now() / 1000);
1354
+ await testRunnerWithCookies.executeStep({
1355
+ cookies: { set: [{ name: 'short', value: 'data', domain: 'example.com', expires: '30m' }] }
1356
+ });
1357
+ const afterTime = Math.floor(Date.now() / 1000);
1358
+
1359
+ const expiresTimestamp = setCookiesCall.cookies[0].expires;
1360
+ // Should be approximately 30 minutes from now
1361
+ const thirtyMinutesInSeconds = 30 * 60;
1362
+ assert.ok(expiresTimestamp >= beforeTime + thirtyMinutesInSeconds - 1);
1363
+ assert.ok(expiresTimestamp <= afterTime + thirtyMinutesInSeconds + 1);
1364
+ });
1365
+
1366
+ it('should parse weeks expiration', async () => {
1367
+ const setCookiesCall = { cookies: null };
1368
+ const mockCookieManager = {
1369
+ setCookies: mock.fn((cookies) => {
1370
+ setCookiesCall.cookies = cookies;
1371
+ return Promise.resolve();
1372
+ })
1373
+ };
1374
+
1375
+ const testRunnerWithCookies = createTestRunner({
1376
+ pageController: mockPageController,
1377
+ elementLocator: mockElementLocator,
1378
+ inputEmulator: mockInputEmulator,
1379
+ screenshotCapture: mockScreenshotCapture,
1380
+ cookieManager: mockCookieManager
1381
+ });
1382
+
1383
+ const beforeTime = Math.floor(Date.now() / 1000);
1384
+ await testRunnerWithCookies.executeStep({
1385
+ cookies: { set: [{ name: 'weekly', value: 'data', domain: 'example.com', expires: '2w' }] }
1386
+ });
1387
+ const afterTime = Math.floor(Date.now() / 1000);
1388
+
1389
+ const expiresTimestamp = setCookiesCall.cookies[0].expires;
1390
+ // Should be approximately 2 weeks from now
1391
+ const twoWeeksInSeconds = 2 * 7 * 24 * 60 * 60;
1392
+ assert.ok(expiresTimestamp >= beforeTime + twoWeeksInSeconds - 1);
1393
+ assert.ok(expiresTimestamp <= afterTime + twoWeeksInSeconds + 1);
1394
+ });
1395
+
1396
+ it('should preserve numeric expiration timestamps', async () => {
1397
+ const setCookiesCall = { cookies: null };
1398
+ const mockCookieManager = {
1399
+ setCookies: mock.fn((cookies) => {
1400
+ setCookiesCall.cookies = cookies;
1401
+ return Promise.resolve();
1402
+ })
1403
+ };
1404
+
1405
+ const testRunnerWithCookies = createTestRunner({
1406
+ pageController: mockPageController,
1407
+ elementLocator: mockElementLocator,
1408
+ inputEmulator: mockInputEmulator,
1409
+ screenshotCapture: mockScreenshotCapture,
1410
+ cookieManager: mockCookieManager
1411
+ });
1412
+
1413
+ const specificTimestamp = 1706547600;
1414
+ await testRunnerWithCookies.executeStep({
1415
+ cookies: { set: [{ name: 'fixed', value: 'data', domain: 'example.com', expires: specificTimestamp }] }
1416
+ });
1417
+
1418
+ assert.strictEqual(setCookiesCall.cookies[0].expires, specificTimestamp);
1419
+ });
1420
+ });
1421
+
1422
+ describe('executeStep - console with stackTrace', () => {
1423
+ it('should include stack trace when option is enabled', async () => {
1424
+ const mockConsoleCapture = {
1425
+ getMessages: mock.fn(() => [
1426
+ {
1427
+ level: 'error',
1428
+ text: 'Uncaught TypeError: Cannot read property',
1429
+ type: 'console',
1430
+ url: 'https://example.com/app.js',
1431
+ line: 42,
1432
+ timestamp: Date.now(),
1433
+ stackTrace: {
1434
+ callFrames: [
1435
+ { functionName: 'handleClick', url: 'https://example.com/app.js', lineNumber: 42, columnNumber: 15 },
1436
+ { functionName: '', url: 'https://example.com/app.js', lineNumber: 100, columnNumber: 5 }
1437
+ ]
1438
+ }
1439
+ }
1440
+ ])
1441
+ };
1442
+
1443
+ const testRunnerWithConsole = createTestRunner({
1444
+ pageController: mockPageController,
1445
+ elementLocator: mockElementLocator,
1446
+ inputEmulator: mockInputEmulator,
1447
+ screenshotCapture: mockScreenshotCapture,
1448
+ consoleCapture: mockConsoleCapture
1449
+ });
1450
+
1451
+ const result = await testRunnerWithConsole.executeStep({
1452
+ console: { stackTrace: true }
1453
+ });
1454
+
1455
+ assert.strictEqual(result.status, 'passed');
1456
+ assert.strictEqual(result.output.messages.length, 1);
1457
+ assert.ok(result.output.messages[0].stackTrace);
1458
+ assert.strictEqual(result.output.messages[0].stackTrace.length, 2);
1459
+ assert.strictEqual(result.output.messages[0].stackTrace[0].functionName, 'handleClick');
1460
+ assert.strictEqual(result.output.messages[0].stackTrace[0].lineNumber, 42);
1461
+ assert.strictEqual(result.output.messages[0].stackTrace[1].functionName, '(anonymous)');
1462
+ });
1463
+
1464
+ it('should not include stack trace when option is disabled', async () => {
1465
+ const mockConsoleCapture = {
1466
+ getMessages: mock.fn(() => [
1467
+ {
1468
+ level: 'error',
1469
+ text: 'Error message',
1470
+ type: 'console',
1471
+ timestamp: Date.now(),
1472
+ stackTrace: {
1473
+ callFrames: [
1474
+ { functionName: 'test', url: 'test.js', lineNumber: 1, columnNumber: 1 }
1475
+ ]
1476
+ }
1477
+ }
1478
+ ])
1479
+ };
1480
+
1481
+ const testRunnerWithConsole = createTestRunner({
1482
+ pageController: mockPageController,
1483
+ elementLocator: mockElementLocator,
1484
+ inputEmulator: mockInputEmulator,
1485
+ screenshotCapture: mockScreenshotCapture,
1486
+ consoleCapture: mockConsoleCapture
1487
+ });
1488
+
1489
+ const result = await testRunnerWithConsole.executeStep({
1490
+ console: { stackTrace: false }
1491
+ });
1492
+
1493
+ assert.strictEqual(result.status, 'passed');
1494
+ assert.strictEqual(result.output.messages.length, 1);
1495
+ assert.strictEqual(result.output.messages[0].stackTrace, undefined);
1496
+ });
1497
+
1498
+ it('should handle messages without stack trace gracefully', async () => {
1499
+ const mockConsoleCapture = {
1500
+ getMessages: mock.fn(() => [
1501
+ {
1502
+ level: 'log',
1503
+ text: 'Simple log message',
1504
+ type: 'console',
1505
+ timestamp: Date.now()
1506
+ // No stackTrace property
1507
+ }
1508
+ ])
1509
+ };
1510
+
1511
+ const testRunnerWithConsole = createTestRunner({
1512
+ pageController: mockPageController,
1513
+ elementLocator: mockElementLocator,
1514
+ inputEmulator: mockInputEmulator,
1515
+ screenshotCapture: mockScreenshotCapture,
1516
+ consoleCapture: mockConsoleCapture
1517
+ });
1518
+
1519
+ const result = await testRunnerWithConsole.executeStep({
1520
+ console: { stackTrace: true }
1521
+ });
1522
+
1523
+ assert.strictEqual(result.status, 'passed');
1524
+ assert.strictEqual(result.output.messages.length, 1);
1525
+ // stackTrace should be undefined when not present on the message
1526
+ assert.strictEqual(result.output.messages[0].stackTrace, undefined);
1527
+ });
1528
+ });
1529
+ });