cdp-skill 1.0.8 → 1.0.15

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 (51) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +157 -241
  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 +251 -50
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +246 -69
  12. package/src/dom/LazyResolver.js +634 -0
  13. package/src/dom/click-executor.js +366 -94
  14. package/src/dom/element-locator.js +34 -25
  15. package/src/dom/fill-executor.js +83 -50
  16. package/src/dom/index.js +3 -0
  17. package/src/page/dialog-handler.js +119 -0
  18. package/src/page/page-controller.js +236 -3
  19. package/src/runner/context-helpers.js +33 -55
  20. package/src/runner/execute-dynamic.js +8 -7
  21. package/src/runner/execute-form.js +11 -11
  22. package/src/runner/execute-input.js +2 -2
  23. package/src/runner/execute-interaction.js +105 -126
  24. package/src/runner/execute-navigation.js +14 -29
  25. package/src/runner/execute-query.js +17 -11
  26. package/src/runner/step-executors.js +225 -84
  27. package/src/runner/step-registry.js +1064 -0
  28. package/src/runner/step-validator.js +16 -754
  29. package/src/tests/Aria.test.js +1025 -0
  30. package/src/tests/ClickExecutor.test.js +170 -50
  31. package/src/tests/ContextHelpers.test.js +41 -30
  32. package/src/tests/ExecuteBrowser.test.js +572 -0
  33. package/src/tests/ExecuteDynamic.test.js +2 -457
  34. package/src/tests/ExecuteForm.test.js +700 -0
  35. package/src/tests/ExecuteInput.test.js +540 -0
  36. package/src/tests/ExecuteInteraction.test.js +319 -0
  37. package/src/tests/ExecuteQuery.test.js +820 -0
  38. package/src/tests/FillExecutor.test.js +89 -37
  39. package/src/tests/LazyResolver.test.js +383 -0
  40. package/src/tests/StepValidator.test.js +224 -78
  41. package/src/tests/TestRunner.test.js +38 -27
  42. package/src/tests/integration.test.js +2 -1
  43. package/src/types.js +9 -9
  44. package/src/utils/backoff.js +118 -0
  45. package/src/utils/cdp-helpers.js +130 -0
  46. package/src/utils/devices.js +140 -0
  47. package/src/utils/errors.js +242 -0
  48. package/src/utils/index.js +65 -0
  49. package/src/utils/temp.js +75 -0
  50. package/src/utils/validators.js +433 -0
  51. package/src/utils.js +14 -1142
@@ -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,63 @@ 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
+
1038
+ /**
1039
+ * Get the current frame identifier for ref generation.
1040
+ * Returns 'f0' for main frame, 'f1', 'f2', etc. for iframes by index.
1041
+ * Uses frame name if available for better stability.
1042
+ * @returns {Promise<string>} Frame identifier (e.g., 'f0', 'f1', 'f[frame-name]')
1043
+ */
1044
+ async function getFrameIdentifier() {
1045
+ // Main frame is always f0
1046
+ if (currentFrameId === mainFrameId || !currentFrameId) {
1047
+ return 'f0';
1048
+ }
1049
+
1050
+ // Get frame tree to find index or name
1051
+ const { frameTree } = await cdpClient.send('Page.getFrameTree');
1052
+
1053
+ function findAllChildFrames(node) {
1054
+ const frames = [];
1055
+ if (node.childFrames) {
1056
+ for (const child of node.childFrames) {
1057
+ frames.push(child);
1058
+ frames.push(...findAllChildFrames(child));
1059
+ }
1060
+ }
1061
+ return frames;
1062
+ }
1063
+
1064
+ const childFrames = findAllChildFrames(frameTree);
1065
+
1066
+ // Find current frame
1067
+ for (let i = 0; i < childFrames.length; i++) {
1068
+ if (childFrames[i].frame.id === currentFrameId) {
1069
+ // Prefer name if available (more stable than index)
1070
+ const frameName = childFrames[i].frame.name;
1071
+ if (frameName) {
1072
+ return `f[${frameName}]`;
1073
+ }
1074
+ // Fall back to index (1-based for iframes)
1075
+ return `f${i + 1}`;
1076
+ }
1077
+ }
1078
+
1079
+ // Fallback: unknown frame, use hash of frameId
1080
+ return `f[${currentFrameId.substring(0, 8)}]`;
1081
+ }
1082
+
853
1083
  /**
854
1084
  * Execute code in the current frame context
855
1085
  * @param {string} expression - JavaScript expression
@@ -1106,8 +1336,11 @@ export function createPageController(cdpClient) {
1106
1336
  waitForNavigationEvent,
1107
1337
  withNavigation,
1108
1338
  waitForNetworkQuiet,
1339
+ waitForNetworkSettle,
1109
1340
  getNetworkStatus,
1110
1341
  searchAllFrames,
1342
+ getFrameContext,
1343
+ getFrameIdentifier,
1111
1344
  dispose,
1112
1345
  get mainFrameId() { return mainFrameId; },
1113
1346
  get currentFrameId() { return currentFrameId; },
@@ -6,30 +6,18 @@
6
6
  * - buildActionContext(action, params, context) → string - Describes what action was taken
7
7
  * - buildCommandContext(steps) → string - Summarizes multi-step commands
8
8
  * - captureFailureContext(deps) → Object - Gathers debug info on failure
9
- * - STEP_TYPES - Array of valid step type names
10
- * - VISUAL_ACTIONS - Actions that trigger auto-screenshot
9
+ * - STEP_TYPES - Array of valid step type names (from registry)
10
+ * - VISUAL_ACTIONS - Actions that trigger auto-screenshot (from registry)
11
11
  *
12
- * DEPENDENCIES: None (pure functions)
12
+ * DEPENDENCIES:
13
+ * - ./step-registry.js: getAllStepTypes, getVisualActions
13
14
  */
14
15
 
15
- export const STEP_TYPES = [
16
- 'goto', 'wait', 'click', 'fill', 'fillForm', 'press', 'query', 'queryAll',
17
- 'inspect', 'scroll', 'console', 'pdf', 'eval', 'snapshot', 'snapshotSearch',
18
- 'hover', 'viewport', 'cookies', 'back', 'forward', 'waitForNavigation', 'listTabs',
19
- 'closeTab', 'openTab', 'type', 'select', 'selectOption', 'validate', 'submit',
20
- 'assert', 'switchToFrame', 'switchToMainFrame', 'listFrames', 'drag', 'formState',
21
- 'extract', 'getDom', 'getBox', 'fillActive', 'refAt', 'elementsAt', 'elementsNear',
22
- 'reload', 'pageFunction', 'poll', 'pipeline', 'writeSiteProfile', 'readSiteProfile'
23
- ];
16
+ import { getAllStepTypes, getVisualActions } from './step-registry.js';
24
17
 
25
- // Visual actions that trigger auto-screenshot
26
- // Actions that should capture a screenshot - anything that interacts with or queries the visible page
27
- export const VISUAL_ACTIONS = [
28
- 'goto', 'reload', 'click', 'fill', 'fillForm', 'type', 'hover', 'press', 'scroll', 'wait', // interactions
29
- 'snapshot', 'snapshotSearch', 'query', 'queryAll', 'inspect', 'eval', 'extract', 'formState', // queries
30
- 'drag', 'select', 'selectOption', 'validate', 'submit', 'assert', // other page interactions
31
- 'openTab' // navigation actions - behave like goto for auto-snapshot
32
- ];
18
+ // Re-export from registry for backwards compatibility
19
+ export const STEP_TYPES = getAllStepTypes();
20
+ export const VISUAL_ACTIONS = getVisualActions();
33
21
 
34
22
  /**
35
23
  * Build action context string for diff summary
@@ -59,8 +47,19 @@ export function buildActionContext(action, params, context) {
59
47
  case 'hover': {
60
48
  if (typeof params === 'string') return `Hovered over ${params}`;
61
49
  if (params?.selector) return `Hovered over ${params.selector}`;
50
+ if (params?.ref) return `Hovered over [ref=${params.ref}]`;
51
+ if (params?.text) return `Hovered over "${params.text}"`;
52
+ if (typeof params?.x === 'number' && typeof params?.y === 'number') return `Hovered over (${params.x}, ${params.y})`;
62
53
  return 'Hovered over element';
63
54
  }
55
+ case 'frame': {
56
+ if (params === 'top') return 'Switched to main frame';
57
+ if (typeof params === 'string') return `Switched to frame ${params}`;
58
+ if (typeof params === 'number') return `Switched to frame index ${params}`;
59
+ if (params?.list) return 'Listed frames';
60
+ if (params?.name) return `Switched to frame "${params.name}"`;
61
+ return 'Frame operation';
62
+ }
64
63
  case 'fill':
65
64
  case 'type': {
66
65
  if (params?.selector) return `Typed in ${params.selector}`;
@@ -93,8 +92,8 @@ export function buildCommandContext(steps) {
93
92
  if (actions.includes('hover')) return 'Hovered';
94
93
  if (actions.includes('fill') || actions.includes('type')) return 'Typed';
95
94
  if (actions.includes('press')) return 'Pressed key';
96
- if (actions.includes('goto') || actions.includes('openTab')) return 'Navigated';
97
- if (actions.includes('select')) return 'Selected';
95
+ if (actions.includes('goto') || actions.includes('newTab')) return 'Navigated';
96
+ if (actions.includes('selectText')) return 'Selected';
98
97
  if (actions.includes('drag')) return 'Dragged';
99
98
 
100
99
  // Default: list the actions
@@ -120,10 +119,7 @@ export async function captureFailureContext(deps, options = {}) {
120
119
 
121
120
  try {
122
121
  // Get page title
123
- const titleResult = await pageController.session.send('Runtime.evaluate', {
124
- expression: 'document.title',
125
- returnByValue: true
126
- });
122
+ const titleResult = await pageController.evaluateInFrame('document.title');
127
123
  context.title = titleResult.result.value || '';
128
124
  } catch {
129
125
  context.title = null;
@@ -131,10 +127,7 @@ export async function captureFailureContext(deps, options = {}) {
131
127
 
132
128
  try {
133
129
  // Get current URL
134
- const urlResult = await pageController.session.send('Runtime.evaluate', {
135
- expression: 'window.location.href',
136
- returnByValue: true
137
- });
130
+ const urlResult = await pageController.evaluateInFrame('window.location.href');
138
131
  context.url = urlResult.result.value || '';
139
132
  } catch {
140
133
  context.url = null;
@@ -142,14 +135,11 @@ export async function captureFailureContext(deps, options = {}) {
142
135
 
143
136
  try {
144
137
  // Get scroll position
145
- const scrollResult = await pageController.session.send('Runtime.evaluate', {
146
- expression: `({
138
+ const scrollResult = await pageController.evaluateInFrame(`({
147
139
  x: window.scrollX || document.documentElement.scrollLeft,
148
140
  y: window.scrollY || document.documentElement.scrollTop,
149
141
  maxY: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - window.innerHeight
150
- })`,
151
- returnByValue: true
152
- });
142
+ })`);
153
143
  const scroll = scrollResult.result.value;
154
144
  context.scrollPosition = {
155
145
  x: scroll.x,
@@ -163,8 +153,7 @@ export async function captureFailureContext(deps, options = {}) {
163
153
 
164
154
  try {
165
155
  // Get visible buttons with refs (limit 8)
166
- const buttonsResult = await pageController.session.send('Runtime.evaluate', {
167
- expression: `
156
+ const buttonsResult = await pageController.evaluateInFrame(`
168
157
  (function() {
169
158
  const buttons = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"], [role="button"]'));
170
159
  return buttons
@@ -193,9 +182,7 @@ export async function captureFailureContext(deps, options = {}) {
193
182
  return { text, selector, ref };
194
183
  });
195
184
  })()
196
- `,
197
- returnByValue: true
198
- });
185
+ `);
199
186
  context.visibleButtons = buttonsResult.result.value || [];
200
187
  } catch {
201
188
  context.visibleButtons = [];
@@ -203,8 +190,7 @@ export async function captureFailureContext(deps, options = {}) {
203
190
 
204
191
  try {
205
192
  // Get visible links (limit 5)
206
- const linksResult = await pageController.session.send('Runtime.evaluate', {
207
- expression: `
193
+ const linksResult = await pageController.evaluateInFrame(`
208
194
  (function() {
209
195
  const links = Array.from(document.querySelectorAll('a[href]'));
210
196
  return links
@@ -219,9 +205,7 @@ export async function captureFailureContext(deps, options = {}) {
219
205
  href: a.href ? a.href.substring(0, 100) : ''
220
206
  }));
221
207
  })()
222
- `,
223
- returnByValue: true
224
- });
208
+ `);
225
209
  context.visibleLinks = linksResult.result.value || [];
226
210
  } catch {
227
211
  context.visibleLinks = [];
@@ -229,8 +213,7 @@ export async function captureFailureContext(deps, options = {}) {
229
213
 
230
214
  try {
231
215
  // Get any visible error messages or alerts
232
- const errorsResult = await pageController.session.send('Runtime.evaluate', {
233
- expression: `
216
+ const errorsResult = await pageController.evaluateInFrame(`
234
217
  (function() {
235
218
  const errorSelectors = [
236
219
  '.error', '.alert', '.warning', '.message',
@@ -252,9 +235,7 @@ export async function captureFailureContext(deps, options = {}) {
252
235
  }
253
236
  return errors.slice(0, 3);
254
237
  })()
255
- `,
256
- returnByValue: true
257
- });
238
+ `);
258
239
  context.visibleErrors = errorsResult.result.value || [];
259
240
  } catch {
260
241
  context.visibleErrors = [];
@@ -264,8 +245,7 @@ export async function captureFailureContext(deps, options = {}) {
264
245
  if (failedSelector || failedText) {
265
246
  try {
266
247
  const searchTerm = failedText || failedSelector;
267
- const nearMatchesResult = await pageController.session.send('Runtime.evaluate', {
268
- expression: `
248
+ const nearMatchesResult = await pageController.evaluateInFrame(`
269
249
  (function() {
270
250
  const searchTerm = ${JSON.stringify(searchTerm)}.toLowerCase();
271
251
  const candidates = [];
@@ -322,9 +302,7 @@ export async function captureFailureContext(deps, options = {}) {
322
302
  candidates.sort((a, b) => b.score - a.score);
323
303
  return candidates.slice(0, 5);
324
304
  })()
325
- `,
326
- returnByValue: true
327
- });
305
+ `);
328
306
  context.nearMatches = nearMatchesResult.result.value || [];
329
307
  } catch {
330
308
  context.nearMatches = [];
@@ -53,7 +53,7 @@ function processSerializedResult(raw) {
53
53
  * @returns {Promise<Object>} serialized return value
54
54
  */
55
55
  export async function executePageFunction(pageController, params) {
56
- const fn = typeof params === 'string' ? params : params.fn;
56
+ const fn = typeof params === 'string' ? params : (params.fn || params.expression);
57
57
  const useRefs = typeof params === 'object' && params.refs === true;
58
58
  const timeout = typeof params === 'object' && typeof params.timeout === 'number'
59
59
  ? params.timeout : null;
@@ -150,11 +150,11 @@ export async function executePoll(pageController, params) {
150
150
  const rawVal = result.result.value;
151
151
  const isTruthy = rawVal !== null && rawVal !== undefined &&
152
152
  rawVal !== false && rawVal !== 0 && rawVal !== '' &&
153
- !(typeof rawVal === 'object' && rawVal.type === 'null') &&
154
- !(typeof rawVal === 'object' && rawVal.type === 'undefined') &&
155
- !(typeof rawVal === 'object' && rawVal.type === 'boolean' && rawVal.value === false) &&
156
- !(typeof rawVal === 'object' && rawVal.type === 'number' && rawVal.value === 0) &&
157
- !(typeof rawVal === 'object' && rawVal.type === 'string' && rawVal.value === '');
153
+ !(typeof rawVal === 'object' && rawVal !== null && rawVal.type === 'null') &&
154
+ !(typeof rawVal === 'object' && rawVal !== null && rawVal.type === 'undefined') &&
155
+ !(typeof rawVal === 'object' && rawVal !== null && rawVal.type === 'boolean' && rawVal.value === false) &&
156
+ !(typeof rawVal === 'object' && rawVal !== null && rawVal.type === 'number' && rawVal.value === 0) &&
157
+ !(typeof rawVal === 'object' && rawVal !== null && rawVal.type === 'string' && rawVal.value === '');
158
158
 
159
159
  if (isTruthy) {
160
160
  return { resolved: true, value: processed, elapsed: Date.now() - start };
@@ -393,7 +393,8 @@ export async function loadSiteProfile(domain) {
393
393
  */
394
394
  export async function executeWriteSiteProfile(params) {
395
395
  if (!params || !params.domain || !params.content) {
396
- throw new Error('writeSiteProfile requires domain and content');
396
+ const providedKeys = params ? Object.keys(params).join(', ') : 'none';
397
+ throw new Error(`writeSiteProfile requires domain and content (got keys: ${providedKeys})`);
397
398
  }
398
399
 
399
400
  const clean = sanitizeDomain(params.domain);
@@ -47,8 +47,7 @@ export async function executeExtract(deps, params) {
47
47
  throw new Error('extract requires a selector');
48
48
  }
49
49
 
50
- const result = await session.send('Runtime.evaluate', {
51
- expression: `
50
+ const extractExpr = `
52
51
  (function() {
53
52
  const selector = ${JSON.stringify(selector)};
54
53
  const typeHint = ${JSON.stringify(type)};
@@ -160,11 +159,15 @@ export async function executeExtract(deps, params) {
160
159
  };
161
160
  }
162
161
 
163
- return { error: 'Could not detect data type. Use type: "table" or "list" option.', detectedType };
162
+ // Fallback: extract text content when element is not a table or list
163
+ const text = (el.textContent || '').trim();
164
+ return { type: 'text', text, tagName };
164
165
  })()
165
- `,
166
- returnByValue: true
167
- });
166
+ `;
167
+ const extractArgs = { expression: extractExpr, returnByValue: true };
168
+ const contextId = pageController.getFrameContext();
169
+ if (contextId) extractArgs.contextId = contextId;
170
+ const result = await session.send('Runtime.evaluate', extractArgs);
168
171
 
169
172
  if (result.exceptionDetails) {
170
173
  throw new Error('Extract error: ' + result.exceptionDetails.text);
@@ -254,15 +257,12 @@ export async function executeAssert(pageController, elementLocator, params) {
254
257
 
255
258
  try {
256
259
  // Get the text content of the target element
257
- const textResult = await pageController.session.send('Runtime.evaluate', {
258
- expression: `
260
+ const textResult = await pageController.evaluateInFrame(`
259
261
  (function() {
260
262
  const el = document.querySelector(${JSON.stringify(selector)});
261
263
  return el ? el.textContent : null;
262
264
  })()
263
- `,
264
- returnByValue: true
265
- });
265
+ `);
266
266
 
267
267
  const actualText = textResult.result.value;
268
268
  textAssertion.found = actualText !== null;
@@ -229,8 +229,8 @@ export async function executeFillActive(pageController, inputEmulator, params) {
229
229
  const session = pageController.session;
230
230
 
231
231
  // Parse params
232
- const value = typeof params === 'string' ? params : params.value;
233
- const clear = typeof params === 'object' ? params.clear !== false : true;
232
+ const value = typeof params === 'string' ? params : (params && params.value);
233
+ const clear = typeof params === 'object' && params !== null ? params.clear !== false : true;
234
234
 
235
235
  // Check if there's an active element and if it's editable
236
236
  const checkResult = await session.send('Runtime.evaluate', {