cdp-skill 1.0.16 → 1.0.17

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 (103) hide show
  1. package/README.md +4 -4
  2. package/SKILL.md +276 -170
  3. package/package.json +9 -8
  4. package/{src → scripts}/aria/index.js +1 -1
  5. package/scripts/aria/role-query.js +295 -0
  6. package/{src → scripts}/aria.js +11 -5
  7. package/{src → scripts}/capture/console-capture.js +11 -9
  8. package/{src → scripts}/capture/screenshot-capture.js +8 -9
  9. package/{src → scripts}/cdp/connection.js +30 -6
  10. package/{src → scripts}/cdp-skill.js +7 -6
  11. package/{src → scripts}/diff.js +7 -6
  12. package/{src → scripts}/dom/LazyResolver.js +23 -12
  13. package/{src → scripts}/dom/actionability.js +39 -22
  14. package/{src → scripts}/dom/click-executor.js +90 -53
  15. package/{src → scripts}/dom/element-locator.js +4 -4
  16. package/{src → scripts}/dom/fill-executor.js +8 -4
  17. package/{src → scripts}/dom/input-emulator.js +47 -9
  18. package/{src → scripts}/dom/react-filler.js +11 -3
  19. package/{src → scripts}/dom/wait-executor.js +10 -2
  20. package/{src → scripts}/page/dialog-handler.js +7 -3
  21. package/{src → scripts}/page/dom-stability.js +17 -10
  22. package/{src → scripts}/page/page-controller.js +41 -34
  23. package/{src → scripts}/runner/context-helpers.js +7 -0
  24. package/{src → scripts}/runner/execute-browser.js +3 -118
  25. package/{src → scripts}/runner/execute-dynamic.js +46 -11
  26. package/{src → scripts}/runner/execute-form.js +6 -4
  27. package/{src → scripts}/runner/execute-input.js +127 -100
  28. package/{src → scripts}/runner/execute-interaction.js +31 -46
  29. package/{src → scripts}/runner/execute-navigation.js +14 -12
  30. package/{src → scripts}/runner/step-executors.js +28 -9
  31. package/{src → scripts}/runner/step-registry.js +57 -8
  32. package/{src → scripts}/runner/step-validator.js +13 -3
  33. package/{src → scripts}/tests/ExecuteInput.test.js +58 -188
  34. package/src/aria/role-query.js +0 -1229
  35. package/src/aria/snapshot.js +0 -459
  36. /package/{src → scripts}/aria/output-processor.js +0 -0
  37. /package/{src → scripts}/capture/debug-capture.js +0 -0
  38. /package/{src → scripts}/capture/error-aggregator.js +0 -0
  39. /package/{src → scripts}/capture/eval-serializer.js +0 -0
  40. /package/{src → scripts}/capture/index.js +0 -0
  41. /package/{src → scripts}/capture/network-capture.js +0 -0
  42. /package/{src → scripts}/capture/pdf-capture.js +0 -0
  43. /package/{src → scripts}/cdp/browser.js +0 -0
  44. /package/{src → scripts}/cdp/discovery.js +0 -0
  45. /package/{src → scripts}/cdp/index.js +0 -0
  46. /package/{src → scripts}/cdp/target-and-session.js +0 -0
  47. /package/{src → scripts}/constants.js +0 -0
  48. /package/{src → scripts}/dom/element-handle.js +0 -0
  49. /package/{src → scripts}/dom/element-validator.js +0 -0
  50. /package/{src → scripts}/dom/index.js +0 -0
  51. /package/{src → scripts}/dom/keyboard-executor.js +0 -0
  52. /package/{src → scripts}/dom/quad-helpers.js +0 -0
  53. /package/{src → scripts}/index.js +0 -0
  54. /package/{src → scripts}/page/cookie-manager.js +0 -0
  55. /package/{src → scripts}/page/index.js +0 -0
  56. /package/{src → scripts}/page/wait-utilities.js +0 -0
  57. /package/{src → scripts}/page/web-storage-manager.js +0 -0
  58. /package/{src → scripts}/runner/execute-query.js +0 -0
  59. /package/{src → scripts}/runner/index.js +0 -0
  60. /package/{src → scripts}/tests/Actionability.test.js +0 -0
  61. /package/{src → scripts}/tests/Aria.test.js +0 -0
  62. /package/{src → scripts}/tests/BrowserClient.test.js +0 -0
  63. /package/{src → scripts}/tests/CDPConnection.test.js +0 -0
  64. /package/{src → scripts}/tests/ChromeDiscovery.test.js +0 -0
  65. /package/{src → scripts}/tests/ClickExecutor.test.js +0 -0
  66. /package/{src → scripts}/tests/ConsoleCapture.test.js +0 -0
  67. /package/{src → scripts}/tests/ContextHelpers.test.js +0 -0
  68. /package/{src → scripts}/tests/CookieManager.test.js +0 -0
  69. /package/{src → scripts}/tests/DebugCapture.test.js +0 -0
  70. /package/{src → scripts}/tests/ElementHandle.test.js +0 -0
  71. /package/{src → scripts}/tests/ElementLocator.test.js +0 -0
  72. /package/{src → scripts}/tests/ErrorAggregator.test.js +0 -0
  73. /package/{src → scripts}/tests/EvalSerializer.test.js +0 -0
  74. /package/{src → scripts}/tests/ExecuteBrowser.test.js +0 -0
  75. /package/{src → scripts}/tests/ExecuteDynamic.test.js +0 -0
  76. /package/{src → scripts}/tests/ExecuteForm.test.js +0 -0
  77. /package/{src → scripts}/tests/ExecuteInteraction.test.js +0 -0
  78. /package/{src → scripts}/tests/ExecuteQuery.test.js +0 -0
  79. /package/{src → scripts}/tests/FillExecutor.test.js +0 -0
  80. /package/{src → scripts}/tests/InputEmulator.test.js +0 -0
  81. /package/{src → scripts}/tests/KeyboardExecutor.test.js +0 -0
  82. /package/{src → scripts}/tests/LazyResolver.test.js +0 -0
  83. /package/{src → scripts}/tests/NetworkErrorCapture.test.js +0 -0
  84. /package/{src → scripts}/tests/PageController.test.js +0 -0
  85. /package/{src → scripts}/tests/PdfCapture.test.js +0 -0
  86. /package/{src → scripts}/tests/ScreenshotCapture.test.js +0 -0
  87. /package/{src → scripts}/tests/SessionRegistry.test.js +0 -0
  88. /package/{src → scripts}/tests/StepValidator.test.js +0 -0
  89. /package/{src → scripts}/tests/TargetManager.test.js +0 -0
  90. /package/{src → scripts}/tests/TestRunner.test.js +0 -0
  91. /package/{src → scripts}/tests/WaitStrategy.test.js +0 -0
  92. /package/{src → scripts}/tests/WaitUtilities.test.js +0 -0
  93. /package/{src → scripts}/tests/WebStorageManager.test.js +0 -0
  94. /package/{src → scripts}/tests/integration.test.js +0 -0
  95. /package/{src → scripts}/types.js +0 -0
  96. /package/{src → scripts}/utils/backoff.js +0 -0
  97. /package/{src → scripts}/utils/cdp-helpers.js +0 -0
  98. /package/{src → scripts}/utils/devices.js +0 -0
  99. /package/{src → scripts}/utils/errors.js +0 -0
  100. /package/{src → scripts}/utils/index.js +0 -0
  101. /package/{src → scripts}/utils/temp.js +0 -0
  102. /package/{src → scripts}/utils/validators.js +0 -0
  103. /package/{src → scripts}/utils.js +0 -0
@@ -0,0 +1,295 @@
1
+ import { createQueryOutputProcessor } from './output-processor.js';
2
+
3
+ // ============================================================================
4
+ // Role Query Executor (from RoleQueryExecutor.js)
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Create a role query executor for advanced role-based queries
9
+ * @param {Object} session - CDP session
10
+ * @param {Object} elementLocator - Element locator instance
11
+ * @returns {Object} Role query executor interface
12
+ */
13
+ export function createRoleQueryExecutor(session, elementLocator, options = {}) {
14
+ const getFrameContext = options.getFrameContext || null;
15
+ const outputProcessor = createQueryOutputProcessor(session);
16
+
17
+ async function releaseObject(objectId) {
18
+ try {
19
+ await session.send('Runtime.releaseObject', { objectId });
20
+ } catch {
21
+ // Ignore
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Query elements by one or more roles
27
+ * @param {string[]} roles - Array of roles to query
28
+ * @param {Object} filters - Filter options
29
+ * @returns {Promise<Object[]>} Array of element handles
30
+ */
31
+ async function queryByRoles(roles, filters) {
32
+ const { name, nameExact, nameRegex, checked, disabled, level } = filters;
33
+
34
+ // Map ARIA roles to common HTML element selectors
35
+ const ROLE_SELECTORS = {
36
+ button: ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]', '[role="button"]'],
37
+ textbox: ['input:not([type])', 'input[type="text"]', 'input[type="email"]', 'input[type="password"]', 'input[type="search"]', 'input[type="tel"]', 'input[type="url"]', 'textarea', '[role="textbox"]'],
38
+ checkbox: ['input[type="checkbox"]', '[role="checkbox"]'],
39
+ link: ['a[href]', '[role="link"]'],
40
+ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role="heading"]'],
41
+ listitem: ['li', '[role="listitem"]'],
42
+ option: ['option', '[role="option"]'],
43
+ combobox: ['select', '[role="combobox"]'],
44
+ radio: ['input[type="radio"]', '[role="radio"]'],
45
+ img: ['img[alt]', '[role="img"]'],
46
+ tab: ['[role="tab"]'],
47
+ tabpanel: ['[role="tabpanel"]'],
48
+ menu: ['[role="menu"]'],
49
+ menuitem: ['[role="menuitem"]'],
50
+ dialog: ['dialog', '[role="dialog"]'],
51
+ alert: ['[role="alert"]'],
52
+ navigation: ['nav', '[role="navigation"]'],
53
+ main: ['main', '[role="main"]'],
54
+ search: ['[role="search"]'],
55
+ form: ['form', '[role="form"]']
56
+ };
57
+
58
+ // Build selectors for all requested roles
59
+ const allSelectors = [];
60
+ for (const r of roles) {
61
+ const selectors = ROLE_SELECTORS[r] || [`[role="${r}"]`];
62
+ allSelectors.push(...selectors);
63
+ }
64
+ const selectorString = allSelectors.join(', ');
65
+
66
+ // Build filter conditions
67
+ const nameFilter = (name !== undefined && name !== null) ? JSON.stringify(name) : null;
68
+ const nameExactFlag = nameExact === true;
69
+ const nameRegexPattern = nameRegex ? JSON.stringify(nameRegex) : null;
70
+ const checkedFilter = checked !== undefined ? checked : null;
71
+ const disabledFilter = disabled !== undefined ? disabled : null;
72
+ const levelFilter = level !== undefined ? level : null;
73
+ const rolesForLevel = roles; // For heading level detection
74
+
75
+ const expression = `
76
+ (function() {
77
+ const selectors = ${JSON.stringify(selectorString)};
78
+ const nameFilter = ${nameFilter};
79
+ const nameExact = ${nameExactFlag};
80
+ const nameRegex = ${nameRegexPattern};
81
+ const checkedFilter = ${checkedFilter !== null ? checkedFilter : 'null'};
82
+ const disabledFilter = ${disabledFilter !== null ? disabledFilter : 'null'};
83
+ const levelFilter = ${levelFilter !== null ? levelFilter : 'null'};
84
+ const rolesForLevel = ${JSON.stringify(rolesForLevel)};
85
+
86
+ const elements = Array.from(document.querySelectorAll(selectors));
87
+
88
+ return elements.filter(el => {
89
+ // Filter by accessible name if specified
90
+ if (nameFilter !== null || nameRegex !== null) {
91
+ const accessibleName = (
92
+ el.getAttribute('aria-label') ||
93
+ el.textContent?.trim() ||
94
+ el.getAttribute('title') ||
95
+ el.getAttribute('placeholder') ||
96
+ el.value ||
97
+ ''
98
+ );
99
+
100
+ if (nameFilter !== null) {
101
+ if (nameExact) {
102
+ // Exact match
103
+ if (accessibleName !== nameFilter) return false;
104
+ } else {
105
+ // Contains match (case-insensitive)
106
+ if (!accessibleName.toLowerCase().includes(nameFilter.toLowerCase())) return false;
107
+ }
108
+ }
109
+
110
+ if (nameRegex !== null) {
111
+ // Regex match
112
+ try {
113
+ const regex = new RegExp(nameRegex);
114
+ if (!regex.test(accessibleName)) return false;
115
+ } catch (e) {
116
+ // Invalid regex, skip filter
117
+ }
118
+ }
119
+ }
120
+
121
+ // Filter by checked state if specified
122
+ if (checkedFilter !== null) {
123
+ const isChecked = el.checked === true || el.getAttribute('aria-checked') === 'true';
124
+ if (isChecked !== checkedFilter) return false;
125
+ }
126
+
127
+ // Filter by disabled state if specified
128
+ if (disabledFilter !== null) {
129
+ const isDisabled = el.disabled === true || el.getAttribute('aria-disabled') === 'true';
130
+ if (isDisabled !== disabledFilter) return false;
131
+ }
132
+
133
+ // Filter by heading level if specified
134
+ if (levelFilter !== null && rolesForLevel.includes('heading')) {
135
+ const tagName = el.tagName.toLowerCase();
136
+ let headingLevel = null;
137
+
138
+ // Check aria-level first
139
+ const ariaLevel = el.getAttribute('aria-level');
140
+ if (ariaLevel) {
141
+ headingLevel = parseInt(ariaLevel, 10);
142
+ } else if (tagName.match(/^h[1-6]$/)) {
143
+ // Extract level from h1-h6 tag
144
+ headingLevel = parseInt(tagName.charAt(1), 10);
145
+ }
146
+
147
+ if (headingLevel !== levelFilter) return false;
148
+ }
149
+
150
+ return true;
151
+ });
152
+ })()
153
+ `;
154
+
155
+ let result;
156
+ try {
157
+ const evalArgs = { expression, returnByValue: false };
158
+ if (getFrameContext) {
159
+ const contextId = getFrameContext();
160
+ if (contextId) evalArgs.contextId = contextId;
161
+ }
162
+ result = await session.send('Runtime.evaluate', evalArgs);
163
+ } catch (error) {
164
+ throw new Error(`Role query error: ${error.message}`);
165
+ }
166
+
167
+ if (result.exceptionDetails) {
168
+ throw new Error(`Role query error: ${result.exceptionDetails.text}`);
169
+ }
170
+
171
+ if (!result.result.objectId) return [];
172
+
173
+ const arrayObjectId = result.result.objectId;
174
+ let props;
175
+ try {
176
+ props = await session.send('Runtime.getProperties', {
177
+ objectId: arrayObjectId,
178
+ ownProperties: true
179
+ });
180
+ } catch (error) {
181
+ await releaseObject(arrayObjectId);
182
+ throw new Error(`Role query error: ${error.message}`);
183
+ }
184
+
185
+ const { createElementHandle } = await import('../dom/element-handle.js');
186
+ const elements = props.result
187
+ .filter(p => /^\d+$/.test(p.name) && p.value && p.value.objectId)
188
+ .map(p => createElementHandle(session, p.value.objectId, {
189
+ selector: `[role="${roles.join('|')}"]`
190
+ }));
191
+
192
+ await releaseObject(arrayObjectId);
193
+ return elements;
194
+ }
195
+
196
+ /**
197
+ * Execute a role-based query with advanced options
198
+ * @param {Object} params - Query parameters
199
+ * @returns {Promise<Object>} Query results
200
+ */
201
+ async function execute(params) {
202
+ const {
203
+ role,
204
+ name,
205
+ nameExact,
206
+ nameRegex,
207
+ checked,
208
+ disabled,
209
+ level,
210
+ limit = 10,
211
+ output = 'text',
212
+ clean = false,
213
+ metadata = false,
214
+ countOnly = false,
215
+ refs = false
216
+ } = params;
217
+
218
+ // Handle compound roles
219
+ const roles = Array.isArray(role) ? role : [role];
220
+
221
+ // Build query expression
222
+ const elements = await queryByRoles(roles, {
223
+ name,
224
+ nameExact,
225
+ nameRegex,
226
+ checked,
227
+ disabled,
228
+ level
229
+ });
230
+
231
+ // Count-only mode
232
+ if (countOnly) {
233
+ // Dispose all elements
234
+ for (const el of elements) {
235
+ try { await el.dispose(); } catch { /* ignore */ }
236
+ }
237
+
238
+ return {
239
+ role: roles.length === 1 ? roles[0] : roles,
240
+ total: elements.length,
241
+ countOnly: true
242
+ };
243
+ }
244
+
245
+ const results = [];
246
+ const count = Math.min(elements.length, limit);
247
+
248
+ for (let i = 0; i < count; i++) {
249
+ const el = elements[i];
250
+ try {
251
+ const resultItem = {
252
+ index: i + 1,
253
+ value: await outputProcessor.processOutput(el, output, { clean })
254
+ };
255
+
256
+ // Add element metadata if requested
257
+ if (metadata) {
258
+ resultItem.metadata = await outputProcessor.getElementMetadata(el);
259
+ }
260
+
261
+ // Add element ref if requested
262
+ if (refs) {
263
+ resultItem.ref = el.objectId;
264
+ }
265
+
266
+ results.push(resultItem);
267
+ } catch (e) {
268
+ results.push({ index: i + 1, value: null, error: e.message });
269
+ }
270
+ }
271
+
272
+ // Dispose all elements
273
+ for (const el of elements) {
274
+ try { await el.dispose(); } catch { /* ignore */ }
275
+ }
276
+
277
+ return {
278
+ role: roles.length === 1 ? roles[0] : roles,
279
+ name: name || null,
280
+ nameExact: nameExact || false,
281
+ nameRegex: nameRegex || null,
282
+ checked: checked !== undefined ? checked : null,
283
+ disabled: disabled !== undefined ? disabled : null,
284
+ level: level !== undefined ? level : null,
285
+ total: elements.length,
286
+ showing: count,
287
+ results
288
+ };
289
+ }
290
+
291
+ return {
292
+ execute,
293
+ queryByRoles
294
+ };
295
+ }
@@ -475,7 +475,7 @@ export function createRoleQueryExecutor(session, elementLocator, options = {}) {
475
475
  // The snapshot script runs entirely in the browser context
476
476
  const SNAPSHOT_SCRIPT = `
477
477
  (function generateAriaSnapshot(rootSelector, options) {
478
- const { mode = 'ai', maxDepth = 50, maxElements = 0, includeText = false, includeFrames = false, viewportOnly = false, pierceShadow = false, preserveRefs = false, since = null, internal = false, frameIdentifier = 'f0' } = options || {};
478
+ const { mode = 'ai', maxDepth = 50, maxElements = 0, maxNameLength = 150, includeText = false, includeFrames = false, viewportOnly = false, pierceShadow = false, preserveRefs = false, since = null, internal = false, frameIdentifier = 'f0' } = options || {};
479
479
 
480
480
  // Viewport dimensions for viewport-only mode
481
481
  const viewportWidth = window.innerWidth;
@@ -628,7 +628,7 @@ const SNAPSHOT_SCRIPT = `
628
628
  'searchbox', 'slider', 'spinbutton', 'textbox', 'tree'];
629
629
 
630
630
  // Interactable roles for AI mode
631
- const INTERACTABLE_ROLES = ['button', 'checkbox', 'combobox', 'link', 'listbox', 'menuitem',
631
+ const INTERACTABLE_ROLES = ['button', 'checkbox', 'combobox', 'heading', 'link', 'listbox', 'menuitem',
632
632
  'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton',
633
633
  'switch', 'tab', 'textbox', 'treeitem'];
634
634
 
@@ -766,6 +766,11 @@ const SNAPSHOT_SCRIPT = `
766
766
  return text.replace(/\\s+/g, ' ').trim();
767
767
  }
768
768
 
769
+ function truncateName(text) {
770
+ if (!text || maxNameLength <= 0 || text.length <= maxNameLength) return text;
771
+ return text.substring(0, maxNameLength) + '…';
772
+ }
773
+
769
774
  function getCheckedState(el, role) {
770
775
  if (!CHECKED_ROLES.includes(role)) return undefined;
771
776
 
@@ -1017,7 +1022,7 @@ const SNAPSHOT_SCRIPT = `
1017
1022
  if (mode === 'ai' && !visible) return null;
1018
1023
 
1019
1024
  const role = getAriaRole(el);
1020
- const name = getAccessibleName(el);
1025
+ const name = truncateName(getAccessibleName(el));
1021
1026
 
1022
1027
  // Skip elements without semantic meaning
1023
1028
  if (!role && mode === 'ai') {
@@ -1415,6 +1420,7 @@ export function createAriaSnapshot(session, options = {}) {
1415
1420
  * @param {string} options.detail - Detail level: 'summary', 'interactive', or 'full' (default: 'full')
1416
1421
  * @param {number} options.maxDepth - Maximum tree depth (default: 50)
1417
1422
  * @param {number} options.maxElements - Maximum elements to include (default: unlimited)
1423
+ * @param {number} options.maxNameLength - Truncate accessible names longer than this (default: 150, 0 to disable)
1418
1424
  * @param {boolean} options.includeText - Include static text nodes in output (default: false for ai mode)
1419
1425
  * @param {boolean} options.includeFrames - Include same-origin iframe content (default: false)
1420
1426
  * @param {boolean} options.viewportOnly - Only include elements visible in viewport (default: false)
@@ -1424,13 +1430,13 @@ export function createAriaSnapshot(session, options = {}) {
1424
1430
  * @returns {Promise<Object>} Snapshot result with tree, yaml, refs, and snapshotId
1425
1431
  */
1426
1432
  async function generate(options = {}) {
1427
- 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;
1433
+ const { root = null, mode = 'ai', detail = 'full', maxDepth = 50, maxElements = 0, maxNameLength = 150, includeText = false, includeFrames = false, viewportOnly = false, pierceShadow = false, preserveRefs = false, since = null, internal = false } = options;
1428
1434
 
1429
1435
  // Get frame identifier for ref generation (f0 for main frame, f1, f2, etc. for iframes)
1430
1436
  const frameIdentifier = getFrameIdentifier ? await getFrameIdentifier() : 'f0';
1431
1437
 
1432
1438
  const evalArgs = {
1433
- expression: `(${SNAPSHOT_SCRIPT})(${JSON.stringify(root)}, ${JSON.stringify({ mode, detail, maxDepth, maxElements, includeText, includeFrames, viewportOnly, pierceShadow, preserveRefs, since, internal, frameIdentifier })})`,
1439
+ expression: `(${SNAPSHOT_SCRIPT})(${JSON.stringify(root)}, ${JSON.stringify({ mode, detail, maxDepth, maxElements, maxNameLength, includeText, includeFrames, viewportOnly, pierceShadow, preserveRefs, since, internal, frameIdentifier })})`,
1434
1440
  returnByValue: true,
1435
1441
  awaitPromise: false
1436
1442
  };
@@ -122,18 +122,20 @@ export function createConsoleCapture(session, options = {}) {
122
122
  async function stopCapture() {
123
123
  if (!capturing) return;
124
124
 
125
- if (handlers.consoleAPICalled) {
126
- session.off('Runtime.consoleAPICalled', handlers.consoleAPICalled);
125
+ try {
126
+ if (handlers.consoleAPICalled) {
127
+ session.off('Runtime.consoleAPICalled', handlers.consoleAPICalled);
128
+ }
129
+ if (handlers.exceptionThrown) {
130
+ session.off('Runtime.exceptionThrown', handlers.exceptionThrown);
131
+ }
132
+
133
+ await session.send('Runtime.disable');
134
+ } finally {
127
135
  handlers.consoleAPICalled = null;
128
- }
129
- if (handlers.exceptionThrown) {
130
- session.off('Runtime.exceptionThrown', handlers.exceptionThrown);
131
136
  handlers.exceptionThrown = null;
137
+ capturing = false;
132
138
  }
133
-
134
- await session.send('Runtime.disable');
135
-
136
- capturing = false;
137
139
  }
138
140
 
139
141
  /**
@@ -86,14 +86,15 @@ export function createScreenshotCapture(session, options = {}) {
86
86
  };
87
87
  }
88
88
 
89
- const result = await session.send('Page.captureScreenshot', params);
90
-
91
- // Reset background override if we changed it
92
- if (captureOptions.omitBackground) {
93
- await session.send('Emulation.setDefaultBackgroundColorOverride');
89
+ try {
90
+ const result = await session.send('Page.captureScreenshot', params);
91
+ return Buffer.from(result.data, 'base64');
92
+ } finally {
93
+ // Reset background override even if screenshot fails
94
+ if (captureOptions.omitBackground) {
95
+ await session.send('Emulation.setDefaultBackgroundColorOverride');
96
+ }
94
97
  }
95
-
96
- return Buffer.from(result.data, 'base64');
97
98
  }
98
99
 
99
100
  /**
@@ -226,7 +227,6 @@ export function createScreenshotCapture(session, options = {}) {
226
227
  */
227
228
  async function captureToFile(filePath, captureOptions = {}, elementLocator = null) {
228
229
  let buffer;
229
- let elementBox = null;
230
230
 
231
231
  // Support element screenshot via selector
232
232
  if (captureOptions.selector && elementLocator) {
@@ -241,7 +241,6 @@ export function createScreenshotCapture(session, options = {}) {
241
241
  throw new Error(`Element has no visible dimensions: ${captureOptions.selector}`);
242
242
  }
243
243
 
244
- elementBox = box;
245
244
  buffer = await captureElement(box, captureOptions);
246
245
  } else if (captureOptions.fullPage) {
247
246
  buffer = await captureFullPage(captureOptions);
@@ -121,6 +121,7 @@ export function createConnection(wsUrl, options = {}) {
121
121
  connected = false;
122
122
  connecting = false;
123
123
  rejectPendingCommands('Connection closed');
124
+ emit('__connection_closed');
124
125
 
125
126
  if (wasConnected && !intentionalClose && autoReconnect) {
126
127
  attemptReconnect();
@@ -164,13 +165,24 @@ export function createConnection(wsUrl, options = {}) {
164
165
 
165
166
  function doReconnect() {
166
167
  return new Promise((resolve, reject) => {
167
- ws = new WebSocket(wsUrl);
168
- ws.addEventListener('open', () => {
168
+ // Close old WebSocket if still open to prevent stale event handlers
169
+ if (ws) {
170
+ try { ws.close(); } catch { /* already closed */ }
171
+ ws = null;
172
+ }
173
+
174
+ const newWs = new WebSocket(wsUrl);
175
+
176
+ newWs.addEventListener('open', () => {
177
+ ws = newWs;
169
178
  connected = true;
170
179
  setupWebSocketListeners();
171
180
  resolve();
172
181
  });
173
- ws.addEventListener('error', (event) => {
182
+
183
+ newWs.addEventListener('error', (event) => {
184
+ // Don't assign to ws if connection failed
185
+ try { newWs.close(); } catch { /* already closing */ }
174
186
  reject(new Error(`CDP reconnection error: ${event.message || 'Connection failed'}`));
175
187
  });
176
188
  });
@@ -206,6 +218,7 @@ export function createConnection(wsUrl, options = {}) {
206
218
  connected = false;
207
219
  connecting = false;
208
220
  rejectPendingCommands('Connection closed');
221
+ emit('__connection_closed');
209
222
 
210
223
  if (wasConnected && !intentionalClose && autoReconnect) {
211
224
  attemptReconnect();
@@ -300,20 +313,31 @@ export function createConnection(wsUrl, options = {}) {
300
313
  */
301
314
  function waitForEvent(event, predicate = () => true, timeout = 30000) {
302
315
  return new Promise((resolve, reject) => {
303
- const timer = setTimeout(() => {
316
+ function cleanup() {
317
+ clearTimeout(timer);
304
318
  off(event, handler);
319
+ off('__connection_closed', closeHandler);
320
+ }
321
+
322
+ const timer = setTimeout(() => {
323
+ cleanup();
305
324
  reject(new Error(`Timeout waiting for event: ${event}`));
306
325
  }, timeout);
307
326
 
308
327
  const handler = (params) => {
309
328
  if (predicate(params)) {
310
- clearTimeout(timer);
311
- off(event, handler);
329
+ cleanup();
312
330
  resolve(params);
313
331
  }
314
332
  };
315
333
 
334
+ const closeHandler = () => {
335
+ cleanup();
336
+ reject(new Error(`Connection closed while waiting for event: ${event}`));
337
+ };
338
+
316
339
  on(event, handler);
340
+ on('__connection_closed', closeHandler);
317
341
  });
318
342
  }
319
343
 
@@ -6,9 +6,9 @@
6
6
  * or reads from stdin (fallback).
7
7
  *
8
8
  * Usage:
9
- * node src/cdp-skill.js '{"steps":[{"goto":"https://google.com"}]}'
10
- * echo '{"steps":[...]}' | node src/cdp-skill.js
11
- * node src/cdp-skill.js --debug '{"steps":[...]}' # Enable debug logging
9
+ * node scripts/cdp-skill.js '{"steps":[{"goto":"https://google.com"}]}'
10
+ * echo '{"steps":[...]}' | node scripts/cdp-skill.js
11
+ * node scripts/cdp-skill.js --debug '{"steps":[...]}' # Enable debug logging
12
12
  */
13
13
 
14
14
  import { createBrowser, getChromeStatus } from './cdp/index.js';
@@ -62,7 +62,8 @@ function generateDebugFilename(steps, status, tabId) {
62
62
  const actionKeys = ['goto', 'click', 'fill', 'type', 'press', 'scroll', 'snapshot',
63
63
  'query', 'hover', 'wait', 'sleep', 'pageFunction', 'newTab', 'closeTab',
64
64
  'selectOption', 'select', 'viewport', 'cookies', 'back', 'forward', 'drag',
65
- 'frame', 'elementsAt', 'extract', 'formState', 'assert', 'validate', 'submit'];
65
+ 'frame', 'elementsAt', 'extract', 'formState', 'assert', 'validate', 'submit',
66
+ 'upload'];
66
67
  for (const key of actionKeys) {
67
68
  if (step[key] !== undefined) return key;
68
69
  }
@@ -507,7 +508,7 @@ async function main() {
507
508
 
508
509
  // Extract top-level fields
509
510
  const tab = json.tab || null;
510
- const timeout = json.timeout || 30000;
511
+ const timeout = json.timeout ?? 30000;
511
512
  let host = 'localhost';
512
513
  let port = 9222;
513
514
  let headless = false;
@@ -689,7 +690,7 @@ async function main() {
689
690
  const frameContextProvider = () => pageController.getFrameContext();
690
691
  const frameIdentifierProvider = () => pageController.getFrameIdentifier();
691
692
  const elementLocator = createElementLocator(session, { getFrameContext: frameContextProvider });
692
- const inputEmulator = createInputEmulator(session);
693
+ const inputEmulator = createInputEmulator(session, { getFrameContext: frameContextProvider });
693
694
  const screenshotCapture = createScreenshotCapture(session);
694
695
  const consoleCapture = createConsoleCapture(session);
695
696
  const pdfCapture = createPdfCapture(session);
@@ -19,9 +19,10 @@ export function createSnapshotDiffer() {
19
19
 
20
20
  const lines = yaml.split('\n');
21
21
  for (const line of lines) {
22
- // Match lines like: - button "Submit" [ref=s1e1]
23
- // Or: - heading "Title" [level=1] [ref=s2e3]
24
- const refMatch = line.match(/\[ref=(s\d+e\d+)\]/);
22
+ // Match lines like: - button "Submit" [ref=f0s1e1]
23
+ // Or: - heading "Title" [level=1] [ref=f0s2e3]
24
+ // Ref format: f{frameId}s{snapshotId}e{elementNumber} where frameId can be a number or [name]
25
+ const refMatch = line.match(/\[ref=(f(?:\d+|\[[^\]]+\])s\d+e\d+)\]/);
25
26
  if (refMatch) {
26
27
  const ref = refMatch[1];
27
28
 
@@ -126,16 +127,16 @@ export function createSnapshotDiffer() {
126
127
  }
127
128
 
128
129
  /**
129
- * Extract ref from a line like "- link \"text\" [ref=s1e42]"
130
+ * Extract ref from a line like "- link \"text\" [ref=f0s1e42]"
130
131
  */
131
132
  function extractRef(line) {
132
- const match = line.match(/\[ref=(s\d+e\d+)\]/);
133
+ const match = line.match(/\[ref=(f(?:\d+|\[[^\]]+\])s\d+e\d+)\]/);
133
134
  return match ? match[1] : null;
134
135
  }
135
136
 
136
137
  /**
137
138
  * Format refs as a compact list
138
- * e.g., [s1e1, s1e2, s1e5] -> "s1e1, s1e2, s1e5"
139
+ * e.g., [f0s1e1, f0s1e2, f0s1e5] -> "f0s1e1, f0s1e2, f0s1e5"
139
140
  */
140
141
  function formatRefs(lines, maxRefs = 10) {
141
142
  const refs = lines.map(extractRef).filter(Boolean);
@@ -417,16 +417,20 @@ export function createLazyResolver(session, options = {}) {
417
417
  return null;
418
418
  }
419
419
 
420
- // Collect all shadow roots
421
- const shadowHosts = document.querySelectorAll('*');
422
- for (const host of shadowHosts) {
423
- if (host.shadowRoot) {
424
- const result = searchInRoot(host.shadowRoot, []);
420
+ // Walk the tree to find shadow roots (avoids querySelectorAll('*'))
421
+ function walkForShadowRoots(node) {
422
+ if (node.shadowRoot) {
423
+ const result = searchInRoot(node.shadowRoot, []);
425
424
  if (result) return result;
426
425
  }
426
+ const children = node.children || [];
427
+ for (const child of children) {
428
+ const result = walkForShadowRoots(child);
429
+ if (result) return result;
430
+ }
431
+ return null;
427
432
  }
428
-
429
- return null;
433
+ return walkForShadowRoots(document.body);
430
434
  })()
431
435
  `;
432
436
 
@@ -474,20 +478,27 @@ export function createLazyResolver(session, options = {}) {
474
478
  return rect.width > 0 && rect.height > 0;
475
479
  }
476
480
 
477
- const shadowHosts = document.querySelectorAll('*');
478
- for (const host of shadowHosts) {
479
- if (host.shadowRoot) {
481
+ function walkForShadowRoots(node) {
482
+ if (node.shadowRoot) {
480
483
  const selectors = ROLE_SELECTORS[targetRole] || ['[role="' + targetRole + '"]'];
481
- const elements = host.shadowRoot.querySelectorAll(selectors.join(', '));
484
+ const elements = node.shadowRoot.querySelectorAll(selectors.join(', '));
482
485
  for (const el of elements) {
483
486
  if (!isVisible(el)) continue;
484
487
  const elName = getAccessibleName(el);
485
488
  if (targetName && !elName.toLowerCase().includes(targetName.toLowerCase())) continue;
486
489
  return el;
487
490
  }
491
+ const found = walkForShadowRoots(node.shadowRoot);
492
+ if (found) return found;
493
+ }
494
+ const children = node.children || [];
495
+ for (const child of children) {
496
+ const found = walkForShadowRoots(child);
497
+ if (found) return found;
488
498
  }
499
+ return null;
489
500
  }
490
- return null;
501
+ return walkForShadowRoots(document.body);
491
502
  })()
492
503
  `;
493
504