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
@@ -3,105 +3,14 @@
3
3
  * Fill and select step executors
4
4
  *
5
5
  * EXPORTS:
6
- * - executeFill(elementLocator, inputEmulator, params) → Promise<void>
7
6
  * - executeFillActive(pageController, inputEmulator, params) → Promise<Object>
8
7
  * - executeSelectOption(elementLocator, params) → Promise<Object>
9
8
  *
10
9
  * DEPENDENCIES:
11
- * - ../dom/index.js: createElementValidator, createReactInputFiller
12
- * - ../utils.js: elementNotFoundError, elementNotEditableError, resetInputState
10
+ * - ../utils.js: elementNotFoundError
13
11
  */
14
12
 
15
- import { createElementValidator, createReactInputFiller } from '../dom/index.js';
16
- import { elementNotFoundError, elementNotEditableError, resetInputState } from '../utils.js';
17
-
18
- export async function executeFill(elementLocator, inputEmulator, params) {
19
- const { selector, value, react } = params;
20
-
21
- if (!selector || value === undefined) {
22
- throw new Error('Fill requires selector and value');
23
- }
24
-
25
- const element = await elementLocator.findElement(selector);
26
- if (!element) {
27
- throw elementNotFoundError(selector, 0);
28
- }
29
-
30
- // Validate element is editable before attempting fill
31
- const validator = createElementValidator(elementLocator.session);
32
- const editableCheck = await validator.isEditable(element._handle.objectId);
33
- if (!editableCheck.editable) {
34
- await element._handle.dispose();
35
- throw elementNotEditableError(selector, editableCheck.reason);
36
- }
37
-
38
- // Try fast path first - scroll to center with short stability check
39
- let actionable;
40
- try {
41
- await element._handle.scrollIntoView({ block: 'center' });
42
- // Use short stability timeout - most elements stabilize quickly
43
- await element._handle.waitForStability({ frames: 2, timeout: 300 });
44
- actionable = await element._handle.isActionable();
45
- } catch (e) {
46
- // Stability check failed, check actionability anyway
47
- actionable = await element._handle.isActionable();
48
- }
49
-
50
- // If not actionable, try alternative scroll strategies
51
- if (!actionable.actionable) {
52
- let lastError = new Error(`Element not actionable: ${actionable.reason}`);
53
-
54
- for (const strategy of ['end', 'start', 'nearest']) {
55
- try {
56
- await element._handle.scrollIntoView({ block: strategy });
57
- await element._handle.waitForStability({ frames: 2, timeout: 500 });
58
- actionable = await element._handle.isActionable();
59
-
60
- if (actionable.actionable) break;
61
- lastError = new Error(`Element not actionable: ${actionable.reason}`);
62
- } catch (e) {
63
- lastError = e;
64
- }
65
- }
66
-
67
- if (!actionable.actionable) {
68
- await element._handle.dispose();
69
- await resetInputState(elementLocator.session);
70
- throw lastError;
71
- }
72
- }
73
-
74
- try {
75
- // Use React-specific fill approach if react option is set
76
- if (react) {
77
- const reactFiller = createReactInputFiller(elementLocator.session);
78
- await reactFiller.fillByObjectId(element._handle.objectId, value);
79
- return; // Success
80
- }
81
-
82
- // Standard fill approach using keyboard events
83
- const box = await element._handle.getBoundingBox();
84
- const x = box.x + box.width / 2;
85
- const y = box.y + box.height / 2;
86
-
87
- // Click to focus
88
- await inputEmulator.click(x, y);
89
-
90
- // Focus element directly - more reliable than relying on click
91
- await element._handle.focus();
92
-
93
- if (params.clear !== false) {
94
- await inputEmulator.selectAll();
95
- }
96
-
97
- await inputEmulator.type(String(value));
98
- } catch (e) {
99
- await resetInputState(elementLocator.session);
100
- throw e;
101
- } finally {
102
- await element._handle.dispose();
103
- }
104
- }
13
+ import { elementNotFoundError } from '../utils.js';
105
14
 
106
15
  /**
107
16
  * Execute a selectOption step - select option in dropdown
@@ -226,15 +135,12 @@ export async function executeSelectOption(elementLocator, params) {
226
135
  */
227
136
 
228
137
  export async function executeFillActive(pageController, inputEmulator, params) {
229
- const session = pageController.session;
230
-
231
138
  // Parse params
232
139
  const value = typeof params === 'string' ? params : (params && params.value);
233
140
  const clear = typeof params === 'object' && params !== null ? params.clear !== false : true;
234
141
 
235
142
  // Check if there's an active element and if it's editable
236
- const checkResult = await session.send('Runtime.evaluate', {
237
- expression: `(function() {
143
+ const checkResult = await pageController.evaluateInFrame(`(function() {
238
144
  const el = document.activeElement;
239
145
  if (!el || el === document.body || el === document.documentElement) {
240
146
  return { error: 'No element is focused' };
@@ -271,9 +177,8 @@ export async function executeFillActive(pageController, inputEmulator, params) {
271
177
  selector: selector,
272
178
  valueBefore: el.value || ''
273
179
  };
274
- })()`,
275
- returnByValue: true
276
- });
180
+ })()`);
181
+
277
182
 
278
183
  if (checkResult.exceptionDetails) {
279
184
  throw new Error(`fillActive error: ${checkResult.exceptionDetails.text}`);
@@ -302,6 +207,128 @@ export async function executeFillActive(pageController, inputEmulator, params) {
302
207
  };
303
208
  }
304
209
 
210
+ /**
211
+ * Execute an upload step - set files on a file input element via CDP
212
+ * Uses DOM.setFileInputFiles to set file paths on <input type="file"> elements
213
+ *
214
+ * Shapes:
215
+ * {"upload": "/path/to/file.txt"} — single file, auto-find file input
216
+ * {"upload": ["/path/a.txt", "/path/b.png"]} — multiple files, auto-find file input
217
+ * {"upload": {"selector": "#file-input", "file": "a.txt"}} — targeted single file
218
+ * {"upload": {"selector": "#file-input", "files": ["a.txt", "b.png"]}} — targeted multiple files
219
+ * {"upload": {"ref": "f0s1e3", "files": ["a.txt"]}} — ref-targeted
220
+ */
221
+ export async function executeUpload(elementLocator, pageController, params) {
222
+ const session = elementLocator.session;
223
+
224
+ // Normalize params into { files, selector?, ref? }
225
+ let files, selector, ref;
226
+ if (typeof params === 'string') {
227
+ files = [params];
228
+ } else if (Array.isArray(params)) {
229
+ files = params;
230
+ } else {
231
+ selector = params.selector;
232
+ ref = params.ref;
233
+ files = params.files || (params.file ? [params.file] : []);
234
+ }
235
+
236
+ // Find the file input element
237
+ let objectId;
238
+ try {
239
+ if (ref) {
240
+ // Resolve ref to objectId via aria refs
241
+ const evalParams = {
242
+ expression: `window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)})`,
243
+ returnByValue: false
244
+ };
245
+ const contextId = pageController.getFrameContext();
246
+ if (contextId) evalParams.contextId = contextId;
247
+ const result = await session.send('Runtime.evaluate', evalParams);
248
+ if (!result.result.objectId) {
249
+ throw new Error(`Element ref ${ref} not found — run 'snapshot' to get fresh refs`);
250
+ }
251
+ objectId = result.result.objectId;
252
+ } else if (selector) {
253
+ // Find by CSS selector
254
+ const evalParams = {
255
+ expression: `document.querySelector(${JSON.stringify(selector)})`,
256
+ returnByValue: false
257
+ };
258
+ const contextId = pageController.getFrameContext();
259
+ if (contextId) evalParams.contextId = contextId;
260
+ const result = await session.send('Runtime.evaluate', evalParams);
261
+ if (!result.result.objectId) {
262
+ throw elementNotFoundError(selector, 0);
263
+ }
264
+ objectId = result.result.objectId;
265
+ } else {
266
+ // Auto-find: look for a file input on the page
267
+ const evalParams = {
268
+ expression: `document.querySelector('input[type="file"]')`,
269
+ returnByValue: false
270
+ };
271
+ const contextId = pageController.getFrameContext();
272
+ if (contextId) evalParams.contextId = contextId;
273
+ const result = await session.send('Runtime.evaluate', evalParams);
274
+ if (!result.result.objectId) {
275
+ throw new Error('No file input (input[type="file"]) found on the page');
276
+ }
277
+ objectId = result.result.objectId;
278
+ }
279
+
280
+ // Verify it's actually a file input
281
+ const typeCheck = await session.send('Runtime.callFunctionOn', {
282
+ objectId,
283
+ functionDeclaration: `function() {
284
+ return {
285
+ tagName: this.tagName,
286
+ type: this.type,
287
+ accept: this.accept || '',
288
+ multiple: this.hasAttribute('multiple')
289
+ };
290
+ }`,
291
+ returnByValue: true
292
+ });
293
+
294
+ const info = typeCheck.result.value;
295
+ if (!info || info.tagName !== 'INPUT' || info.type !== 'file') {
296
+ throw new Error(`Target element is ${info?.tagName || 'unknown'} type="${info?.type || 'unknown'}" — expected input[type="file"]`);
297
+ }
298
+
299
+ if (files.length > 1 && !info.multiple) {
300
+ throw new Error(`File input does not have 'multiple' attribute but ${files.length} files were provided`);
301
+ }
302
+
303
+ // Set files via CDP
304
+ await session.send('DOM.setFileInputFiles', {
305
+ objectId,
306
+ files
307
+ });
308
+
309
+ // Dispatch change event so frameworks detect the file selection
310
+ await session.send('Runtime.callFunctionOn', {
311
+ objectId,
312
+ functionDeclaration: `function() {
313
+ this.dispatchEvent(new Event('change', { bubbles: true }));
314
+ this.dispatchEvent(new Event('input', { bubbles: true }));
315
+ }`
316
+ });
317
+
318
+ return {
319
+ uploaded: true,
320
+ files,
321
+ accept: info.accept,
322
+ multiple: info.multiple,
323
+ target: selector || ref || 'input[type="file"]'
324
+ };
325
+ } finally {
326
+ if (objectId) {
327
+ try { await session.send('Runtime.releaseObject', { objectId }); } catch {}
328
+ }
329
+ }
330
+ }
331
+
305
332
  /**
306
333
  * Execute a refAt step - get or create a ref for the element at given coordinates
307
334
  * Uses document.elementFromPoint to find the element, then assigns/retrieves a ref
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * EXPORTS:
6
6
  * - executeClick(elementLocator, inputEmulator, ariaSnapshot, params) → Promise<Object>
7
+ * - clickWithVerification: removed (stale, superseded by click-executor.js pointerdown version)
7
8
  * - executeHover(elementLocator, inputEmulator, ariaSnapshot, params) → Promise<Object>
8
9
  * - executeDrag(elementLocator, inputEmulator, pageController, ariaSnapshot, params) → Promise<Object>
9
10
  *
@@ -15,8 +16,6 @@
15
16
  import { createClickExecutor, createActionabilityChecker } from '../dom/index.js';
16
17
  import { elementNotFoundError, sleep, resetInputState, releaseObject } from '../utils.js';
17
18
 
18
- const SCROLL_STRATEGIES = ['center', 'end', 'start', 'nearest'];
19
-
20
19
  export async function executeClick(elementLocator, inputEmulator, ariaSnapshot, params) {
21
20
  // Delegate to ClickExecutor for improved click handling with JS fallback
22
21
  const clickExecutor = createClickExecutor(
@@ -28,42 +27,6 @@ export async function executeClick(elementLocator, inputEmulator, ariaSnapshot,
28
27
  return clickExecutor.execute(params);
29
28
  }
30
29
 
31
- export async function clickWithVerification(elementLocator, inputEmulator, x, y, targetObjectId) {
32
- const session = elementLocator.session;
33
-
34
- // Setup event listener on target before clicking
35
- await session.send('Runtime.callFunctionOn', {
36
- objectId: targetObjectId,
37
- functionDeclaration: `function() {
38
- this.__clickReceived = false;
39
- this.__clickHandler = () => { this.__clickReceived = true; };
40
- this.addEventListener('click', this.__clickHandler, { once: true });
41
- }`
42
- });
43
-
44
- // Perform click
45
- await inputEmulator.click(x, y);
46
- await sleep(50);
47
-
48
- // Check if target received the click
49
- const verifyResult = await session.send('Runtime.callFunctionOn', {
50
- objectId: targetObjectId,
51
- functionDeclaration: `function() {
52
- this.removeEventListener('click', this.__clickHandler);
53
- const received = this.__clickReceived;
54
- delete this.__clickReceived;
55
- delete this.__clickHandler;
56
- return received;
57
- }`,
58
- returnByValue: true
59
- });
60
-
61
- return {
62
- clicked: true,
63
- targetReceived: verifyResult.result.value === true
64
- };
65
- }
66
-
67
30
  /**
68
31
  * Execute a hover step - moves mouse over an element to trigger hover events
69
32
  * Uses Playwright-style auto-waiting for element to be visible and stable
@@ -187,7 +150,9 @@ export async function executeHover(elementLocator, inputEmulator, ariaSnapshot,
187
150
 
188
151
  // Use Playwright-style auto-waiting for element to be actionable
189
152
  // Hover requires: visible, stable
190
- const actionabilityChecker = createActionabilityChecker(elementLocator.session);
153
+ const actionabilityChecker = createActionabilityChecker(elementLocator.session, {
154
+ getFrameContext: elementLocator.getFrameContext || null
155
+ });
191
156
  const waitResult = await actionabilityChecker.waitForActionable(selector, 'hover', {
192
157
  timeout,
193
158
  force
@@ -428,7 +393,7 @@ export async function executeDrag(elementLocator, inputEmulator, pageController,
428
393
  const sourceSelector = getSelectorExpression(source);
429
394
  const targetSelector = getSelectorExpression(target);
430
395
 
431
- const jsDragResult = await session.send('Runtime.evaluate', {
396
+ const dragEvalParams = {
432
397
  expression: `
433
398
  (function() {
434
399
  const sourceEl = ${sourceSelector || 'null'};
@@ -523,21 +488,41 @@ export async function executeDrag(elementLocator, inputEmulator, pageController,
523
488
  if (method === 'mouse') return doMouseDrag();
524
489
  if (method === 'html5') return doHtml5Drag();
525
490
 
526
- // auto: try mouse first (works for jQuery UI, sortable lists), then HTML5 DnD
527
- const mouseResult = doMouseDrag();
528
- if (mouseResult.success) return mouseResult;
529
-
530
- if (sourceEl && targetEl) {
491
+ // auto: detect HTML5 DnD indicators before choosing method
492
+ const needsHtml5 = sourceEl && targetEl && (
493
+ sourceEl.draggable ||
494
+ sourceEl.hasAttribute('ondragstart') ||
495
+ sourceEl.hasAttribute('ondrag') ||
496
+ targetEl.hasAttribute('ondrop') ||
497
+ targetEl.hasAttribute('ondragover')
498
+ );
499
+
500
+ if (needsHtml5) {
501
+ // Try HTML5 DnD first for elements that clearly use it
531
502
  const html5Result = doHtml5Drag();
532
503
  if (html5Result.success) return html5Result;
504
+ // Fall back to mouse if HTML5 failed
505
+ return doMouseDrag();
533
506
  }
534
507
 
508
+ // No HTML5 indicators — use mouse events, fall back to HTML5
509
+ const mouseResult = doMouseDrag();
510
+ if (!mouseResult.success && sourceEl && targetEl) {
511
+ const html5Result = doHtml5Drag();
512
+ if (html5Result.success) return html5Result;
513
+ }
535
514
  return mouseResult;
536
515
  })()
537
516
  `,
538
517
  returnByValue: true,
539
518
  awaitPromise: false
540
- });
519
+ };
520
+
521
+ // Add frame context if in an iframe
522
+ const contextId = pageController.getFrameContext();
523
+ if (contextId) dragEvalParams.contextId = contextId;
524
+
525
+ const jsDragResult = await session.send('Runtime.evaluate', dragEvalParams);
541
526
 
542
527
  const dragResult = jsDragResult.result?.value;
543
528
 
@@ -31,11 +31,10 @@ export async function executeWait(elementLocator, params) {
31
31
  * @returns {Promise<void>}
32
32
  */
33
33
  export async function executeWaitForNavigation(pageController, params) {
34
- const options = params === true ? {} : params;
34
+ const options = (params && typeof params === 'object') ? params : {};
35
35
  const timeout = options.timeout || 30000;
36
36
  const waitUntil = options.waitUntil || 'load';
37
37
 
38
- const session = pageController.session;
39
38
  const startTime = Date.now();
40
39
 
41
40
  // Poll for page ready state
@@ -47,11 +46,8 @@ export async function executeWaitForNavigation(pageController, params) {
47
46
  }
48
47
 
49
48
  try {
50
- const result = await session.send('Runtime.evaluate', {
51
- expression: 'document.readyState',
52
- returnByValue: true
53
- });
54
- const readyState = result.result.value;
49
+ const result = await pageController.evaluateInFrame('document.readyState');
50
+ const readyState = result.result?.value;
55
51
 
56
52
  if (waitUntil === 'commit') {
57
53
  resolve();
@@ -130,8 +126,11 @@ export async function executeScroll(elementLocator, inputEmulator, pageControlle
130
126
  if (!el) {
131
127
  throw elementNotFoundError(params, 0);
132
128
  }
133
- await el.scrollIntoView();
134
- await el.dispose();
129
+ try {
130
+ await el.scrollIntoView();
131
+ } finally {
132
+ await el.dispose();
133
+ }
135
134
  }
136
135
  }
137
136
  } else if (params && typeof params === 'object') {
@@ -145,8 +144,11 @@ export async function executeScroll(elementLocator, inputEmulator, pageControlle
145
144
  if (!el) {
146
145
  throw elementNotFoundError(params.selector, 0);
147
146
  }
148
- await el.scrollIntoView();
149
- await el.dispose();
147
+ try {
148
+ await el.scrollIntoView();
149
+ } finally {
150
+ await el.dispose();
151
+ }
150
152
  } else if (params.deltaY !== undefined || params.deltaX !== undefined) {
151
153
  // Scroll by delta using JavaScript (more reliable than CDP mouse wheel events)
152
154
  await pageController.evaluateInFrame(`window.scrollBy(${params.deltaX || 0}, ${params.deltaY || 0})`);
@@ -161,5 +163,5 @@ export async function executeScroll(elementLocator, inputEmulator, pageControlle
161
163
  '({ scrollX: window.scrollX, scrollY: window.scrollY })'
162
164
  );
163
165
 
164
- return posResult.result.value;
166
+ return posResult.result?.value ?? { scrollX: 0, scrollY: 0 };
165
167
  }
@@ -9,7 +9,7 @@
9
9
  * SUBMODULES:
10
10
  * - ./execute-navigation.js: executeWait, executeWaitForNavigation, executeScroll
11
11
  * - ./execute-interaction.js: executeClick, executeHover, executeDrag
12
- * - ./execute-input.js: executeFill, executeFillActive, executeSelectOption
12
+ * - ./execute-input.js: executeFillActive, executeSelectOption
13
13
  * - ./execute-query.js: executeSnapshot, executeQuery, executeQueryAll, executeInspect, etc.
14
14
  * - ./execute-form.js: executeValidate, executeSubmit, executeFormState, executeExtract, executeAssert
15
15
  * - ./execute-browser.js: executePdf, executeEval, executeCookies, executeConsole, etc.
@@ -47,10 +47,10 @@ import { validateSteps } from './step-validator.js';
47
47
  // Import domain executors
48
48
  import { executeWait, executeWaitForNavigation, executeScroll } from './execute-navigation.js';
49
49
  import { executeClick, executeHover, executeDrag } from './execute-interaction.js';
50
- import { executeFillActive, executeSelectOption } from './execute-input.js';
50
+ import { executeFillActive, executeSelectOption, executeUpload } from './execute-input.js';
51
51
  import { executeSnapshot, executeSnapshotSearch, executeQuery, executeQueryAll, executeInspect, executeGetDom, executeGetBox, executeRefAt, executeElementsAt, executeElementsNear } from './execute-query.js';
52
52
  // executeRefAt, executeElementsNear kept for internal dispatch from unified elementsAt
53
- import { executeSubmit, executeExtract } from './execute-form.js';
53
+ import { executeSubmit, executeExtract, executeAssert } from './execute-form.js';
54
54
  import { executePdf, executeEval, executeCookies, executeListTabs, executeCloseTab, executeConsole, formatCommandConsole } from './execute-browser.js';
55
55
  // executeEval kept for internal dispatch from unified pageFunction
56
56
  import { executePageFunction, executePoll, executeWriteSiteProfile, executeReadSiteProfile, loadSiteProfile } from './execute-dynamic.js';
@@ -115,7 +115,7 @@ export async function executeStep(deps, step, options = {}) {
115
115
  if (actionValue && typeof actionValue === 'object' && actionValue.readyWhen) {
116
116
  const readyResult = await executePoll(pageController, {
117
117
  fn: actionValue.readyWhen,
118
- timeout: options.stepTimeout || 30000
118
+ timeout: stepTimeout || 30000
119
119
  });
120
120
  if (!readyResult.resolved) {
121
121
  throw new Error(`readyWhen did not resolve within timeout`);
@@ -397,6 +397,9 @@ export async function executeStep(deps, step, options = {}) {
397
397
  } else if (step.drag !== undefined) {
398
398
  stepResult.action = 'drag';
399
399
  stepResult.output = await executeDrag(elementLocator, inputEmulator, pageController, deps.ariaSnapshot, step.drag);
400
+ } else if (step.upload !== undefined) {
401
+ stepResult.action = 'upload';
402
+ stepResult.output = await executeUpload(elementLocator, pageController, step.upload);
400
403
  } else if (step.get !== undefined) {
401
404
  stepResult.action = 'get';
402
405
  const getParams = step.get;
@@ -416,10 +419,29 @@ export async function executeStep(deps, step, options = {}) {
416
419
  const selector = typeof getParams === 'string' ? getParams : getParams.selector;
417
420
  stepResult.output = await formValidator.getFormState(selector);
418
421
  stepResult.output.mode = 'value';
422
+ } else if (mode === 'attributes') {
423
+ // Attributes extraction — return all HTML attributes of the element
424
+ const selector = getParams.selector || getParams.ref;
425
+ const attrResult = await pageController.evaluateInFrame(`
426
+ (function() {
427
+ const el = document.querySelector(${JSON.stringify(selector)});
428
+ if (!el) return { error: 'Element not found: ' + ${JSON.stringify(selector)} };
429
+ const attrs = {};
430
+ for (const attr of el.attributes) {
431
+ attrs[attr.name] = attr.value;
432
+ }
433
+ return { attributes: attrs };
434
+ })()
435
+ `, { returnByValue: true });
436
+ const data = attrResult.result?.value;
437
+ if (data?.error) {
438
+ throw new Error(data.error);
439
+ }
440
+ stepResult.output = { attributes: data?.attributes || {}, mode: 'attributes' };
419
441
  } else {
420
- // Default: text/attributes extraction use extract
442
+ // Default: text extraction (with auto table/list detection)
421
443
  stepResult.output = await executeExtract(deps, getParams);
422
- stepResult.output.mode = mode || 'text';
444
+ stepResult.output.mode = 'text';
423
445
  }
424
446
  } else if (step.selectOption !== undefined) {
425
447
  stepResult.action = 'selectOption';
@@ -564,9 +586,6 @@ export async function executeStep(deps, step, options = {}) {
564
586
  return stepResult;
565
587
  }
566
588
 
567
- // Import executeAssert from execute-form.js
568
- import { executeAssert } from './execute-form.js';
569
-
570
589
  /**
571
590
  * Run an array of test steps
572
591
  * @param {Object} deps - Dependencies
@@ -57,7 +57,8 @@ export const STEP_TYPES = {
57
57
  READ_SITE_PROFILE: 'readSiteProfile',
58
58
  SWITCH_TAB: 'switchTab',
59
59
  GET_URL: 'getUrl',
60
- GET_TITLE: 'getTitle'
60
+ GET_TITLE: 'getTitle',
61
+ UPLOAD: 'upload'
61
62
  };
62
63
 
63
64
  /**
@@ -152,7 +153,7 @@ export const STEP_CONFIG = {
152
153
  errors.push('wait hidden must be a boolean');
153
154
  }
154
155
  } else {
155
- errors.push('wait requires a number (ms), selector string, or params object');
156
+ errors.push('wait requires a selector string or params object with selector/text/textRegex/urlContains');
156
157
  }
157
158
  return errors;
158
159
  },
@@ -309,7 +310,8 @@ export const STEP_CONFIG = {
309
310
  if (!params || typeof params !== 'object') {
310
311
  errors.push('queryAll requires an object mapping names to selectors');
311
312
  } else {
312
- const entries = Object.entries(params);
313
+ const hookKeys = new Set(['readyWhen', 'settledWhen', 'observe', 'timeout', 'optional']);
314
+ const entries = Object.entries(params).filter(([name]) => !hookKeys.has(name));
313
315
  if (entries.length === 0) {
314
316
  errors.push('queryAll requires at least one query');
315
317
  }
@@ -341,13 +343,14 @@ export const STEP_CONFIG = {
341
343
  validate: (params) => {
342
344
  const errors = [];
343
345
  if (typeof params === 'string') {
344
- if (!['top', 'bottom', 'up', 'down'].includes(params)) {
345
- errors.push(`Invalid scroll direction: ${params}. Must be one of: top, bottom, up, down`);
346
+ if (params.length === 0) {
347
+ errors.push('scroll requires a non-empty string');
346
348
  }
349
+ // Accepts direction ("top","bottom","up","down"), CSS selector, or ref string
347
350
  } else if (params && typeof params === 'object') {
348
- // selector, x, y, deltaX, deltaY are all valid
351
+ // selector, ref, x, y, deltaX, deltaY are all valid
349
352
  } else {
350
- errors.push('scroll requires direction string or params object');
353
+ errors.push('scroll requires a direction/selector string or params object');
351
354
  }
352
355
  return errors;
353
356
  },
@@ -358,7 +361,7 @@ export const STEP_CONFIG = {
358
361
  [STEP_TYPES.CONSOLE]: {
359
362
  validate: (params) => {
360
363
  const errors = [];
361
- if (params !== true && params !== false && typeof params !== 'object') {
364
+ if (params !== true && params !== false && (typeof params !== 'object' || params === null)) {
362
365
  errors.push('console requires true, false, or params object');
363
366
  }
364
367
  return errors;
@@ -808,6 +811,10 @@ export const STEP_CONFIG = {
808
811
  } else if (typeof params === 'object' && params !== null) {
809
812
  if (!params.refs && !params.ref) {
810
813
  errors.push('getBox requires ref or refs');
814
+ } else if (params.ref && typeof params.ref !== 'string') {
815
+ errors.push('getBox ref must be a string');
816
+ } else if (params.refs && !Array.isArray(params.refs)) {
817
+ errors.push('getBox refs must be an array');
811
818
  }
812
819
  } else {
813
820
  errors.push('getBox requires a ref string, array of refs, or options object');
@@ -1001,6 +1008,48 @@ export const STEP_CONFIG = {
1001
1008
  },
1002
1009
  isVisual: false,
1003
1010
  hooks: []
1011
+ },
1012
+
1013
+ [STEP_TYPES.UPLOAD]: {
1014
+ validate: (params) => {
1015
+ const errors = [];
1016
+ if (typeof params === 'string') {
1017
+ // Shape 1: single file path
1018
+ if (params.length === 0) {
1019
+ errors.push('upload requires a non-empty file path');
1020
+ }
1021
+ } else if (Array.isArray(params)) {
1022
+ // Shape 2: array of file paths
1023
+ if (params.length === 0) {
1024
+ errors.push('upload requires at least one file path');
1025
+ }
1026
+ for (const p of params) {
1027
+ if (typeof p !== 'string' || p.length === 0) {
1028
+ errors.push('upload file paths must be non-empty strings');
1029
+ break;
1030
+ }
1031
+ }
1032
+ } else if (params && typeof params === 'object') {
1033
+ // Shape 3: object with selector/ref + files
1034
+ if (!params.selector && !params.ref) {
1035
+ errors.push('upload object requires selector or ref to target a file input');
1036
+ }
1037
+ if (!params.files && !params.file) {
1038
+ errors.push('upload object requires files (array) or file (string)');
1039
+ }
1040
+ if (params.files && !Array.isArray(params.files)) {
1041
+ errors.push('upload files must be an array of file path strings');
1042
+ }
1043
+ if (params.file && typeof params.file !== 'string') {
1044
+ errors.push('upload file must be a string');
1045
+ }
1046
+ } else {
1047
+ errors.push('upload requires a file path string, array of paths, or object with selector/ref and files');
1048
+ }
1049
+ return errors;
1050
+ },
1051
+ isVisual: true,
1052
+ hooks: ['readyWhen', 'settledWhen', 'observe']
1004
1053
  }
1005
1054
  };
1006
1055
 
@@ -10,7 +10,7 @@
10
10
  * - ./step-registry.js: getAllStepTypes, getStepConfig, validateHooks
11
11
  */
12
12
 
13
- import { getAllStepTypes, getStepConfig, validateHooks } from './step-registry.js';
13
+ import { getAllStepTypes, getStepConfig, validateHooks, stepSupportsHooks } from './step-registry.js';
14
14
 
15
15
  /**
16
16
  * Validate a single step definition
@@ -53,8 +53,18 @@ export function validateStepInternal(step) {
53
53
  errors.push(...stepErrors);
54
54
 
55
55
  // Validate hooks on action steps (readyWhen, settledWhen, observe)
56
- const hookErrors = validateHooks(params);
57
- errors.push(...hookErrors);
56
+ if (stepSupportsHooks(action)) {
57
+ const hookErrors = validateHooks(params);
58
+ errors.push(...hookErrors);
59
+ } else if (params && typeof params === 'object' && params !== null) {
60
+ // Reject hook keys on steps that don't support them
61
+ const hookKeys = ['readyWhen', 'settledWhen', 'observe'];
62
+ for (const key of hookKeys) {
63
+ if (params[key] !== undefined) {
64
+ errors.push(`${action} does not support the '${key}' hook`);
65
+ }
66
+ }
67
+ }
58
68
 
59
69
  return errors;
60
70
  }