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,459 @@
1
+ export function createAriaSnapshot(session, options = {}) {
2
+ const getFrameContext = options.getFrameContext || null;
3
+ /**
4
+ * Generate accessibility snapshot of the page
5
+ * @param {Object} options - Snapshot options
6
+ * @param {string} options.root - CSS selector or role selector (e.g., "role=main") for root element
7
+ * @param {string} options.mode - 'ai' for agent-friendly output, 'full' for complete tree
8
+ * @param {string} options.detail - Detail level: 'summary', 'interactive', or 'full' (default: 'full')
9
+ * @param {number} options.maxDepth - Maximum tree depth (default: 50)
10
+ * @param {number} options.maxElements - Maximum elements to include (default: unlimited)
11
+ * @param {boolean} options.includeText - Include static text nodes in output (default: false for ai mode)
12
+ * @param {boolean} options.includeFrames - Include same-origin iframe content (default: false)
13
+ * @param {boolean} options.viewportOnly - Only include elements visible in viewport (default: false)
14
+ * @param {boolean} options.pierceShadow - Traverse into open shadow DOM trees (default: false)
15
+ * @param {boolean} options.preserveRefs - Merge new refs into existing instead of overwriting (default: false)
16
+ * @param {string} options.since - Snapshot ID to check against (e.g., "s1") - returns {unchanged: true} if page hasn't changed
17
+ * @returns {Promise<Object>} Snapshot result with tree, yaml, refs, and snapshotId
18
+ */
19
+ async function generate(options = {}) {
20
+ const { root = null, mode = 'ai', detail = 'full', maxDepth = 50, maxElements = 0, includeText = false, includeFrames = false, viewportOnly = false, pierceShadow = false, preserveRefs = false, since = null, internal = false } = options;
21
+
22
+ const evalArgs = {
23
+ expression: `(${SNAPSHOT_SCRIPT})(${JSON.stringify(root)}, ${JSON.stringify({ mode, detail, maxDepth, maxElements, includeText, includeFrames, viewportOnly, pierceShadow, preserveRefs, since, internal })})`,
24
+ returnByValue: true,
25
+ awaitPromise: false
26
+ };
27
+ if (getFrameContext) {
28
+ const contextId = getFrameContext();
29
+ if (contextId) evalArgs.contextId = contextId;
30
+ }
31
+ const result = await session.send('Runtime.evaluate', evalArgs);
32
+
33
+ if (result.exceptionDetails) {
34
+ throw new Error(`Snapshot generation failed: ${result.exceptionDetails.text}`);
35
+ }
36
+
37
+ const snapshotResult = result.result.value;
38
+
39
+ // If page unchanged (HTTP 304-like response), return early
40
+ if (snapshotResult.unchanged) {
41
+ return {
42
+ unchanged: true,
43
+ snapshotId: snapshotResult.snapshotId,
44
+ message: `Page unchanged since ${since}`
45
+ };
46
+ }
47
+
48
+ // Handle detail levels post-processing
49
+ if (detail === 'summary') {
50
+ const summaryResult = generateSummaryView(snapshotResult);
51
+ summaryResult.snapshotId = snapshotResult.snapshotId;
52
+ return summaryResult;
53
+ } else if (detail === 'interactive') {
54
+ const interactiveResult = generateInteractiveView(snapshotResult);
55
+ interactiveResult.snapshotId = snapshotResult.snapshotId;
56
+ return interactiveResult;
57
+ }
58
+
59
+ return snapshotResult;
60
+ }
61
+
62
+ /**
63
+ * Generate a summary view of the snapshot
64
+ * Shows landmarks and interactive element counts
65
+ */
66
+ function generateSummaryView(snapshot) {
67
+ if (!snapshot || !snapshot.tree) {
68
+ return { ...snapshot, detail: 'summary' };
69
+ }
70
+
71
+ const landmarks = [];
72
+ let totalElements = 0;
73
+ let interactiveElements = 0;
74
+ let viewportElements = 0;
75
+
76
+ const LANDMARK_ROLES = ['main', 'navigation', 'banner', 'contentinfo', 'complementary', 'search', 'form', 'region'];
77
+ const INTERACTIVE_ROLES = ['button', 'checkbox', 'combobox', 'link', 'listbox', 'menuitem', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem'];
78
+
79
+ function walkTree(node, depth = 0) {
80
+ if (!node) return;
81
+ totalElements++;
82
+
83
+ const role = node.role || '';
84
+ const isInteractive = INTERACTIVE_ROLES.includes(role);
85
+ const isLandmark = LANDMARK_ROLES.includes(role);
86
+
87
+ if (isInteractive) {
88
+ interactiveElements++;
89
+ }
90
+
91
+ // Count all semantic (non-generic, non-staticText) nodes as viewport elements
92
+ // since they passed isVisible() checks during tree construction
93
+ if (role && role !== 'generic' && role !== 'staticText') {
94
+ viewportElements++;
95
+ }
96
+
97
+ if (isLandmark) {
98
+ const landmark = {
99
+ role,
100
+ name: node.name || null,
101
+ interactiveCount: countInteractive(node),
102
+ children: getChildRoles(node)
103
+ };
104
+ landmarks.push(landmark);
105
+ }
106
+
107
+ if (node.children && Array.isArray(node.children)) {
108
+ for (const child of node.children) {
109
+ walkTree(child, depth + 1);
110
+ }
111
+ }
112
+ }
113
+
114
+ function countInteractive(node) {
115
+ let count = 0;
116
+ function walk(n) {
117
+ if (!n) return;
118
+ if (INTERACTIVE_ROLES.includes(n.role)) count++;
119
+ if (n.children) n.children.forEach(walk);
120
+ }
121
+ if (node.children) node.children.forEach(walk);
122
+ return count;
123
+ }
124
+
125
+ function getChildRoles(node) {
126
+ const roles = [];
127
+ if (node.children) {
128
+ for (const child of node.children) {
129
+ if (child.role && !['staticText', 'generic'].includes(child.role)) {
130
+ roles.push(child.role);
131
+ }
132
+ }
133
+ }
134
+ return roles.slice(0, 5); // Limit to 5
135
+ }
136
+
137
+ walkTree(snapshot.tree);
138
+
139
+ // Generate summary YAML
140
+ const yamlLines = [];
141
+ yamlLines.push('# Snapshot Summary');
142
+ yamlLines.push(`# Total elements: ${totalElements}`);
143
+ yamlLines.push(`# Interactive elements: ${interactiveElements}`);
144
+ yamlLines.push(`# Viewport elements: ${viewportElements}`);
145
+ yamlLines.push('');
146
+ yamlLines.push('landmarks:');
147
+ for (const lm of landmarks) {
148
+ yamlLines.push(` - role: ${lm.role}`);
149
+ if (lm.name) yamlLines.push(` name: "${lm.name}"`);
150
+ yamlLines.push(` interactiveCount: ${lm.interactiveCount}`);
151
+ if (lm.children.length > 0) {
152
+ yamlLines.push(` children: [${lm.children.join(', ')}]`);
153
+ }
154
+ }
155
+
156
+ return {
157
+ yaml: yamlLines.join('\n'),
158
+ refs: snapshot.refs,
159
+ detail: 'summary',
160
+ stats: {
161
+ totalElements,
162
+ interactiveElements,
163
+ viewportElements,
164
+ landmarkCount: landmarks.length
165
+ },
166
+ landmarks
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Generate an interactive-only view of the snapshot
172
+ * Shows only actionable elements with their paths
173
+ */
174
+ function generateInteractiveView(snapshot) {
175
+ if (!snapshot || !snapshot.tree) {
176
+ return { ...snapshot, detail: 'interactive' };
177
+ }
178
+
179
+ const INTERACTIVE_ROLES = ['button', 'checkbox', 'combobox', 'link', 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem'];
180
+ const elements = [];
181
+
182
+ function walkTree(node, path = []) {
183
+ if (!node) return;
184
+
185
+ const role = node.role || '';
186
+ const isInteractive = INTERACTIVE_ROLES.includes(role);
187
+
188
+ if (isInteractive) {
189
+ const el = {
190
+ role,
191
+ name: node.name || '',
192
+ ref: node.ref || null,
193
+ path: path.join(' > ')
194
+ };
195
+ if (node.checked !== undefined) el.checked = node.checked;
196
+ if (node.disabled) el.disabled = true;
197
+ if (node.expanded !== undefined) el.expanded = node.expanded;
198
+ if (node.value) el.value = node.value;
199
+ elements.push(el);
200
+ }
201
+
202
+ if (node.children && Array.isArray(node.children)) {
203
+ const newPath = role && !['staticText', 'generic'].includes(role) ? [...path, role] : path;
204
+ for (const child of node.children) {
205
+ walkTree(child, newPath);
206
+ }
207
+ }
208
+ }
209
+
210
+ walkTree(snapshot.tree);
211
+
212
+ // Generate compact YAML for interactive elements
213
+ const yamlLines = elements.map(el => {
214
+ let line = `- ${el.role} "${el.name}"`;
215
+ if (el.ref) line += ` [ref=${el.ref}]`;
216
+ if (el.checked !== undefined) line += el.checked ? ' [checked]' : '';
217
+ if (el.disabled) line += ' [disabled]';
218
+ if (el.expanded !== undefined) line += el.expanded ? ' [expanded]' : ' [collapsed]';
219
+ line += `: path=${el.path}`;
220
+ return line;
221
+ });
222
+
223
+ return {
224
+ yaml: yamlLines.join('\n'),
225
+ refs: snapshot.refs,
226
+ detail: 'interactive',
227
+ stats: {
228
+ interactiveCount: elements.length
229
+ },
230
+ elements
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Get element by ref with automatic re-resolution fallback.
236
+ * When the original element is stale (removed from DOM), attempts to find
237
+ * a replacement element using stored metadata (CSS selector + role/name verification).
238
+ * @param {string} ref - Element reference (e.g., 's1e1')
239
+ * @returns {Promise<Object>} Element info with selector, box, and connection status
240
+ */
241
+ async function getElementByRef(ref) {
242
+ const evalArgs = {
243
+ expression: `(function() {
244
+ const ref = ${JSON.stringify(ref)};
245
+ const refsMap = window.__ariaRefs;
246
+ const metaMap = window.__ariaRefMeta;
247
+ let el = refsMap && refsMap.get(ref);
248
+
249
+ // Helper to build result from a live element
250
+ function buildResult(element, reResolved) {
251
+ const style = window.getComputedStyle(element);
252
+ const isVisible = style.display !== 'none' &&
253
+ style.visibility !== 'hidden' &&
254
+ style.opacity !== '0';
255
+ const rect = element.getBoundingClientRect();
256
+ const info = {
257
+ selector: element.id ? '#' + element.id : null,
258
+ box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
259
+ isConnected: true,
260
+ isVisible: isVisible && rect.width > 0 && rect.height > 0
261
+ };
262
+ if (reResolved) info.reResolved = true;
263
+ return info;
264
+ }
265
+
266
+ // Helper to compute accessible name for verification
267
+ function getAccessibleName(element) {
268
+ return (
269
+ element.getAttribute('aria-label') ||
270
+ element.getAttribute('title') ||
271
+ element.getAttribute('placeholder') ||
272
+ (element.textContent ? element.textContent.replace(/\\s+/g, ' ').trim().substring(0, 200) : '') ||
273
+ ''
274
+ );
275
+ }
276
+
277
+ // Helper to get ARIA role
278
+ function getRole(element) {
279
+ const explicit = element.getAttribute('role');
280
+ if (explicit) return explicit.split(/\\s+/)[0];
281
+ const tag = element.tagName.toUpperCase();
282
+ if (tag === 'INPUT') {
283
+ const type = (element.type || 'text').toLowerCase();
284
+ const inputTypeMap = {
285
+ 'checkbox': 'checkbox', 'radio': 'radio',
286
+ 'range': 'slider', 'number': 'spinbutton',
287
+ 'search': 'searchbox'
288
+ };
289
+ return inputTypeMap[type] || 'textbox';
290
+ }
291
+ const implicitMap = {
292
+ 'A': 'link', 'BUTTON': 'button',
293
+ 'SELECT': 'combobox', 'TEXTAREA': 'textbox',
294
+ 'H1': 'heading', 'H2': 'heading', 'H3': 'heading',
295
+ 'H4': 'heading', 'H5': 'heading', 'H6': 'heading',
296
+ 'NAV': 'navigation', 'MAIN': 'main', 'LI': 'listitem',
297
+ 'OPTION': 'option', 'IMG': 'img', 'DIALOG': 'dialog'
298
+ };
299
+ return implicitMap[tag] || null;
300
+ }
301
+
302
+ // 1. Element exists and is connected - return as-is (fast path)
303
+ if (el && el.isConnected) {
304
+ return buildResult(el, false);
305
+ }
306
+
307
+ // Helper to check if candidate matches role+name
308
+ function matchesRoleAndName(candidate, meta) {
309
+ if (!candidate || !candidate.isConnected) return false;
310
+ const candidateRole = getRole(candidate);
311
+ const roleMatch = !meta.role || candidateRole === meta.role;
312
+ if (!roleMatch) return false;
313
+ if (!meta.name) return true;
314
+ const candidateName = getAccessibleName(candidate);
315
+ return candidateName.toLowerCase().includes(meta.name.toLowerCase().substring(0, 100));
316
+ }
317
+
318
+ // Helper to resolve a CSS selector through a chain of shadow hosts
319
+ function queryShadow(shadowHostPath, selector) {
320
+ let root = document;
321
+ for (const hostSel of shadowHostPath) {
322
+ try {
323
+ const host = root.querySelector(hostSel);
324
+ if (!host || !host.shadowRoot) return null;
325
+ root = host.shadowRoot;
326
+ } catch (e) { return null; }
327
+ }
328
+ try { return root.querySelector(selector); } catch (e) { return null; }
329
+ }
330
+
331
+ // Helper to querySelectorAll through shadow hosts
332
+ function queryShadowAll(shadowHostPath, selector) {
333
+ let root = document;
334
+ for (const hostSel of shadowHostPath) {
335
+ try {
336
+ const host = root.querySelector(hostSel);
337
+ if (!host || !host.shadowRoot) return [];
338
+ root = host.shadowRoot;
339
+ } catch (e) { return []; }
340
+ }
341
+ try { return Array.from(root.querySelectorAll(selector)); } catch (e) { return []; }
342
+ }
343
+
344
+ // Helper to collect all shadow roots in the document for broad search
345
+ function collectShadowRoots(node, roots) {
346
+ if (node.shadowRoot) {
347
+ roots.push(node.shadowRoot);
348
+ collectShadowRoots(node.shadowRoot, roots);
349
+ }
350
+ const children = node.children || node.childNodes || [];
351
+ for (const child of children) {
352
+ if (child.nodeType === 1) collectShadowRoots(child, roots);
353
+ }
354
+ return roots;
355
+ }
356
+
357
+ // 2. Element is null or stale - attempt re-resolution via metadata
358
+ if (metaMap) {
359
+ const meta = metaMap.get(ref);
360
+ if (meta) {
361
+ const hasShadowPath = meta.shadowHostPath && meta.shadowHostPath.length > 0;
362
+
363
+ // 2a. Try stored CSS selector first (fastest)
364
+ if (meta.selector) {
365
+ try {
366
+ const candidate = hasShadowPath
367
+ ? queryShadow(meta.shadowHostPath, meta.selector)
368
+ : document.querySelector(meta.selector);
369
+ if (matchesRoleAndName(candidate, meta)) {
370
+ if (refsMap) refsMap.set(ref, candidate);
371
+ return buildResult(candidate, true);
372
+ }
373
+ } catch (e) {
374
+ // querySelector can throw on invalid selectors - fall through
375
+ }
376
+ }
377
+
378
+ // 2b. Broader search: find by role + name
379
+ if (meta.role) {
380
+ const roleSelectors = {
381
+ 'link': 'a[href]',
382
+ 'button': 'button,[role="button"]',
383
+ 'heading': 'h1,h2,h3,h4,h5,h6,[role="heading"]',
384
+ 'textbox': 'input:not([type]),input[type="text"],input[type="email"],input[type="url"],input[type="search"],input[type="tel"],textarea,[role="textbox"]',
385
+ 'checkbox': 'input[type="checkbox"],[role="checkbox"]',
386
+ 'radio': 'input[type="radio"],[role="radio"]',
387
+ 'combobox': 'select,[role="combobox"],[role="listbox"]',
388
+ 'img': 'img,[role="img"]',
389
+ 'listitem': 'li,[role="listitem"]',
390
+ 'tab': '[role="tab"]',
391
+ 'menuitem': '[role="menuitem"]'
392
+ };
393
+ const sel = roleSelectors[meta.role] || '[role="' + meta.role + '"]';
394
+
395
+ // Search in known shadow path first, then light DOM
396
+ if (hasShadowPath) {
397
+ try {
398
+ const candidates = queryShadowAll(meta.shadowHostPath, sel);
399
+ for (const candidate of candidates) {
400
+ if (matchesRoleAndName(candidate, meta)) {
401
+ if (refsMap) refsMap.set(ref, candidate);
402
+ return buildResult(candidate, true);
403
+ }
404
+ }
405
+ } catch (e) {}
406
+ }
407
+
408
+ // Light DOM search
409
+ try {
410
+ const candidates = document.querySelectorAll(sel);
411
+ for (const candidate of candidates) {
412
+ if (matchesRoleAndName(candidate, meta)) {
413
+ if (refsMap) refsMap.set(ref, candidate);
414
+ return buildResult(candidate, true);
415
+ }
416
+ }
417
+ } catch (e) {}
418
+
419
+ // 2c. Last resort: search ALL shadow roots in the document
420
+ if (!hasShadowPath) {
421
+ try {
422
+ const shadowRoots = collectShadowRoots(document.body, []);
423
+ for (const sr of shadowRoots) {
424
+ const candidates = sr.querySelectorAll(sel);
425
+ for (const candidate of candidates) {
426
+ if (matchesRoleAndName(candidate, meta)) {
427
+ if (refsMap) refsMap.set(ref, candidate);
428
+ return buildResult(candidate, true);
429
+ }
430
+ }
431
+ }
432
+ } catch (e) {}
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ // 3. All fallbacks failed
439
+ if (el && !el.isConnected) {
440
+ return { stale: true, ref: ref };
441
+ }
442
+ return null;
443
+ })()`,
444
+ returnByValue: true
445
+ };
446
+ if (getFrameContext) {
447
+ const contextId = getFrameContext();
448
+ if (contextId) evalArgs.contextId = contextId;
449
+ }
450
+ const result = await session.send('Runtime.evaluate', evalArgs);
451
+
452
+ return result.result.value;
453
+ }
454
+
455
+ return {
456
+ generate,
457
+ getElementByRef
458
+ };
459
+ }