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.
- package/SKILL.md +543 -0
- package/install.js +92 -0
- package/package.json +47 -0
- package/src/aria.js +1302 -0
- package/src/capture.js +1359 -0
- package/src/cdp.js +905 -0
- package/src/cli.js +244 -0
- package/src/dom.js +3525 -0
- package/src/index.js +155 -0
- package/src/page.js +1720 -0
- package/src/runner.js +2111 -0
- package/src/tests/BrowserClient.test.js +588 -0
- package/src/tests/CDPConnection.test.js +598 -0
- package/src/tests/ChromeDiscovery.test.js +181 -0
- package/src/tests/ConsoleCapture.test.js +302 -0
- package/src/tests/ElementHandle.test.js +586 -0
- package/src/tests/ElementLocator.test.js +586 -0
- package/src/tests/ErrorAggregator.test.js +327 -0
- package/src/tests/InputEmulator.test.js +641 -0
- package/src/tests/NetworkErrorCapture.test.js +458 -0
- package/src/tests/PageController.test.js +822 -0
- package/src/tests/ScreenshotCapture.test.js +356 -0
- package/src/tests/SessionRegistry.test.js +257 -0
- package/src/tests/TargetManager.test.js +274 -0
- package/src/tests/TestRunner.test.js +1529 -0
- package/src/tests/WaitStrategy.test.js +406 -0
- package/src/tests/integration.test.js +431 -0
- package/src/utils.js +1034 -0
- package/uninstall.js +44 -0
|
@@ -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
|
+
});
|