cdp-skill 1.0.7 → 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 +198 -1344
  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 +268 -68
  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 +34 -143
  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 +256 -95
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -740
  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 +34 -736
  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
@@ -31,17 +31,59 @@ import {
31
31
  * @param {Object} elementLocator - Element locator instance
32
32
  * @param {Object} inputEmulator - Input emulator instance
33
33
  * @param {Object} [ariaSnapshot] - Optional ARIA snapshot instance
34
+ * @param {Object} [options] - Configuration options
35
+ * @param {Function} [options.getFrameContext] - Returns contextId when in a non-main frame
34
36
  * @returns {Object} Fill executor interface
35
37
  */
36
- export function createFillExecutor(session, elementLocator, inputEmulator, ariaSnapshot = null) {
38
+ export function createFillExecutor(session, elementLocator, inputEmulator, ariaSnapshot = null, options = {}) {
37
39
  if (!session) throw new Error('CDP session is required');
38
40
  if (!elementLocator) throw new Error('Element locator is required');
39
41
  if (!inputEmulator) throw new Error('Input emulator is required');
40
42
 
43
+ const getFrameContext = options.getFrameContext || null;
41
44
  const actionabilityChecker = createActionabilityChecker(session);
42
45
  const elementValidator = createElementValidator(session);
43
46
  const reactInputFiller = createReactInputFiller(session);
44
47
 
48
+ /**
49
+ * Build Runtime.evaluate params, injecting contextId when in an iframe.
50
+ */
51
+ function evalParams(expression, returnByValue = false) {
52
+ const params = { expression, returnByValue };
53
+ if (getFrameContext) {
54
+ const contextId = getFrameContext();
55
+ if (contextId) params.contextId = contextId;
56
+ }
57
+ return params;
58
+ }
59
+
60
+ /**
61
+ * Select all and fill with value, handling the empty-string case.
62
+ * When value is "" and clear is true, presses Delete after selectAll
63
+ * to actually remove the selected content (insertText("") is a no-op).
64
+ */
65
+ async function selectAndFill(value, clear) {
66
+ if (clear) {
67
+ await inputEmulator.selectAll();
68
+ }
69
+ if (value === '' && clear) {
70
+ // insertText("") is a no-op in CDP — press Delete to remove selected text
71
+ await inputEmulator.press('Delete');
72
+ // Dispatch input/change events so frameworks (React, Vue, etc.) react to the clear
73
+ await session.send('Runtime.evaluate', evalParams(`
74
+ (function() {
75
+ const el = document.activeElement;
76
+ if (el) {
77
+ el.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
78
+ el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
79
+ }
80
+ })()
81
+ `, true));
82
+ } else {
83
+ await inputEmulator.insertText(String(value));
84
+ }
85
+ }
86
+
45
87
  async function fillByRef(ref, value, opts = {}) {
46
88
  const { clear = true, react = false } = opts;
47
89
 
@@ -62,13 +104,12 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
62
104
  throw new Error(`Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`);
63
105
  }
64
106
 
65
- const elementResult = await session.send('Runtime.evaluate', {
66
- expression: `(function() {
107
+ const elementResult = await session.send('Runtime.evaluate',
108
+ evalParams(`(function() {
67
109
  const el = window.__ariaRefs && window.__ariaRefs.get(${JSON.stringify(ref)});
68
110
  return el;
69
- })()`,
70
- returnByValue: false
71
- });
111
+ })()`, false)
112
+ );
72
113
 
73
114
  if (!elementResult.result.objectId) {
74
115
  throw elementNotFoundError(`ref:${ref}`, 0);
@@ -106,11 +147,7 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
106
147
  functionDeclaration: `function() { this.focus(); }`
107
148
  });
108
149
 
109
- if (clear) {
110
- await inputEmulator.selectAll();
111
- }
112
-
113
- await inputEmulator.insertText(String(value));
150
+ await selectAndFill(value, clear);
114
151
 
115
152
  return { filled: true, ref, method: 'insertText' };
116
153
  } finally {
@@ -153,11 +190,7 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
153
190
  functionDeclaration: `function() { this.focus(); }`
154
191
  });
155
192
 
156
- if (clear) {
157
- await inputEmulator.selectAll();
158
- }
159
-
160
- await inputEmulator.insertText(String(value));
193
+ await selectAndFill(value, clear);
161
194
 
162
195
  return { filled: true, selector, method: 'insertText' };
163
196
  } catch (e) {
@@ -277,10 +310,9 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
277
310
 
278
311
  let result;
279
312
  try {
280
- result = await session.send('Runtime.evaluate', {
281
- expression,
282
- returnByValue: false
283
- });
313
+ result = await session.send('Runtime.evaluate',
314
+ evalParams(expression, false)
315
+ );
284
316
  } catch (error) {
285
317
  throw connectionError(error.message, 'Runtime.evaluate (findInputByLabel)');
286
318
  }
@@ -383,11 +415,7 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
383
415
  functionDeclaration: `function() { this.focus(); }`
384
416
  });
385
417
 
386
- if (clear) {
387
- await inputEmulator.selectAll();
388
- }
389
-
390
- await inputEmulator.insertText(String(value));
418
+ await selectAndFill(value, clear);
391
419
 
392
420
  return { filled: true, label, method: 'insertText', foundBy: foundMethod };
393
421
  } catch (e) {
@@ -430,7 +458,7 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
430
458
 
431
459
  async function executeBatch(params) {
432
460
  if (!params || typeof params !== 'object') {
433
- throw new Error('fillForm requires an object mapping selectors to values');
461
+ throw new Error('fill batch requires an object mapping selectors to values');
434
462
  }
435
463
 
436
464
  // Support both formats:
@@ -450,7 +478,7 @@ export function createFillExecutor(session, elementLocator, inputEmulator, ariaS
450
478
 
451
479
  const entries = Object.entries(fields);
452
480
  if (entries.length === 0) {
453
- throw new Error('fillForm requires at least one field');
481
+ throw new Error('fill batch requires at least one field');
454
482
  }
455
483
 
456
484
  const results = [];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Dialog Handler Module
3
+ * Handles JavaScript alerts, confirms, and prompts
4
+ *
5
+ * PUBLIC EXPORTS:
6
+ * - createDialogHandler(session) - Factory for dialog handler
7
+ *
8
+ * @module cdp-skill/page/dialog-handler
9
+ */
10
+
11
+ /**
12
+ * Create a dialog handler for JavaScript dialogs
13
+ * @param {import('../types.js').CDPSession} session - CDP session
14
+ * @returns {Object} Dialog handler interface
15
+ */
16
+ export function createDialogHandler(session) {
17
+ let dialogCallback = null;
18
+ let boundHandler = null;
19
+ const responseQueue = [];
20
+
21
+ function onDialogOpening(params) {
22
+ const { type, message, defaultPrompt } = params;
23
+
24
+ // Default behavior: accept all dialogs
25
+ let accept = true;
26
+ let promptText = undefined;
27
+
28
+ // Check if there's a queued response
29
+ if (responseQueue.length > 0) {
30
+ const queued = responseQueue.shift();
31
+ accept = queued.accept !== false;
32
+ promptText = queued.promptText;
33
+ } else if (dialogCallback) {
34
+ // If custom callback is set, use it
35
+ const result = dialogCallback({ type, message, defaultPrompt });
36
+ accept = result.accept !== false;
37
+ promptText = result.promptText;
38
+ } else {
39
+ // Auto-accept with reasonable defaults for prompts
40
+ if (type === 'prompt') {
41
+ // Use defaultPrompt if available
42
+ // Otherwise, for test automation purposes, use a reasonable default
43
+ if (defaultPrompt !== undefined && defaultPrompt.length > 0) {
44
+ promptText = defaultPrompt;
45
+ } else if (message && message.toLowerCase().includes('prompt')) {
46
+ // For prompt dialogs asking for input, use a test value
47
+ promptText = 'Hello CDP';
48
+ } else {
49
+ promptText = '';
50
+ }
51
+ }
52
+ }
53
+
54
+ // Handle the dialog
55
+ session.send('Page.handleJavaScriptDialog', {
56
+ accept,
57
+ promptText
58
+ }).catch(err => {
59
+ // Ignore errors - dialog may have been already handled
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Enable dialog handling
65
+ * @param {Function} [callback] - Optional callback to customize dialog handling
66
+ * @returns {Promise<void>}
67
+ */
68
+ async function enable(callback = null) {
69
+ dialogCallback = callback;
70
+
71
+ if (!boundHandler) {
72
+ boundHandler = onDialogOpening;
73
+ session.on('Page.javascriptDialogOpening', boundHandler);
74
+ }
75
+
76
+ // Enable page domain if not already enabled
77
+ try {
78
+ await session.send('Page.enable');
79
+ } catch (err) {
80
+ // Ignore if already enabled
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Disable dialog handling
86
+ * @returns {Promise<void>}
87
+ */
88
+ async function disable() {
89
+ if (boundHandler) {
90
+ session.off('Page.javascriptDialogOpening', boundHandler);
91
+ boundHandler = null;
92
+ }
93
+ dialogCallback = null;
94
+ }
95
+
96
+ /**
97
+ * Set a custom dialog handler
98
+ * @param {Function} callback - Callback({type, message, defaultPrompt}) => {accept, promptText}
99
+ */
100
+ function setHandler(callback) {
101
+ dialogCallback = callback;
102
+ }
103
+
104
+ /**
105
+ * Queue a response for the next dialog
106
+ * @param {boolean} accept - Whether to accept the dialog
107
+ * @param {string} [promptText] - Text to enter for prompts
108
+ */
109
+ function queueResponse(accept, promptText) {
110
+ responseQueue.push({ accept, promptText });
111
+ }
112
+
113
+ return {
114
+ enable,
115
+ disable,
116
+ setHandler,
117
+ queueResponse
118
+ };
119
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from '../utils.js';
22
22
 
23
23
  import { TIMEOUTS } from '../constants.js';
24
+ import { createDialogHandler } from './dialog-handler.js';
24
25
 
25
26
  const MAX_TIMEOUT = TIMEOUTS.MAX;
26
27
 
@@ -39,9 +40,13 @@ export const WaitCondition = Object.freeze({
39
40
  /**
40
41
  * Create a page controller for navigation and lifecycle events
41
42
  * @param {import('../types.js').CDPSession} cdpClient - CDP client with send/on/off methods
43
+ * @param {Object} [options] - Options
44
+ * @param {function(Object): void} [options.onFrameChanged] - Called when frame context changes (for persistence)
45
+ * @param {function(): Object|null} [options.getSavedFrameState] - Returns saved frame state (for restoration)
42
46
  * @returns {Object} Page controller interface
43
47
  */
44
- export function createPageController(cdpClient) {
48
+ export function createPageController(cdpClient, options = {}) {
49
+ const { onFrameChanged, getSavedFrameState } = options;
45
50
  let mainFrameId = null;
46
51
  let currentFrameId = null;
47
52
  let currentExecutionContextId = null;
@@ -54,6 +59,7 @@ export function createPageController(cdpClient) {
54
59
  const networkIdleDelay = 500;
55
60
  let navigationInProgress = false;
56
61
  let currentNavigationAbort = null;
62
+ const dialogHandler = createDialogHandler(cdpClient);
57
63
  let currentNavigationUrl = null;
58
64
  let pageCrashed = false;
59
65
  const crashWaiters = new Set();
@@ -221,6 +227,54 @@ export function createPageController(cdpClient) {
221
227
  });
222
228
  }
223
229
 
230
+ /**
231
+ * Best-effort network settle — waits briefly for network to quiet down.
232
+ * Unlike waitForNetworkQuiet, this NEVER throws on timeout. It resolves
233
+ * silently so callers (snapshot, post-navigation) don't fail on sites
234
+ * with persistent connections or long-polling.
235
+ *
236
+ * @param {Object} [options]
237
+ * @param {number} [options.timeout=2000] - Max time to wait
238
+ * @param {number} [options.idleTime=300] - Idle window to consider settled
239
+ * @returns {Promise<{settled: boolean, pendingCount: number}>}
240
+ */
241
+ function waitForNetworkSettle(options = {}) {
242
+ const { timeout = 2000, idleTime = 300 } = options;
243
+
244
+ return new Promise((resolve) => {
245
+ // Already idle long enough?
246
+ if (pendingRequests.size === 0 && (Date.now() - lastNetworkActivity) >= idleTime) {
247
+ resolve({ settled: true, pendingCount: 0 });
248
+ return;
249
+ }
250
+
251
+ let resolved = false;
252
+ let timeoutId = null;
253
+ let checkInterval = null;
254
+
255
+ const finish = (settled) => {
256
+ if (resolved) return;
257
+ resolved = true;
258
+ if (timeoutId) clearTimeout(timeoutId);
259
+ if (checkInterval) clearInterval(checkInterval);
260
+ networkIdleWaiters.delete(waiter);
261
+ resolve({ settled, pendingCount: pendingRequests.size });
262
+ };
263
+
264
+ const waiter = () => finish(true);
265
+ networkIdleWaiters.add(waiter);
266
+
267
+ timeoutId = setTimeout(() => finish(false), timeout);
268
+
269
+ checkInterval = setInterval(() => {
270
+ if (resolved) { clearInterval(checkInterval); return; }
271
+ if (pendingRequests.size === 0 && (Date.now() - lastNetworkActivity) >= idleTime) {
272
+ finish(true);
273
+ }
274
+ }, 50);
275
+ });
276
+ }
277
+
224
278
  /**
225
279
  * Get current network status
226
280
  * @returns {{pendingCount: number, totalRequests: number, lastActivity: number, isIdle: boolean}}
@@ -360,6 +414,9 @@ export function createPageController(cdpClient) {
360
414
  cdpClient.send('Inspector.enable')
361
415
  ]);
362
416
 
417
+ // Enable dialog handling for JavaScript alerts, confirms, and prompts
418
+ await dialogHandler.enable();
419
+
363
420
  const { frameTree } = await cdpClient.send('Page.getFrameTree');
364
421
  mainFrameId = frameTree.frame.id;
365
422
  currentFrameId = mainFrameId;
@@ -388,6 +445,56 @@ export function createPageController(cdpClient) {
388
445
  addListener('Network.loadingFinished', onRequestFinished);
389
446
  addListener('Network.loadingFailed', onRequestFinished);
390
447
  addListener('Inspector.targetCrashed', onTargetCrashed);
448
+
449
+ // Restore persisted frame context from a previous CLI invocation
450
+ if (getSavedFrameState) {
451
+ const saved = getSavedFrameState();
452
+ if (saved && saved.frameId && saved.frameId !== mainFrameId) {
453
+ // Verify the saved frame still exists in the frame tree
454
+ function findAllFrames(node) {
455
+ const frames = [node];
456
+ if (node.childFrames) {
457
+ for (const child of node.childFrames) {
458
+ frames.push(...findAllFrames(child));
459
+ }
460
+ }
461
+ return frames;
462
+ }
463
+ const allFrames = findAllFrames(frameTree);
464
+ const savedFrame = allFrames.find(f => f.frame.id === saved.frameId);
465
+
466
+ if (savedFrame) {
467
+ currentFrameId = saved.frameId;
468
+ // Try to use the saved contextId if it's still in our context map
469
+ const knownContextId = frameExecutionContexts.get(saved.frameId);
470
+ if (knownContextId) {
471
+ currentExecutionContextId = knownContextId;
472
+ } else {
473
+ // Create a fresh isolated world for this frame
474
+ try {
475
+ const { executionContextId } = await cdpClient.send('Page.createIsolatedWorld', {
476
+ frameId: saved.frameId,
477
+ worldName: 'cdp-automation'
478
+ });
479
+ currentExecutionContextId = executionContextId;
480
+ frameExecutionContexts.set(saved.frameId, executionContextId);
481
+ } catch {
482
+ // Frame context restoration failed — fall back to main frame
483
+ currentFrameId = mainFrameId;
484
+ currentExecutionContextId = frameExecutionContexts.get(mainFrameId) || null;
485
+ if (onFrameChanged) {
486
+ onFrameChanged({ frameId: null, contextId: null });
487
+ }
488
+ }
489
+ }
490
+ } else {
491
+ // Frame no longer exists — clear stale state
492
+ if (onFrameChanged) {
493
+ onFrameChanged({ frameId: null, contextId: null });
494
+ }
495
+ }
496
+ }
497
+ }
391
498
  }
392
499
 
393
500
  /**
@@ -670,7 +777,13 @@ export function createPageController(cdpClient) {
670
777
  }
671
778
 
672
779
  /**
673
- * Get frame tree with all iframes
780
+ * Get frame tree with all iframes, including cross-origin ones.
781
+ *
782
+ * Page.getFrameTree only returns same-origin frames. Cross-origin iframes
783
+ * live in separate renderer processes and are invisible to that API.
784
+ * We supplement the CDP tree by querying the DOM for all <iframe> elements
785
+ * and merging any that aren't already represented.
786
+ *
674
787
  * @returns {Promise<{mainFrameId: string, currentFrameId: string, frames: Array}>}
675
788
  */
676
789
  async function getFrameTree() {
@@ -694,10 +807,60 @@ export function createPageController(cdpClient) {
694
807
  return frames;
695
808
  }
696
809
 
810
+ const frames = flattenFrames(frameTree);
811
+
812
+ // Discover cross-origin iframes via DOM query
813
+ try {
814
+ const domResult = await cdpClient.send('Runtime.evaluate', {
815
+ expression: `
816
+ (function() {
817
+ const iframes = document.querySelectorAll('iframe');
818
+ return Array.from(iframes).map(function(el, i) {
819
+ var src = el.src || el.getAttribute('src') || '';
820
+ var name = el.name || el.id || '';
821
+ var crossOrigin = false;
822
+ try { var _d = el.contentDocument; } catch(e) { crossOrigin = true; }
823
+ if (!el.contentDocument) crossOrigin = true;
824
+ return { index: i, src: src, name: name, crossOrigin: crossOrigin };
825
+ });
826
+ })()
827
+ `,
828
+ returnByValue: true
829
+ });
830
+
831
+ const domIframes = domResult.result?.value;
832
+ if (Array.isArray(domIframes)) {
833
+ for (const iframe of domIframes) {
834
+ if (!iframe.crossOrigin) continue;
835
+
836
+ // Check if this iframe is already in the CDP frame tree
837
+ const alreadyListed = frames.some(f =>
838
+ f.parentId && (
839
+ (iframe.src && f.url === iframe.src) ||
840
+ (iframe.name && f.name === iframe.name)
841
+ )
842
+ );
843
+
844
+ if (!alreadyListed) {
845
+ frames.push({
846
+ frameId: `cross-origin-${iframe.index}`,
847
+ url: iframe.src || 'about:blank',
848
+ name: iframe.name || null,
849
+ parentId: mainFrameId,
850
+ depth: 1,
851
+ crossOrigin: true
852
+ });
853
+ }
854
+ }
855
+ }
856
+ } catch {
857
+ // DOM query failed — return CDP-only tree
858
+ }
859
+
697
860
  return {
698
861
  mainFrameId,
699
862
  currentFrameId,
700
- frames: flattenFrames(frameTree)
863
+ frames
701
864
  };
702
865
  }
703
866
 
@@ -830,6 +993,11 @@ export function createPageController(cdpClient) {
830
993
  result.warning = warning;
831
994
  }
832
995
 
996
+ // Persist frame state across CLI invocations
997
+ if (onFrameChanged) {
998
+ onFrameChanged({ frameId: currentFrameId, contextId: currentExecutionContextId });
999
+ }
1000
+
833
1001
  return result;
834
1002
  }
835
1003
 
@@ -841,6 +1009,11 @@ export function createPageController(cdpClient) {
841
1009
  currentFrameId = mainFrameId;
842
1010
  currentExecutionContextId = frameExecutionContexts.get(mainFrameId) || null;
843
1011
 
1012
+ // Clear persisted frame state (back to main)
1013
+ if (onFrameChanged) {
1014
+ onFrameChanged({ frameId: null, contextId: null });
1015
+ }
1016
+
844
1017
  const { frameTree } = await cdpClient.send('Page.getFrameTree');
845
1018
 
846
1019
  return {
@@ -850,6 +1023,18 @@ export function createPageController(cdpClient) {
850
1023
  };
851
1024
  }
852
1025
 
1026
+ /**
1027
+ * Get the current frame execution context ID (if in a non-main frame).
1028
+ * Used for dependency injection into modules that need frame-aware evaluation.
1029
+ * @returns {number|null} contextId for current frame, or null if in main frame
1030
+ */
1031
+ function getFrameContext() {
1032
+ if (currentFrameId !== mainFrameId && currentExecutionContextId) {
1033
+ return currentExecutionContextId;
1034
+ }
1035
+ return null;
1036
+ }
1037
+
853
1038
  /**
854
1039
  * Execute code in the current frame context
855
1040
  * @param {string} expression - JavaScript expression
@@ -1106,8 +1291,10 @@ export function createPageController(cdpClient) {
1106
1291
  waitForNavigationEvent,
1107
1292
  withNavigation,
1108
1293
  waitForNetworkQuiet,
1294
+ waitForNetworkSettle,
1109
1295
  getNetworkStatus,
1110
1296
  searchAllFrames,
1297
+ getFrameContext,
1111
1298
  dispose,
1112
1299
  get mainFrameId() { return mainFrameId; },
1113
1300
  get currentFrameId() { return currentFrameId; },