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,586 @@
1
+ import { describe, it, mock, beforeEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { createElementHandle } from '../dom.js';
4
+ import { ErrorTypes, staleElementError } from '../utils.js';
5
+
6
+ describe('ElementHandle', () => {
7
+ let mockCdp;
8
+ let handle;
9
+ const testObjectId = 'test-object-id-123';
10
+
11
+ beforeEach(() => {
12
+ mockCdp = {
13
+ send: mock.fn(async () => ({}))
14
+ };
15
+ handle = createElementHandle(mockCdp, testObjectId);
16
+ });
17
+
18
+ describe('constructor', () => {
19
+ it('should throw if cdp is not provided', () => {
20
+ assert.throws(() => createElementHandle(null, 'obj-id'), {
21
+ message: 'CDP session is required'
22
+ });
23
+ });
24
+
25
+ it('should throw if objectId is not provided', () => {
26
+ assert.throws(() => createElementHandle(mockCdp, null), {
27
+ message: 'objectId is required'
28
+ });
29
+ });
30
+
31
+ it('should store objectId correctly', () => {
32
+ assert.strictEqual(handle.objectId, testObjectId);
33
+ });
34
+
35
+ it('should accept selector option', () => {
36
+ const handleWithSelector = createElementHandle(mockCdp, testObjectId, { selector: '#myButton' });
37
+ assert.strictEqual(handleWithSelector.selector, '#myButton');
38
+ });
39
+
40
+ it('should default selector to null', () => {
41
+ assert.strictEqual(handle.selector, null);
42
+ });
43
+ });
44
+
45
+ describe('getBoundingBox', () => {
46
+ it('should call Runtime.callFunctionOn with correct parameters', async () => {
47
+ const expectedBox = { x: 10, y: 20, width: 100, height: 50 };
48
+ mockCdp.send = mock.fn(async () => ({
49
+ result: { value: expectedBox }
50
+ }));
51
+
52
+ const result = await handle.getBoundingBox();
53
+
54
+ assert.strictEqual(mockCdp.send.mock.calls.length, 1);
55
+ const [method, params] = mockCdp.send.mock.calls[0].arguments;
56
+ assert.strictEqual(method, 'Runtime.callFunctionOn');
57
+ assert.strictEqual(params.objectId, testObjectId);
58
+ assert.strictEqual(params.returnByValue, true);
59
+ assert.ok(params.functionDeclaration.includes('getBoundingClientRect'));
60
+ assert.deepStrictEqual(result, expectedBox);
61
+ });
62
+
63
+ it('should throw if handle is disposed', async () => {
64
+ await handle.dispose();
65
+ await assert.rejects(() => handle.getBoundingBox(), {
66
+ message: 'ElementHandle has been disposed'
67
+ });
68
+ });
69
+ });
70
+
71
+ describe('getClickPoint', () => {
72
+ it('should return center coordinates of bounding box', async () => {
73
+ mockCdp.send = mock.fn(async () => ({
74
+ result: { value: { x: 100, y: 200, width: 50, height: 30 } }
75
+ }));
76
+
77
+ const point = await handle.getClickPoint();
78
+
79
+ assert.deepStrictEqual(point, { x: 125, y: 215 });
80
+ });
81
+
82
+ it('should throw if handle is disposed', async () => {
83
+ await handle.dispose();
84
+ await assert.rejects(() => handle.getClickPoint(), {
85
+ message: 'ElementHandle has been disposed'
86
+ });
87
+ });
88
+ });
89
+
90
+ describe('isConnectedToDOM', () => {
91
+ it('should return true when element is connected', async () => {
92
+ mockCdp.send = mock.fn(async () => ({
93
+ result: { value: true }
94
+ }));
95
+
96
+ const result = await handle.isConnectedToDOM();
97
+
98
+ assert.strictEqual(result, true);
99
+ const [method, params] = mockCdp.send.mock.calls[0].arguments;
100
+ assert.strictEqual(method, 'Runtime.callFunctionOn');
101
+ assert.ok(params.functionDeclaration.includes('isConnected'));
102
+ });
103
+
104
+ it('should return false when element is not connected', async () => {
105
+ mockCdp.send = mock.fn(async () => ({
106
+ result: { value: false }
107
+ }));
108
+
109
+ const result = await handle.isConnectedToDOM();
110
+
111
+ assert.strictEqual(result, false);
112
+ });
113
+
114
+ it('should return false when CDP throws stale element error', async () => {
115
+ mockCdp.send = mock.fn(async () => {
116
+ throw new Error('Could not find object with given id');
117
+ });
118
+
119
+ const result = await handle.isConnectedToDOM();
120
+
121
+ assert.strictEqual(result, false);
122
+ });
123
+
124
+ it('should rethrow non-stale errors', async () => {
125
+ mockCdp.send = mock.fn(async () => {
126
+ throw new Error('Network error');
127
+ });
128
+
129
+ await assert.rejects(() => handle.isConnectedToDOM(), {
130
+ message: 'Network error'
131
+ });
132
+ });
133
+
134
+ it('should throw if handle is disposed', async () => {
135
+ await handle.dispose();
136
+ await assert.rejects(() => handle.isConnectedToDOM(), {
137
+ message: 'ElementHandle has been disposed'
138
+ });
139
+ });
140
+ });
141
+
142
+ describe('ensureConnected', () => {
143
+ it('should not throw when element is connected', async () => {
144
+ mockCdp.send = mock.fn(async () => ({
145
+ result: { value: true }
146
+ }));
147
+
148
+ await assert.doesNotReject(() => handle.ensureConnected('click'));
149
+ });
150
+
151
+ it('should throw StaleElementError when element is not connected', async () => {
152
+ mockCdp.send = mock.fn(async () => ({
153
+ result: { value: false }
154
+ }));
155
+
156
+ await assert.rejects(
157
+ () => handle.ensureConnected('click'),
158
+ (err) => {
159
+ assert.strictEqual(err.name, ErrorTypes.STALE_ELEMENT);
160
+ assert.strictEqual(err.objectId, testObjectId);
161
+ assert.strictEqual(err.operation, 'click');
162
+ return true;
163
+ }
164
+ );
165
+ });
166
+
167
+ it('should include operation name in error', async () => {
168
+ mockCdp.send = mock.fn(async () => ({
169
+ result: { value: false }
170
+ }));
171
+
172
+ await assert.rejects(
173
+ () => handle.ensureConnected('getBoundingBox'),
174
+ (err) => {
175
+ assert.ok(err.message.includes('getBoundingBox'));
176
+ return true;
177
+ }
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('isVisible', () => {
183
+ it('should return true for visible elements', async () => {
184
+ mockCdp.send = mock.fn(async () => ({
185
+ result: { value: true }
186
+ }));
187
+
188
+ const result = await handle.isVisible();
189
+
190
+ assert.strictEqual(result, true);
191
+ const [method, params] = mockCdp.send.mock.calls[0].arguments;
192
+ assert.strictEqual(method, 'Runtime.callFunctionOn');
193
+ assert.strictEqual(params.returnByValue, true);
194
+ });
195
+
196
+ it('should return false for hidden elements', async () => {
197
+ mockCdp.send = mock.fn(async () => ({
198
+ result: { value: false }
199
+ }));
200
+
201
+ const result = await handle.isVisible();
202
+
203
+ assert.strictEqual(result, false);
204
+ });
205
+ });
206
+
207
+ describe('isActionable', () => {
208
+ it('should return actionable true when element is actionable', async () => {
209
+ mockCdp.send = mock.fn(async () => ({
210
+ result: { value: { actionable: true, reason: null } }
211
+ }));
212
+
213
+ const result = await handle.isActionable();
214
+
215
+ assert.deepStrictEqual(result, { actionable: true, reason: null });
216
+ });
217
+
218
+ it('should return reason when element is not actionable', async () => {
219
+ mockCdp.send = mock.fn(async () => ({
220
+ result: { value: { actionable: false, reason: 'hidden by CSS' } }
221
+ }));
222
+
223
+ const result = await handle.isActionable();
224
+
225
+ assert.deepStrictEqual(result, { actionable: false, reason: 'hidden by CSS' });
226
+ });
227
+
228
+ it('should detect opacity:0 as hidden', async () => {
229
+ mockCdp.send = mock.fn(async () => ({
230
+ result: { value: { actionable: false, reason: 'hidden by CSS' } }
231
+ }));
232
+
233
+ const result = await handle.isActionable();
234
+
235
+ assert.strictEqual(result.actionable, false);
236
+ assert.strictEqual(result.reason, 'hidden by CSS');
237
+ });
238
+
239
+ it('should detect element not connected to DOM', async () => {
240
+ mockCdp.send = mock.fn(async () => ({
241
+ result: { value: { actionable: false, reason: 'element not connected to DOM' } }
242
+ }));
243
+
244
+ const result = await handle.isActionable();
245
+
246
+ assert.strictEqual(result.actionable, false);
247
+ assert.strictEqual(result.reason, 'element not connected to DOM');
248
+ });
249
+
250
+ it('should detect element center not hittable', async () => {
251
+ mockCdp.send = mock.fn(async () => ({
252
+ result: { value: { actionable: false, reason: 'element center not hittable' } }
253
+ }));
254
+
255
+ const result = await handle.isActionable();
256
+
257
+ assert.strictEqual(result.actionable, false);
258
+ assert.strictEqual(result.reason, 'element center not hittable');
259
+ });
260
+ });
261
+
262
+ describe('scrollIntoView', () => {
263
+ it('should call Runtime.callFunctionOn with scrollIntoView options', async () => {
264
+ mockCdp.send = mock.fn(async () => ({}));
265
+
266
+ await handle.scrollIntoView({ block: 'center', inline: 'nearest' });
267
+
268
+ assert.strictEqual(mockCdp.send.mock.calls.length, 1);
269
+ const [method, params] = mockCdp.send.mock.calls[0].arguments;
270
+ assert.strictEqual(method, 'Runtime.callFunctionOn');
271
+ assert.strictEqual(params.objectId, testObjectId);
272
+ assert.ok(params.functionDeclaration.includes('scrollIntoView'));
273
+ assert.deepStrictEqual(params.arguments, [{ value: 'center' }, { value: 'nearest' }]);
274
+ });
275
+
276
+ it('should use default block and inline values', async () => {
277
+ mockCdp.send = mock.fn(async () => ({}));
278
+
279
+ await handle.scrollIntoView();
280
+
281
+ const [, params] = mockCdp.send.mock.calls[0].arguments;
282
+ assert.deepStrictEqual(params.arguments, [{ value: 'center' }, { value: 'nearest' }]);
283
+ });
284
+ });
285
+
286
+ describe('evaluate', () => {
287
+ it('should evaluate function on element', async () => {
288
+ mockCdp.send = mock.fn(async () => ({
289
+ result: { value: 'test-value' }
290
+ }));
291
+
292
+ const result = await handle.evaluate(function() { return this.id; });
293
+
294
+ assert.strictEqual(result, 'test-value');
295
+ const [method, params] = mockCdp.send.mock.calls[0].arguments;
296
+ assert.strictEqual(method, 'Runtime.callFunctionOn');
297
+ assert.strictEqual(params.objectId, testObjectId);
298
+ assert.strictEqual(params.returnByValue, true);
299
+ });
300
+
301
+ it('should pass arguments to the function', async () => {
302
+ mockCdp.send = mock.fn(async () => ({
303
+ result: { value: 42 }
304
+ }));
305
+
306
+ await handle.evaluate(() => {}, 'arg1', 'arg2');
307
+
308
+ const [, params] = mockCdp.send.mock.calls[0].arguments;
309
+ assert.deepStrictEqual(params.arguments, [{ value: 'arg1' }, { value: 'arg2' }]);
310
+ });
311
+
312
+ it('should accept string function declaration', async () => {
313
+ mockCdp.send = mock.fn(async () => ({
314
+ result: { value: 'result' }
315
+ }));
316
+
317
+ await handle.evaluate('function() { return this.tagName; }');
318
+
319
+ const [, params] = mockCdp.send.mock.calls[0].arguments;
320
+ assert.strictEqual(params.functionDeclaration, 'function() { return this.tagName; }');
321
+ });
322
+ });
323
+
324
+ describe('dispose', () => {
325
+ it('should call Runtime.releaseObject with objectId', async () => {
326
+ mockCdp.send = mock.fn(async () => ({}));
327
+
328
+ await handle.dispose();
329
+
330
+ assert.strictEqual(mockCdp.send.mock.calls.length, 1);
331
+ const [method, params] = mockCdp.send.mock.calls[0].arguments;
332
+ assert.strictEqual(method, 'Runtime.releaseObject');
333
+ assert.strictEqual(params.objectId, testObjectId);
334
+ });
335
+
336
+ it('should mark handle as disposed', async () => {
337
+ assert.strictEqual(handle.isDisposed(), false);
338
+
339
+ await handle.dispose();
340
+
341
+ assert.strictEqual(handle.isDisposed(), true);
342
+ });
343
+
344
+ it('should not call releaseObject twice', async () => {
345
+ mockCdp.send = mock.fn(async () => ({}));
346
+
347
+ await handle.dispose();
348
+ await handle.dispose();
349
+
350
+ assert.strictEqual(mockCdp.send.mock.calls.length, 1);
351
+ });
352
+
353
+ it('should not throw if releaseObject fails', async () => {
354
+ mockCdp.send = mock.fn(async () => {
355
+ throw new Error('Release failed');
356
+ });
357
+
358
+ await assert.doesNotReject(() => handle.dispose());
359
+ assert.strictEqual(handle.isDisposed(), true);
360
+ });
361
+ });
362
+
363
+ describe('disposed handle operations', () => {
364
+ beforeEach(async () => {
365
+ mockCdp.send = mock.fn(async () => ({}));
366
+ await handle.dispose();
367
+ });
368
+
369
+ it('should throw on getBoundingBox', async () => {
370
+ await assert.rejects(() => handle.getBoundingBox(), {
371
+ message: 'ElementHandle has been disposed'
372
+ });
373
+ });
374
+
375
+ it('should throw on isVisible', async () => {
376
+ await assert.rejects(() => handle.isVisible(), {
377
+ message: 'ElementHandle has been disposed'
378
+ });
379
+ });
380
+
381
+ it('should throw on isActionable', async () => {
382
+ await assert.rejects(() => handle.isActionable(), {
383
+ message: 'ElementHandle has been disposed'
384
+ });
385
+ });
386
+
387
+ it('should throw on scrollIntoView', async () => {
388
+ await assert.rejects(() => handle.scrollIntoView(), {
389
+ message: 'ElementHandle has been disposed'
390
+ });
391
+ });
392
+
393
+ it('should throw on evaluate', async () => {
394
+ await assert.rejects(() => handle.evaluate(() => {}), {
395
+ message: 'ElementHandle has been disposed'
396
+ });
397
+ });
398
+ });
399
+
400
+ describe('stale element error handling', () => {
401
+ const staleErrorMessages = [
402
+ 'Could not find object with given id',
403
+ 'Object reference not found',
404
+ 'Cannot find context with specified id',
405
+ 'Node with given id does not belong to the document',
406
+ 'No node with given id found',
407
+ 'Object is not available',
408
+ 'No object with given id',
409
+ 'Object with given id not found'
410
+ ];
411
+
412
+ describe('getBoundingBox', () => {
413
+ staleErrorMessages.forEach(message => {
414
+ it(`should throw StaleElementError for "${message}"`, async () => {
415
+ mockCdp.send = mock.fn(async () => {
416
+ throw new Error(message);
417
+ });
418
+
419
+ await assert.rejects(
420
+ () => handle.getBoundingBox(),
421
+ (err) => {
422
+ assert.strictEqual(err.name, ErrorTypes.STALE_ELEMENT);
423
+ assert.strictEqual(err.operation, 'getBoundingBox');
424
+ assert.strictEqual(err.objectId, testObjectId);
425
+ return true;
426
+ }
427
+ );
428
+ });
429
+ });
430
+
431
+ it('should include selector in error when available', async () => {
432
+ const handleWithSelector = createElementHandle(mockCdp, testObjectId, { selector: '#myButton' });
433
+ mockCdp.send = mock.fn(async () => {
434
+ throw new Error('Could not find object with given id');
435
+ });
436
+
437
+ await assert.rejects(
438
+ () => handleWithSelector.getBoundingBox(),
439
+ (err) => {
440
+ assert.strictEqual(err.name, ErrorTypes.STALE_ELEMENT);
441
+ assert.strictEqual(err.selector, '#myButton');
442
+ assert.ok(err.message.includes('#myButton'));
443
+ return true;
444
+ }
445
+ );
446
+ });
447
+
448
+ it('should preserve original error as cause', async () => {
449
+ mockCdp.send = mock.fn(async () => {
450
+ throw new Error('Could not find object with given id');
451
+ });
452
+
453
+ await assert.rejects(
454
+ () => handle.getBoundingBox(),
455
+ (err) => {
456
+ assert.ok(err.cause instanceof Error);
457
+ assert.strictEqual(err.cause.message, 'Could not find object with given id');
458
+ return true;
459
+ }
460
+ );
461
+ });
462
+
463
+ it('should rethrow non-stale errors unchanged', async () => {
464
+ const originalError = new Error('Network timeout');
465
+ mockCdp.send = mock.fn(async () => {
466
+ throw originalError;
467
+ });
468
+
469
+ await assert.rejects(
470
+ () => handle.getBoundingBox(),
471
+ (err) => {
472
+ assert.strictEqual(err, originalError);
473
+ return true;
474
+ }
475
+ );
476
+ });
477
+ });
478
+
479
+ describe('isVisible', () => {
480
+ it('should throw StaleElementError for stale element', async () => {
481
+ mockCdp.send = mock.fn(async () => {
482
+ throw new Error('Could not find object with given id');
483
+ });
484
+
485
+ await assert.rejects(
486
+ () => handle.isVisible(),
487
+ (err) => {
488
+ assert.strictEqual(err.name, ErrorTypes.STALE_ELEMENT);
489
+ assert.strictEqual(err.operation, 'isVisible');
490
+ return true;
491
+ }
492
+ );
493
+ });
494
+ });
495
+
496
+ describe('isActionable', () => {
497
+ it('should throw StaleElementError for stale element', async () => {
498
+ mockCdp.send = mock.fn(async () => {
499
+ throw new Error('No node with given id found');
500
+ });
501
+
502
+ await assert.rejects(
503
+ () => handle.isActionable(),
504
+ (err) => {
505
+ assert.strictEqual(err.name, ErrorTypes.STALE_ELEMENT);
506
+ assert.strictEqual(err.operation, 'isActionable');
507
+ return true;
508
+ }
509
+ );
510
+ });
511
+ });
512
+
513
+ describe('scrollIntoView', () => {
514
+ it('should throw StaleElementError for stale element', async () => {
515
+ mockCdp.send = mock.fn(async () => {
516
+ throw new Error('Object reference not found');
517
+ });
518
+
519
+ await assert.rejects(
520
+ () => handle.scrollIntoView(),
521
+ (err) => {
522
+ assert.strictEqual(err.name, ErrorTypes.STALE_ELEMENT);
523
+ assert.strictEqual(err.operation, 'scrollIntoView');
524
+ return true;
525
+ }
526
+ );
527
+ });
528
+ });
529
+
530
+ describe('evaluate', () => {
531
+ it('should throw StaleElementError for stale element', async () => {
532
+ mockCdp.send = mock.fn(async () => {
533
+ throw new Error('Cannot find context with specified id');
534
+ });
535
+
536
+ await assert.rejects(
537
+ () => handle.evaluate(() => this.id),
538
+ (err) => {
539
+ assert.strictEqual(err.name, ErrorTypes.STALE_ELEMENT);
540
+ assert.strictEqual(err.operation, 'evaluate');
541
+ return true;
542
+ }
543
+ );
544
+ });
545
+ });
546
+ });
547
+
548
+ describe('staleElementError factory function', () => {
549
+ it('should support legacy string operation parameter', () => {
550
+ const error = staleElementError('obj-123', 'click');
551
+ assert.strictEqual(error.objectId, 'obj-123');
552
+ assert.strictEqual(error.operation, 'click');
553
+ assert.ok(error.message.includes('click'));
554
+ });
555
+
556
+ it('should support options object', () => {
557
+ const error = staleElementError('obj-123', {
558
+ operation: 'getBoundingBox',
559
+ selector: 'button.submit'
560
+ });
561
+ assert.strictEqual(error.objectId, 'obj-123');
562
+ assert.strictEqual(error.operation, 'getBoundingBox');
563
+ assert.strictEqual(error.selector, 'button.submit');
564
+ assert.ok(error.message.includes('button.submit'));
565
+ assert.ok(error.message.includes('getBoundingBox'));
566
+ });
567
+
568
+ it('should include all details in message', () => {
569
+ const error = staleElementError('obj-456', {
570
+ operation: 'click',
571
+ selector: '#login'
572
+ });
573
+ assert.ok(error.message.includes('obj-456'));
574
+ assert.ok(error.message.includes('click'));
575
+ assert.ok(error.message.includes('#login'));
576
+ });
577
+
578
+ it('should handle missing optional parameters', () => {
579
+ const error = staleElementError('obj-789');
580
+ assert.strictEqual(error.objectId, 'obj-789');
581
+ assert.strictEqual(error.operation, null);
582
+ assert.strictEqual(error.selector, null);
583
+ assert.ok(error.message.includes('obj-789'));
584
+ });
585
+ });
586
+ });