cdp-skill 1.0.8 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +151 -239
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +237 -43
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +245 -69
  12. package/src/dom/click-executor.js +240 -76
  13. package/src/dom/element-locator.js +34 -25
  14. package/src/dom/fill-executor.js +55 -27
  15. package/src/page/dialog-handler.js +119 -0
  16. package/src/page/page-controller.js +190 -3
  17. package/src/runner/context-helpers.js +33 -55
  18. package/src/runner/execute-dynamic.js +8 -7
  19. package/src/runner/execute-form.js +11 -11
  20. package/src/runner/execute-input.js +2 -2
  21. package/src/runner/execute-interaction.js +99 -120
  22. package/src/runner/execute-navigation.js +11 -26
  23. package/src/runner/execute-query.js +8 -5
  24. package/src/runner/step-executors.js +225 -84
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -754
  27. package/src/tests/Aria.test.js +1025 -0
  28. package/src/tests/ContextHelpers.test.js +39 -28
  29. package/src/tests/ExecuteBrowser.test.js +572 -0
  30. package/src/tests/ExecuteDynamic.test.js +2 -457
  31. package/src/tests/ExecuteForm.test.js +700 -0
  32. package/src/tests/ExecuteInput.test.js +540 -0
  33. package/src/tests/ExecuteInteraction.test.js +319 -0
  34. package/src/tests/ExecuteQuery.test.js +820 -0
  35. package/src/tests/FillExecutor.test.js +2 -2
  36. package/src/tests/StepValidator.test.js +222 -76
  37. package/src/tests/TestRunner.test.js +36 -25
  38. package/src/tests/integration.test.js +2 -1
  39. package/src/types.js +9 -9
  40. package/src/utils/backoff.js +118 -0
  41. package/src/utils/cdp-helpers.js +130 -0
  42. package/src/utils/devices.js +140 -0
  43. package/src/utils/errors.js +242 -0
  44. package/src/utils/index.js +65 -0
  45. package/src/utils/temp.js +75 -0
  46. package/src/utils/validators.js +433 -0
  47. package/src/utils.js +14 -1142
@@ -0,0 +1,1025 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import {
4
+ createQueryOutputProcessor,
5
+ createRoleQueryExecutor,
6
+ createAriaSnapshot
7
+ } from '../aria.js';
8
+
9
+ describe('ARIA Module', () => {
10
+ let mockSession;
11
+
12
+ beforeEach(() => {
13
+ mockSession = {
14
+ send: mock.fn(async () => ({}))
15
+ };
16
+ });
17
+
18
+ afterEach(() => {
19
+ mock.reset();
20
+ });
21
+
22
+ // ============================================================================
23
+ // QueryOutputProcessor Tests
24
+ // ============================================================================
25
+
26
+ describe('createQueryOutputProcessor', () => {
27
+ let processor;
28
+
29
+ beforeEach(() => {
30
+ processor = createQueryOutputProcessor(mockSession);
31
+ });
32
+
33
+ it('should create processor with processOutput method', () => {
34
+ assert.ok(typeof processor.processOutput === 'function');
35
+ });
36
+
37
+ describe('processOutput with string mode', () => {
38
+ let mockElementHandle;
39
+
40
+ beforeEach(() => {
41
+ mockElementHandle = {
42
+ evaluate: mock.fn(async () => 'test content')
43
+ };
44
+ });
45
+
46
+ it('should extract text content', async () => {
47
+ const result = await processor.processOutput(mockElementHandle, 'text', { clean: false });
48
+ assert.strictEqual(result, 'test content');
49
+ });
50
+
51
+ it('should extract html content', async () => {
52
+ mockElementHandle.evaluate = mock.fn(async () => '<div>test</div>');
53
+ const result = await processor.processOutput(mockElementHandle, 'html', { clean: false });
54
+ assert.strictEqual(result, '<div>test</div>');
55
+ });
56
+
57
+ it('should extract href attribute', async () => {
58
+ mockElementHandle.evaluate = mock.fn(async () => 'https://example.com');
59
+ const result = await processor.processOutput(mockElementHandle, 'href', { clean: false });
60
+ assert.strictEqual(result, 'https://example.com');
61
+ });
62
+
63
+ it('should extract value attribute', async () => {
64
+ mockElementHandle.evaluate = mock.fn(async () => 'input value');
65
+ const result = await processor.processOutput(mockElementHandle, 'value', { clean: false });
66
+ assert.strictEqual(result, 'input value');
67
+ });
68
+
69
+ it('should extract tag name', async () => {
70
+ mockElementHandle.evaluate = mock.fn(async () => 'button');
71
+ const result = await processor.processOutput(mockElementHandle, 'tag', { clean: false });
72
+ assert.strictEqual(result, 'button');
73
+ });
74
+
75
+ it('should trim whitespace when clean=true', async () => {
76
+ mockElementHandle.evaluate = mock.fn(async () => ' trimmed ');
77
+ const result = await processor.processOutput(mockElementHandle, 'text', { clean: true });
78
+ assert.strictEqual(result, 'trimmed');
79
+ });
80
+
81
+ it('should handle empty/null values', async () => {
82
+ mockElementHandle.evaluate = mock.fn(async () => null);
83
+ const result = await processor.processOutput(mockElementHandle, 'text', { clean: false });
84
+ assert.strictEqual(result, '');
85
+ });
86
+ });
87
+
88
+ describe('processOutput with array of modes', () => {
89
+ let mockElementHandle;
90
+
91
+ beforeEach(() => {
92
+ mockElementHandle = {
93
+ evaluate: mock.fn(async () => 'test')
94
+ };
95
+ });
96
+
97
+ it('should return object with values for multiple modes', async () => {
98
+ let callCount = 0;
99
+ mockElementHandle.evaluate = mock.fn(async () => {
100
+ callCount++;
101
+ return callCount === 1 ? 'text content' : 'button';
102
+ });
103
+
104
+ const result = await processor.processOutput(mockElementHandle, ['text', 'tag'], { clean: false });
105
+ assert.ok(typeof result === 'object');
106
+ assert.strictEqual(result.text, 'text content');
107
+ assert.strictEqual(result.tag, 'button');
108
+ });
109
+ });
110
+
111
+ describe('processOutput with object specification', () => {
112
+ let mockElementHandle;
113
+
114
+ beforeEach(() => {
115
+ mockElementHandle = {
116
+ evaluate: mock.fn(async () => 'test')
117
+ };
118
+ });
119
+
120
+ it('should handle object with attribute key', async () => {
121
+ mockElementHandle.evaluate = mock.fn(async () => 'data-value');
122
+
123
+ const result = await processor.processOutput(mockElementHandle, { attribute: 'data-test' }, { clean: false });
124
+ assert.ok(typeof result === 'string');
125
+ });
126
+ });
127
+
128
+ describe('getAttribute', () => {
129
+ it('should extract custom attribute', async () => {
130
+ const mockElementHandle = {
131
+ evaluate: mock.fn(async () => 'custom-value')
132
+ };
133
+
134
+ // processOutput with @attr syntax uses getAttribute internally
135
+ const result = await processor.processOutput(mockElementHandle, '@data-id', { clean: false });
136
+ assert.ok(result !== undefined);
137
+ });
138
+ });
139
+ });
140
+
141
+ // ============================================================================
142
+ // RoleQueryExecutor Tests
143
+ // ============================================================================
144
+
145
+ describe('createRoleQueryExecutor', () => {
146
+ let executor;
147
+ let mockElementLocator;
148
+
149
+ beforeEach(() => {
150
+ mockElementLocator = {
151
+ querySelector: mock.fn(async () => ({ objectId: 'obj-123' }))
152
+ };
153
+ executor = createRoleQueryExecutor(mockSession, mockElementLocator);
154
+ });
155
+
156
+ it('should create executor with execute method', () => {
157
+ assert.ok(typeof executor.execute === 'function');
158
+ });
159
+
160
+ it('should create executor with queryByRoles method', () => {
161
+ assert.ok(typeof executor.queryByRoles === 'function');
162
+ });
163
+
164
+ describe('query by single role', () => {
165
+ it('should query button role', async () => {
166
+ mockSession.send = mock.fn(async (method) => {
167
+ if (method === 'Runtime.evaluate') {
168
+ return {
169
+ result: {
170
+ objectId: 'array-1',
171
+ value: undefined
172
+ }
173
+ };
174
+ }
175
+ if (method === 'Runtime.getProperties') {
176
+ return {
177
+ result: [
178
+ { name: '0', value: { objectId: 'obj-1' } },
179
+ { name: 'length', value: { value: 1 } }
180
+ ]
181
+ };
182
+ }
183
+ if (method === 'Runtime.callFunctionOn') {
184
+ return {
185
+ result: { value: 'Submit' }
186
+ };
187
+ }
188
+ return {};
189
+ });
190
+
191
+ const result = await executor.execute({ role: 'button' });
192
+ assert.ok(Array.isArray(result.elements) || typeof result === 'object');
193
+ });
194
+
195
+ it('should query textbox role', async () => {
196
+ mockSession.send = mock.fn(async (method) => {
197
+ if (method === 'Runtime.evaluate') {
198
+ return { result: { objectId: 'array-1', value: undefined } };
199
+ }
200
+ if (method === 'Runtime.getProperties') {
201
+ return { result: [] };
202
+ }
203
+ if (method === 'Runtime.releaseObject') {
204
+ return {};
205
+ }
206
+ return {};
207
+ });
208
+
209
+ await executor.execute({ role: 'textbox' });
210
+ const calls = mockSession.send.mock.calls;
211
+ assert.ok(calls.length > 0);
212
+ });
213
+
214
+ it('should query checkbox role', async () => {
215
+ mockSession.send = mock.fn(async (method) => {
216
+ if (method === 'Runtime.evaluate') {
217
+ return { result: { objectId: 'array-1', value: undefined } };
218
+ }
219
+ if (method === 'Runtime.getProperties') {
220
+ return { result: [] };
221
+ }
222
+ if (method === 'Runtime.releaseObject') {
223
+ return {};
224
+ }
225
+ return {};
226
+ });
227
+
228
+ await executor.execute({ role: 'checkbox' });
229
+ const calls = mockSession.send.mock.calls;
230
+ assert.ok(calls.length > 0);
231
+ });
232
+
233
+ it('should query link role', async () => {
234
+ mockSession.send = mock.fn(async (method) => {
235
+ if (method === 'Runtime.evaluate') {
236
+ return { result: { objectId: 'array-1', value: undefined } };
237
+ }
238
+ if (method === 'Runtime.getProperties') {
239
+ return { result: [] };
240
+ }
241
+ if (method === 'Runtime.releaseObject') {
242
+ return {};
243
+ }
244
+ return {};
245
+ });
246
+
247
+ await executor.execute({ role: 'link' });
248
+ const calls = mockSession.send.mock.calls;
249
+ assert.ok(calls.length > 0);
250
+ });
251
+
252
+ it('should query heading role', async () => {
253
+ mockSession.send = mock.fn(async (method) => {
254
+ if (method === 'Runtime.evaluate') {
255
+ return { result: { objectId: 'array-1', value: undefined } };
256
+ }
257
+ if (method === 'Runtime.getProperties') {
258
+ return { result: [] };
259
+ }
260
+ if (method === 'Runtime.releaseObject') {
261
+ return {};
262
+ }
263
+ return {};
264
+ });
265
+
266
+ await executor.execute({ role: 'heading' });
267
+ const calls = mockSession.send.mock.calls;
268
+ assert.ok(calls.length > 0);
269
+ });
270
+ });
271
+
272
+ describe('query with name filter', () => {
273
+ it('should filter by name (contains)', async () => {
274
+ mockSession.send = mock.fn(async (method) => {
275
+ if (method === 'Runtime.evaluate') {
276
+ return {
277
+ result: { objectId: 'array-1', value: undefined }
278
+ };
279
+ }
280
+ if (method === 'Runtime.getProperties') {
281
+ return { result: [] };
282
+ }
283
+ return {};
284
+ });
285
+
286
+ await executor.execute({ role: 'button', name: 'Submit' });
287
+ const evaluateCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.evaluate');
288
+ assert.ok(evaluateCalls.length > 0);
289
+ // Check that name filter is in the expression
290
+ const expression = evaluateCalls[0].arguments[1].expression;
291
+ assert.ok(expression.includes('Submit') || expression.includes('nameFilter'));
292
+ });
293
+
294
+ it('should filter by name (exact match)', async () => {
295
+ mockSession.send = mock.fn(async (method) => {
296
+ if (method === 'Runtime.evaluate') {
297
+ return {
298
+ result: { objectId: 'array-1', value: undefined }
299
+ };
300
+ }
301
+ if (method === 'Runtime.getProperties') {
302
+ return { result: [] };
303
+ }
304
+ return {};
305
+ });
306
+
307
+ await executor.execute({ role: 'button', name: 'Submit', nameExact: true });
308
+ const evaluateCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.evaluate');
309
+ assert.ok(evaluateCalls.length > 0);
310
+ });
311
+
312
+ it('should filter by nameRegex', async () => {
313
+ mockSession.send = mock.fn(async (method) => {
314
+ if (method === 'Runtime.evaluate') {
315
+ return {
316
+ result: { objectId: 'array-1', value: undefined }
317
+ };
318
+ }
319
+ if (method === 'Runtime.getProperties') {
320
+ return { result: [] };
321
+ }
322
+ return {};
323
+ });
324
+
325
+ await executor.execute({ role: 'button', nameRegex: 'Sub.*' });
326
+ const evaluateCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.evaluate');
327
+ assert.ok(evaluateCalls.length > 0);
328
+ });
329
+ });
330
+
331
+ describe('query with state filters', () => {
332
+ it('should filter by checked state', async () => {
333
+ mockSession.send = mock.fn(async (method) => {
334
+ if (method === 'Runtime.evaluate') {
335
+ return {
336
+ result: { objectId: 'array-1', value: undefined }
337
+ };
338
+ }
339
+ if (method === 'Runtime.getProperties') {
340
+ return { result: [] };
341
+ }
342
+ return {};
343
+ });
344
+
345
+ await executor.execute({ role: 'checkbox', checked: true });
346
+ const evaluateCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.evaluate');
347
+ assert.ok(evaluateCalls.length > 0);
348
+ });
349
+
350
+ it('should filter by disabled state', async () => {
351
+ mockSession.send = mock.fn(async (method) => {
352
+ if (method === 'Runtime.evaluate') {
353
+ return {
354
+ result: { objectId: 'array-1', value: undefined }
355
+ };
356
+ }
357
+ if (method === 'Runtime.getProperties') {
358
+ return { result: [] };
359
+ }
360
+ return {};
361
+ });
362
+
363
+ await executor.execute({ role: 'button', disabled: false });
364
+ const evaluateCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.evaluate');
365
+ assert.ok(evaluateCalls.length > 0);
366
+ });
367
+
368
+ it('should filter by heading level', async () => {
369
+ mockSession.send = mock.fn(async (method) => {
370
+ if (method === 'Runtime.evaluate') {
371
+ return {
372
+ result: { objectId: 'array-1', value: undefined }
373
+ };
374
+ }
375
+ if (method === 'Runtime.getProperties') {
376
+ return { result: [] };
377
+ }
378
+ return {};
379
+ });
380
+
381
+ await executor.execute({ role: 'heading', level: 2 });
382
+ const evaluateCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.evaluate');
383
+ assert.ok(evaluateCalls.length > 0);
384
+ });
385
+ });
386
+
387
+ describe('query with compound roles', () => {
388
+ it('should handle array of roles', async () => {
389
+ mockSession.send = mock.fn(async (method) => {
390
+ if (method === 'Runtime.evaluate') {
391
+ return {
392
+ result: { objectId: 'array-1', value: undefined }
393
+ };
394
+ }
395
+ if (method === 'Runtime.getProperties') {
396
+ return { result: [] };
397
+ }
398
+ return {};
399
+ });
400
+
401
+ await executor.execute({ role: ['button', 'link'] });
402
+ const evaluateCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.evaluate');
403
+ assert.ok(evaluateCalls.length > 0);
404
+ });
405
+ });
406
+
407
+ describe('query with output specification', () => {
408
+ it('should support output: "text"', async () => {
409
+ mockSession.send = mock.fn(async (method) => {
410
+ if (method === 'Runtime.evaluate') {
411
+ return {
412
+ result: { objectId: 'array-1', value: undefined }
413
+ };
414
+ }
415
+ if (method === 'Runtime.getProperties') {
416
+ return {
417
+ result: [
418
+ { name: '0', value: { objectId: 'obj-1' } },
419
+ { name: 'length', value: { value: 1 } }
420
+ ]
421
+ };
422
+ }
423
+ if (method === 'Runtime.callFunctionOn') {
424
+ return {
425
+ result: { value: 'Button text' }
426
+ };
427
+ }
428
+ return {};
429
+ });
430
+
431
+ await executor.execute({ role: 'button', output: 'text' });
432
+ const callFunctionCalls = mockSession.send.mock.calls.filter(call => call.arguments[0] === 'Runtime.callFunctionOn');
433
+ assert.ok(callFunctionCalls.length > 0);
434
+ });
435
+
436
+ it('should support output: array', async () => {
437
+ mockSession.send = mock.fn(async (method) => {
438
+ if (method === 'Runtime.evaluate') {
439
+ return {
440
+ result: { objectId: 'array-1', value: undefined }
441
+ };
442
+ }
443
+ if (method === 'Runtime.getProperties') {
444
+ return {
445
+ result: [
446
+ { name: '0', value: { objectId: 'obj-1' } },
447
+ { name: 'length', value: { value: 1 } }
448
+ ]
449
+ };
450
+ }
451
+ if (method === 'Runtime.callFunctionOn') {
452
+ return {
453
+ result: { value: 'text' }
454
+ };
455
+ }
456
+ return {};
457
+ });
458
+
459
+ await executor.execute({ role: 'button', output: ['text', 'tag'] });
460
+ assert.ok(mockSession.send.mock.calls.length > 0);
461
+ });
462
+
463
+ it('should support output: object', async () => {
464
+ mockSession.send = mock.fn(async (method) => {
465
+ if (method === 'Runtime.evaluate') {
466
+ return {
467
+ result: { objectId: 'array-1', value: undefined }
468
+ };
469
+ }
470
+ if (method === 'Runtime.getProperties') {
471
+ return {
472
+ result: [
473
+ { name: '0', value: { objectId: 'obj-1' } },
474
+ { name: 'length', value: { value: 1 } }
475
+ ]
476
+ };
477
+ }
478
+ if (method === 'Runtime.callFunctionOn') {
479
+ return {
480
+ result: { value: 'value' }
481
+ };
482
+ }
483
+ return {};
484
+ });
485
+
486
+ await executor.execute({ role: 'button', output: { label: 'text', element: 'tag' } });
487
+ assert.ok(mockSession.send.mock.calls.length > 0);
488
+ });
489
+ });
490
+
491
+ describe('query with limit option', () => {
492
+ it('should limit number of results', async () => {
493
+ mockSession.send = mock.fn(async (method) => {
494
+ if (method === 'Runtime.evaluate') {
495
+ return {
496
+ result: { objectId: 'array-1', value: undefined }
497
+ };
498
+ }
499
+ if (method === 'Runtime.getProperties') {
500
+ return {
501
+ result: [
502
+ { name: '0', value: { objectId: 'obj-1' } },
503
+ { name: '1', value: { objectId: 'obj-2' } },
504
+ { name: '2', value: { objectId: 'obj-3' } },
505
+ { name: 'length', value: { value: 3 } }
506
+ ]
507
+ };
508
+ }
509
+ if (method === 'Runtime.callFunctionOn') {
510
+ return {
511
+ result: { value: 'text' }
512
+ };
513
+ }
514
+ return {};
515
+ });
516
+
517
+ const result = await executor.execute({ role: 'button', limit: 2 });
518
+ // Should only process up to limit
519
+ assert.ok(result);
520
+ });
521
+ });
522
+
523
+ describe('error handling', () => {
524
+ it('should handle CDP errors gracefully', async () => {
525
+ mockSession.send = mock.fn(async () => {
526
+ throw new Error('CDP connection failed');
527
+ });
528
+
529
+ await assert.rejects(
530
+ async () => await executor.execute({ role: 'button' }),
531
+ Error
532
+ );
533
+ });
534
+
535
+ it('should handle evaluation exceptions', async () => {
536
+ mockSession.send = mock.fn(async (method) => {
537
+ if (method === 'Runtime.evaluate') {
538
+ return {
539
+ exceptionDetails: {
540
+ text: 'JavaScript error'
541
+ }
542
+ };
543
+ }
544
+ return {};
545
+ });
546
+
547
+ await assert.rejects(
548
+ async () => await executor.execute({ role: 'button' }),
549
+ Error
550
+ );
551
+ });
552
+ });
553
+ });
554
+
555
+ // ============================================================================
556
+ // AriaSnapshot Tests
557
+ // ============================================================================
558
+
559
+ describe('createAriaSnapshot', () => {
560
+ let snapshot;
561
+
562
+ beforeEach(() => {
563
+ snapshot = createAriaSnapshot(mockSession);
564
+ });
565
+
566
+ it('should create snapshot with generate method', () => {
567
+ assert.ok(typeof snapshot.generate === 'function');
568
+ });
569
+
570
+ it('should create snapshot with getElementByRef method', () => {
571
+ assert.ok(typeof snapshot.getElementByRef === 'function');
572
+ });
573
+
574
+ describe('generate snapshot', () => {
575
+ it('should generate basic snapshot', async () => {
576
+ mockSession.send = mock.fn(async () => ({
577
+ result: {
578
+ value: {
579
+ tree: { role: 'document', children: [] },
580
+ yaml: 'document:\n',
581
+ refs: new Map(),
582
+ snapshotId: 's1'
583
+ }
584
+ }
585
+ }));
586
+
587
+ const result = await snapshot.generate();
588
+ assert.ok(result.tree);
589
+ assert.ok(result.snapshotId);
590
+ });
591
+
592
+ it('should support mode: "ai"', async () => {
593
+ mockSession.send = mock.fn(async (method, params) => ({
594
+ result: {
595
+ value: {
596
+ tree: { role: 'document', children: [] },
597
+ yaml: 'document:\n',
598
+ refs: new Map(),
599
+ snapshotId: 's1'
600
+ }
601
+ }
602
+ }));
603
+
604
+ await snapshot.generate({ mode: 'ai' });
605
+ const calls = mockSession.send.mock.calls;
606
+ assert.ok(calls.length > 0);
607
+ // Check expression includes mode parameter
608
+ const expression = calls[0].arguments[1].expression;
609
+ assert.ok(expression.includes('ai'));
610
+ });
611
+
612
+ it('should support mode: "full"', async () => {
613
+ mockSession.send = mock.fn(async () => ({
614
+ result: {
615
+ value: {
616
+ tree: { role: 'document', children: [] },
617
+ yaml: 'document:\n',
618
+ refs: new Map(),
619
+ snapshotId: 's1'
620
+ }
621
+ }
622
+ }));
623
+
624
+ await snapshot.generate({ mode: 'full' });
625
+ const calls = mockSession.send.mock.calls;
626
+ assert.ok(calls.length > 0);
627
+ });
628
+
629
+ it('should support detail: "summary"', async () => {
630
+ mockSession.send = mock.fn(async () => ({
631
+ result: {
632
+ value: {
633
+ tree: { role: 'document', children: [
634
+ { role: 'main', name: 'Main content', children: [] },
635
+ { role: 'button', name: 'Submit', children: [] }
636
+ ]},
637
+ yaml: 'document:\n',
638
+ refs: new Map(),
639
+ snapshotId: 's1'
640
+ }
641
+ }
642
+ }));
643
+
644
+ const result = await snapshot.generate({ detail: 'summary' });
645
+ assert.ok(result.snapshotId);
646
+ // Summary view should have landmarks or stats
647
+ assert.ok(result.landmarks || result.stats || result.tree);
648
+ });
649
+
650
+ it('should support detail: "interactive"', async () => {
651
+ mockSession.send = mock.fn(async () => ({
652
+ result: {
653
+ value: {
654
+ tree: { role: 'document', children: [
655
+ { role: 'button', name: 'Click me', children: [] },
656
+ { role: 'textbox', name: 'Username', children: [] }
657
+ ]},
658
+ yaml: 'document:\n',
659
+ refs: new Map(),
660
+ snapshotId: 's1'
661
+ }
662
+ }
663
+ }));
664
+
665
+ const result = await snapshot.generate({ detail: 'interactive' });
666
+ assert.ok(result.snapshotId);
667
+ });
668
+
669
+ it('should support detail: "full"', async () => {
670
+ mockSession.send = mock.fn(async () => ({
671
+ result: {
672
+ value: {
673
+ tree: { role: 'document', children: [] },
674
+ yaml: 'document:\n',
675
+ refs: new Map(),
676
+ snapshotId: 's1'
677
+ }
678
+ }
679
+ }));
680
+
681
+ const result = await snapshot.generate({ detail: 'full' });
682
+ assert.ok(result.tree);
683
+ assert.ok(result.snapshotId);
684
+ });
685
+
686
+ it('should support maxDepth option', async () => {
687
+ mockSession.send = mock.fn(async (method, params) => ({
688
+ result: {
689
+ value: {
690
+ tree: { role: 'document', children: [] },
691
+ yaml: 'document:\n',
692
+ refs: new Map(),
693
+ snapshotId: 's1'
694
+ }
695
+ }
696
+ }));
697
+
698
+ await snapshot.generate({ maxDepth: 3 });
699
+ const calls = mockSession.send.mock.calls;
700
+ const expression = calls[0].arguments[1].expression;
701
+ assert.ok(expression.includes('maxDepth'));
702
+ });
703
+
704
+ it('should support maxElements option', async () => {
705
+ mockSession.send = mock.fn(async () => ({
706
+ result: {
707
+ value: {
708
+ tree: { role: 'document', children: [] },
709
+ yaml: 'document:\n',
710
+ refs: new Map(),
711
+ snapshotId: 's1'
712
+ }
713
+ }
714
+ }));
715
+
716
+ await snapshot.generate({ maxElements: 100 });
717
+ const calls = mockSession.send.mock.calls;
718
+ assert.ok(calls.length > 0);
719
+ });
720
+
721
+ it('should support viewportOnly option', async () => {
722
+ mockSession.send = mock.fn(async () => ({
723
+ result: {
724
+ value: {
725
+ tree: { role: 'document', children: [] },
726
+ yaml: 'document:\n',
727
+ refs: new Map(),
728
+ snapshotId: 's1'
729
+ }
730
+ }
731
+ }));
732
+
733
+ await snapshot.generate({ viewportOnly: true });
734
+ const calls = mockSession.send.mock.calls;
735
+ const expression = calls[0].arguments[1].expression;
736
+ assert.ok(expression.includes('viewportOnly'));
737
+ });
738
+
739
+ it('should support pierceShadow option', async () => {
740
+ mockSession.send = mock.fn(async () => ({
741
+ result: {
742
+ value: {
743
+ tree: { role: 'document', children: [] },
744
+ yaml: 'document:\n',
745
+ refs: new Map(),
746
+ snapshotId: 's1'
747
+ }
748
+ }
749
+ }));
750
+
751
+ await snapshot.generate({ pierceShadow: true });
752
+ const calls = mockSession.send.mock.calls;
753
+ const expression = calls[0].arguments[1].expression;
754
+ assert.ok(expression.includes('pierceShadow'));
755
+ });
756
+
757
+ it('should support root selector option', async () => {
758
+ mockSession.send = mock.fn(async () => ({
759
+ result: {
760
+ value: {
761
+ tree: { role: 'main', children: [] },
762
+ yaml: 'main:\n',
763
+ refs: new Map(),
764
+ snapshotId: 's1'
765
+ }
766
+ }
767
+ }));
768
+
769
+ await snapshot.generate({ root: 'main' });
770
+ const calls = mockSession.send.mock.calls;
771
+ const expression = calls[0].arguments[1].expression;
772
+ assert.ok(expression.includes('main'));
773
+ });
774
+
775
+ it('should support since option for change detection', async () => {
776
+ mockSession.send = mock.fn(async () => ({
777
+ result: {
778
+ value: {
779
+ unchanged: true,
780
+ snapshotId: 's2',
781
+ message: 'Page unchanged since s1'
782
+ }
783
+ }
784
+ }));
785
+
786
+ const result = await snapshot.generate({ since: 's1' });
787
+ assert.strictEqual(result.unchanged, true);
788
+ assert.ok(result.message);
789
+ });
790
+
791
+ it('should handle snapshot generation errors', async () => {
792
+ mockSession.send = mock.fn(async () => ({
793
+ exceptionDetails: {
794
+ text: 'Failed to generate snapshot'
795
+ }
796
+ }));
797
+
798
+ await assert.rejects(
799
+ async () => await snapshot.generate(),
800
+ /Snapshot generation failed/
801
+ );
802
+ });
803
+ });
804
+
805
+ describe('getElementByRef', () => {
806
+ it('should retrieve element info by ref', async () => {
807
+ mockSession.send = mock.fn(async (method) => {
808
+ if (method === 'Runtime.evaluate') {
809
+ return {
810
+ result: {
811
+ value: {
812
+ selector: '#submit-btn',
813
+ box: { x: 10, y: 20, width: 100, height: 30 },
814
+ isConnected: true,
815
+ isVisible: true
816
+ }
817
+ }
818
+ };
819
+ }
820
+ return {};
821
+ });
822
+
823
+ const refInfo = await snapshot.getElementByRef('s1e1');
824
+
825
+ // Should return ref info with box, isConnected, etc
826
+ assert.ok(refInfo);
827
+ assert.ok(refInfo.box);
828
+ assert.ok(typeof refInfo.isConnected === 'boolean');
829
+ });
830
+
831
+ it('should return null for non-existent ref', async () => {
832
+ mockSession.send = mock.fn(async () => ({
833
+ result: { value: null }
834
+ }));
835
+
836
+ const refInfo = await snapshot.getElementByRef('s99e99');
837
+
838
+ assert.strictEqual(refInfo, null);
839
+ });
840
+
841
+ it('should detect stale refs', async () => {
842
+ mockSession.send = mock.fn(async () => ({
843
+ result: {
844
+ value: {
845
+ stale: true,
846
+ message: 'Element no longer attached to DOM'
847
+ }
848
+ }
849
+ }));
850
+
851
+ const refInfo = await snapshot.getElementByRef('s1e1');
852
+
853
+ // Stale refs return object with stale flag
854
+ if (refInfo) {
855
+ assert.ok(refInfo.stale === true || typeof refInfo === 'object');
856
+ }
857
+ });
858
+ });
859
+
860
+ describe('ref management', () => {
861
+ it('should generate unique snapshot IDs', async () => {
862
+ mockSession.send = mock.fn(async () => ({
863
+ result: {
864
+ value: {
865
+ tree: { role: 'document', children: [] },
866
+ yaml: 'document:\n',
867
+ refs: new Map(),
868
+ snapshotId: 's1'
869
+ }
870
+ }
871
+ }));
872
+
873
+ const result1 = await snapshot.generate();
874
+
875
+ mockSession.send = mock.fn(async () => ({
876
+ result: {
877
+ value: {
878
+ tree: { role: 'document', children: [] },
879
+ yaml: 'document:\n',
880
+ refs: new Map(),
881
+ snapshotId: 's2'
882
+ }
883
+ }
884
+ }));
885
+
886
+ const result2 = await snapshot.generate();
887
+
888
+ // IDs should be tracked (may or may not be different depending on implementation)
889
+ assert.ok(result1.snapshotId);
890
+ assert.ok(result2.snapshotId);
891
+ });
892
+
893
+ it('should support preserveRefs option', async () => {
894
+ mockSession.send = mock.fn(async () => ({
895
+ result: {
896
+ value: {
897
+ tree: { role: 'document', children: [] },
898
+ yaml: 'document:\n',
899
+ refs: new Map(),
900
+ snapshotId: 's1'
901
+ }
902
+ }
903
+ }));
904
+
905
+ await snapshot.generate({ preserveRefs: true });
906
+ const calls = mockSession.send.mock.calls;
907
+ const expression = calls[0].arguments[1].expression;
908
+ assert.ok(expression.includes('preserveRefs'));
909
+ });
910
+ });
911
+
912
+ describe('frame context support', () => {
913
+ it('should inject contextId when getFrameContext is provided', async () => {
914
+ const mockGetFrameContext = () => 'frame-123';
915
+ const frameSnapshot = createAriaSnapshot(mockSession, { getFrameContext: mockGetFrameContext });
916
+
917
+ mockSession.send = mock.fn(async (method, params) => {
918
+ // Verify contextId is included
919
+ if (method === 'Runtime.evaluate') {
920
+ assert.strictEqual(params.contextId, 'frame-123');
921
+ }
922
+ return {
923
+ result: {
924
+ value: {
925
+ tree: { role: 'document', children: [] },
926
+ yaml: 'document:\n',
927
+ refs: new Map(),
928
+ snapshotId: 's1'
929
+ }
930
+ }
931
+ };
932
+ });
933
+
934
+ await frameSnapshot.generate();
935
+ assert.ok(mockSession.send.mock.calls.length > 0);
936
+ });
937
+
938
+ it('should work without frame context', async () => {
939
+ mockSession.send = mock.fn(async (method, params) => {
940
+ // Verify contextId is NOT included when no getFrameContext
941
+ if (method === 'Runtime.evaluate') {
942
+ assert.strictEqual(params.contextId, undefined);
943
+ }
944
+ return {
945
+ result: {
946
+ value: {
947
+ tree: { role: 'document', children: [] },
948
+ yaml: 'document:\n',
949
+ refs: new Map(),
950
+ snapshotId: 's1'
951
+ }
952
+ }
953
+ };
954
+ });
955
+
956
+ await snapshot.generate();
957
+ assert.ok(mockSession.send.mock.calls.length > 0);
958
+ });
959
+ });
960
+ });
961
+
962
+ // ============================================================================
963
+ // Integration Tests
964
+ // ============================================================================
965
+
966
+ describe('Integration', () => {
967
+ it('should create all three factory functions', () => {
968
+ const processor = createQueryOutputProcessor(mockSession);
969
+ const executor = createRoleQueryExecutor(mockSession, {});
970
+ const snapshot = createAriaSnapshot(mockSession);
971
+
972
+ assert.ok(processor);
973
+ assert.ok(executor);
974
+ assert.ok(snapshot);
975
+ });
976
+
977
+ it('should handle role queries with snapshot refs', async () => {
978
+ const snapshot = createAriaSnapshot(mockSession);
979
+
980
+ let callCount = 0;
981
+ mockSession.send = mock.fn(async (method) => {
982
+ callCount++;
983
+ if (callCount === 1) {
984
+ // First call: generate snapshot
985
+ return {
986
+ result: {
987
+ value: {
988
+ tree: {
989
+ role: 'document',
990
+ children: [
991
+ { role: 'button', name: 'Click me', ref: 's1e1' }
992
+ ]
993
+ },
994
+ yaml: 'document:\n button: Click me [s1e1]\n',
995
+ refs: new Map([
996
+ ['s1e1', { ref: 's1e1', role: 'button', name: 'Click me' }]
997
+ ]),
998
+ snapshotId: 's1'
999
+ }
1000
+ }
1001
+ };
1002
+ } else {
1003
+ // Second call: getElementByRef
1004
+ return {
1005
+ result: {
1006
+ value: {
1007
+ selector: '#btn',
1008
+ box: { x: 0, y: 0, width: 50, height: 30 },
1009
+ isConnected: true,
1010
+ isVisible: true
1011
+ }
1012
+ }
1013
+ };
1014
+ }
1015
+ });
1016
+
1017
+ const result = await snapshot.generate();
1018
+ assert.ok(result.snapshotId);
1019
+
1020
+ // Refs should be accessible via getElementByRef
1021
+ const refInfo = await snapshot.getElementByRef('s1e1');
1022
+ assert.ok(refInfo === null || typeof refInfo === 'object');
1023
+ });
1024
+ });
1025
+ });